不同于前面几篇O(n^2)或O(n*logn)排序算法,此篇文章将讲解另一个排序算法——堆排序,也是此系列的第一个数据结构—–堆,需要注意的是在堆结构中排序是次要的,重要的是堆结构及衍生出来的数据结构问题,排序只是堆应用之一。
此篇涉及的知识点有:
挖掘算法中的数据结构(一):选择、插入、冒泡、希尔排序 及 O(n^2)排序算法思考
挖掘算法中的数据结构(二):O(n*logn)排序算法之 归并排序(自顶向下、自底向上) 及 算法优化
挖掘算法中的数据结构(三):O(n*logn)排序算法之 快速排序(随机化、二路、三路排序) 及衍生算法
首先来了解堆的经典应用—–优先队列,此概念并不陌生:
优先队列在OS的使用
而优先队列这种机制在计算机中被大量使用,最典型应用就是操作系统执行任务,它需要同时执行多个任务,而实际上是将CPU执行周期划分时间片,在时间片中执行一个任务,每一个任务都有优先级,OS动态选择优先级最高的任务执行,所以需要使用优先队列,所有任务进行优先队列,由队列来进行调度需要执行哪个任务。
为什么使用优先队列?
注意“动态”的重要性,如果任务是固定的话,可以将这些任务排序好安装优先级最高到最低依次执行,可是实际处理确要复杂得多。如下图:蓝色任务处理中心就类似CPU,由它来处理所有请求(红色代表Request)。选择执行某个请求后,下一步不是简单地选择另一个请求执行,与此同时可能会来新的任务,不仅如此,旧的任务优先级可能会发生改变,所以将所有任务按优先级排序再依次执行是不现实的。
所以优先队列模型不仅适用于OS,更存在与生活中方方面面,例如大家同时请求某个网页,服务器端需要依次回应请求,回应的顺序通常是按照优先队列决定的。
优先队列处理“静态问题”
前面一直在强调优先队列善于处理“动态”的情况,但其实对于“静态”也是十分擅长,例如在1,000,000个元素中选出前100名,也就是“在N个元素中选出前M个元素”。
在前三篇博文中学习了排序算法后,很快得到将所有元素排序,选出前M个元素即可,时间复杂度为O(n*logn)。但是使用了优先队列,可将时间复杂度降低为O(n *logM)!具体实现涉及到优先队列实现,后续介绍。
优先队列主要操作
优先队列采用的数据结构:
举个例子,对于总共N个请求:
因此若要实现优先队列,必须采用堆数据结构,下面介绍堆有关知识及如何实现。
(1)概念特征
在以上了解堆中操作都是O(n *logn)级别,应当知道堆相应的是一种树形结构,其中最为经典的是二叉堆,类似于二叉树,每一个节点可以有两个子节点,特点:
注意:第一个特征中说明在二叉树上任何一个子节点都不大于其父节点,并不意味着层数越高节点数越大,这都是相对父节点而言的。例如第三层的19比第二层的16大。
这样的二叉堆又被称为“最大堆”,父节点总是比子节点大,同理而言“最小堆”中父节点总是比子节点小,这里只讲解“最大堆”。
(2)结构实现
对于其具体实现,熟悉树形结构的同学可能认为需要两个指针来实现左、右节点,当然可以这样实现,但是还有一个经典实现方式——通过数组实现,正是因为堆是一棵完全的二叉树。
将这棵二叉树自上到下、自左到右地给每一个节点标上一个序列号,如下图所示。对于每一个父节点而言:
(这里的根节点下标是由1开始而得出以上规则,但其实由0开始也可得出相应的规则,此部分重点还是放在下标1开始)
(3)基本结构代码实现
template
class MaxHeap{
private:
Item *data;
int count;
public:
// 构造函数, 构造一个空堆, 可容纳capacity个元素
MaxHeap(int capacity){
data = new Item[capacity+1];
count = 0;
}
~MaxHeap(){
delete[] data;
}
// 返回堆中的元素个数
int size(){
return count;
}
// 返回一个布尔值, 表示堆中是否为空
bool isEmpty(){
return count == 0;
}
};
// 测试 MaxHeap
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
cout<return 0;
}
以上C++代码并不复杂,只是简单实现了最大堆(MaxHeap)的基本结构,定义了data值,因为不知道值的具体类型,通过模板(泛型)结合指针来定义,提供简单的构造、析构、简单函数方法。
在完成代码的二叉堆基本结构后,需要实现最重要的两个操作逻辑,即Shift Up 和 Shift Down。
(1)Shift Up
下面就实现在二叉堆中如何插入一个元素,即优先队列中“入队操作”。以下动画中需要插入元素52,由于二叉堆是用数组表示,所以相当于在数组末尾添加一个元素,相当于52是索引值11的元素。
算法思想
注意!其实整个逻辑思想完全依赖于二叉树的特征,因为在二叉堆上任何一个子节点都不大于其父节点,所以需要将新插入的元素挪到合适位置来维护此特征:
代码实现
在MaxHeap中新增一个insert
方法,传入新增元素在二叉堆中的下标
//将下标k的新增元素放入到二叉堆中合适位置
void shiftUp(int k){
while( k > 1 && data[k/2] < data[k] ){//边界&&循环与父节点比较
swap( data[k/2], data[k] );
k /= 2;
}
}
// 像最大堆中插入一个新的元素 item
void insert(Item item){
assert( count + 1 <= capacity );
data[count+1] = item;//注意下标是从1开始,所以新增元素插入位置为count+1,并非count
count ++;//数量增加1
shiftUp(count);
}
注意:以上代码中严格需要注意边界问题,因为在创建MaxHeap已设置好数组个数MaxHeap
,所以在上述insert
中使用了assert函数来判断,若超过数组长度则不插入。其实这里有另外一种更好的解决方法,就是超过时动态增加数组长度,由于此篇重点为数据结构,留给各位实现。
测试:
创建一个长度为20的数组,随机数字循环插入,最后打印出来,结果如下:(测试代码不粘贴,详细见源码)
(2)Shift Down
上一部分讲解了如何从二叉堆中插入一个元素,此部分讲解如何取出一个元素,即优先队列中“出队操作”。
算法思想
代码实现
void shiftDown(int k){
while( 2*k <= count ){
int j = 2*k; // 在此轮循环中,data[k]和data[j]交换位置
if( j+1 <= count && data[j+1] > data[j] )
j ++;
// data[j] 是 data[2*k]和data[2*k+1]中的最大值
if( data[k] >= data[j] ) break;
swap( data[k] , data[j] );
k = j;
}
}
// 从最大堆中取出堆顶元素, 即堆中所存储的最大数据
Item extractMax(){
assert( count > 0 );
Item ret = data[1];
swap( data[1] , data[count] );
count --;
shiftDown(1);
return ret;
}
测试
首先设置二叉堆长度为20,使用MaxHeap中的insert
方法随机插入20个元素,再调用extractMax
方法将数据逐渐取出来,取出来的顺序应该是按照从大到小的顺序取出来的。
// 测试最大堆
int main() {
MaxHeap<int> maxheap = MaxHeap<int>(100);
srand(time(NULL));
int n = 20; // 随机生成n个元素放入最大堆中
for( int i = 0 ; i < n ; i ++ ){
maxheap.insert( rand()%100 );
}
int* arr = new int[n];
// 将maxheap中的数据逐渐使用extractMax取出来
// 取出来的顺序应该是按照从大到小的顺序取出来的
for( int i = 0 ; i < n ; i ++ ){
arr[i] = maxheap.extractMax();
cout<" ";
}
cout<// 确保arr数组是从大到小排列的
for( int i = 1 ; i < n ; i ++ )
assert( arr[i-1] >= arr[i] );
delete[] arr;
return 0;
}
结果
在学习以上二叉堆实现后,发现它同样可用于排序,不断调用二叉堆的extractMax
方法,即可取出数据。(从大到小的顺序)
// heapSort1, 将所有的元素依次添加到堆中, 在将所有元素从堆中依次取出来, 即完成了排序
// 无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)
// 整个堆排序的整体时间复杂度为O(nlogn)
template<typename T>
void heapSort1(T arr[], int n){
MaxHeap maxheap = MaxHeap(n);
for( int i = 0 ; i < n ; i ++ )
maxheap.insert(arr[i]);
for( int i = n-1 ; i >= 0 ; i-- )
arr[i] = maxheap.extractMax();
}
(1)测试
所以以下将二叉堆和之前所学到的O(n*logn)排序算法比较测试,分别对
以上3组测试用例进行时间比较,结果如下(测试代码查看github源码):
虽然二叉堆排序使用的时间相较于其它排序算法要慢,但使用时间仍在接收范围内。因为整个堆排序的整体时间复杂度为O(nlogn) ,无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)。总共循环n此,每次循环二叉树操作消耗O(logn),所以最后是O(nlogn)。
但是还可以继续优化,使性能达到更优以上过程创建二叉堆的过程是一个个将元素插入,其实还有更好的方式——Heapify。
(2)Heapify算法思想
给定一个数组,使这个数组形成堆的形状,此过程名为Heapify。例如以下数组{15,17,19,13,22,16,28,30,41,62}:
此数组形成的二叉树并非最大堆,不满足特征。但是上图中的叶子节点,即最后一层的每个节点可看作是一个最大堆(因为只有它一个节点)。接着再向上递进一层:
(3)代码实现
所以,此堆排序的优化就是修改其创建方法,不通过一个一个元素插入来创建二叉堆,而是通过Heapify方法来完成创建,此过程消耗的时间复杂度为O(n),性能更优。
需要修改MaxHeap中的构造函数,传入参数为无序的数组和数组长度,首先开辟空间,下标从1开始将数组元素值赋值到新数组中,再结合Shift Down方法层层递进。
// 构造函数, 通过一个给定数组创建一个最大堆
// 该构造堆的过程, 时间复杂度为O(n)
MaxHeap(Item arr[], int n){
data = new Item[n+1];
capacity = n;
for( int i = 0 ; i < n ; i ++ )
data[i+1] = arr[i];
count = n;
for( int i = count/2 ; i >= 1 ; i -- )
shiftDown(i);
}
template
void heapSort2(T arr[], int n){
//优化后的创建二叉堆构造函数
MaxHeap maxheap = MaxHeap(arr,n);
for( int i = n-1 ; i >= 0 ; i-- )
arr[i] = maxheap.extractMax();
}
(4)测试
通过优化后的创建二叉堆构造函数再次测试,结果如下:
可明显看出优化创建二叉堆构造函数后,堆排序使用时间更少
结论
将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn),而使用Heapify的过程,算法复杂度为O(n)
不同于其他排序算法,在堆排序中需要将数组元素放入“堆”中,需要开辟新的数组,相当于开了额外的O(n)空间,其实可以继续优化不适用空间原地对元素进行排序。
引出第二个优化 —— 原地堆排序,事实上,按照堆排序的思想,可以原地进行排序,不需要任何额外空间。
算法思想
其思想也很简单,通过之前构造堆这个类的过程已知一个数组可以看成是队列。因此将一个数组构造“最大堆”:
这样所有的元素逐渐排序好,直到整个数组都变成蓝色。使用的空间复杂度是O(1),但是这里需要注意的是,如此一来下标是从0开始并非1,所以规则需要进行相应的调整:
代码实现
// 优化的shiftDown过程, 使用赋值的方式取代不断的swap,
// 该优化思想和我们之前对插入排序进行优化的思路是一致的
template<typename T>
void __shiftDown2(T arr[], int n, int k){
T e = arr[k];
while( 2*k+1 < n ){
int j = 2*k+1;
if( j+1 < n && arr[j+1] > arr[j] )
j += 1;
if( e >= arr[j] ) break;
arr[k] = arr[j];
k = j;
}
arr[k] = e;
}
// 不使用一个额外的最大堆, 直接在原数组上进行原地的堆排序
template<typename T>
void heapSort(T arr[], int n){
// 注意,此时我们的堆是从0开始索引的
// 从(最后一个元素的索引-1)/2开始
// 最后一个元素的索引 = n-1
for( int i = (n-1-1)/2 ; i >= 0 ; i -- )
__shiftDown2(arr, n, i);
for( int i = n-1; i > 0 ; i-- ){
swap( arr[0] , arr[i] );
__shiftDown2(arr, i, 0);
}
}
测试:
分别测试原始Shift Down堆排序 和 Heapify堆排序 和 原地堆排序的时间消耗。
从结构得知优化后的原地堆排序快于之前原始Shift Down堆排序和Heapify堆排序,因为新的算法不需要额外的空间,也不需要对这些空间赋值,所以性能有所提高。
所有以上解决算法详细代码请查看liuyubo老师的github:
https://github.com/liuyubobobo/Play-with-Algorithms
前三篇博文介绍的排序算法及以上讲解完的堆排序完成,意味着有关排序算法已讲解完毕,下面篇博文对这些排序算法进行比较总结,并且学习另一个经典的堆结构,处于二叉堆优化之上的索引堆。
若有错误,虚心指教~