本章简介
本章主要介绍了二叉堆和索引堆的概念以及堆排序的实现与改进
包括了shiftUp,shiftDown,Heapify,change等与堆相关的主要函数的构造过程
同时借此章对排序[十]到[十二]章的排序算法做一个总结与比较
堆和优先队列 Heap and Priority Queue
普通队列满足先进先出,后进后出的原则,由时间的顺序决定出队的顺序
而在优先队列中,出队顺序与入队顺序无关,而与优先级相关
举例来说:急诊病人的优先级高于普通病人,故就算普通病人到的比急诊病人早,也要优先治疗急诊病人;
在操作系统中,每个任务都有一个优先级,操作系统会动态的选择优先级最高的任务执行;
游戏中AI决定攻击敌人的顺序也要用到优先队列,比如选择最强的敌人或者选择血量最少的敌人攻击;
其实在一些静态的问题中,优先队列也有其优势
比如:在n个元素中选择前m个元素的算法中,利用先排序再选前m个元素的时间复杂度最低为O(nlogn),但是使用了优先队列可以将其降低到O(nlogm)的级别
优先队列的实现:
对于优先队列来说,操作有两个:入队和选择最高优先级的出队;
优先队列的实现可以通过普通数组,顺序数组和堆进行:
其中普通数组入队需要O(1)的时间,直接新元素放入即可,出队的时候为了取出优先级最高的元素则需要将数组从头到尾扫描一遍后再找出优先级最高的元素,消耗时间为O(n);
顺序数组入队的时候需要O(n)的时间找到此元素优先级应该排在的位置,这样出队只需O(1)出队头元素即可;
而想要很好的平衡好入队和出队的时间复杂度,我们就需要引入一个新的数据结构:堆
在堆中,入队和出队的时间复杂度均为O(logn);
综上,对于总共n个请求的优先队列,使用普通数组和顺序数组的最差时间复杂度为O(n^2),而利用堆后时间复杂度稳定在O(nlogn)上
下面我们来开始进入堆的构造
二叉堆 Binary Heap
先给出一张最大堆的示例图:
我们规定最大堆为:
1.堆中某个节点的值不大于其父亲节点的值(这不意味着层级越高节点值越大,比如42 > 30)
2.堆总是一棵完全二叉树
关于完全二叉树的定义可参考:
最小堆类推可得
我们观察最大堆发现除了用树形结构data/*lchild/*rchild的方法,还可以用数组的方法写出最大堆:
根据这个序号依次排下去可发现对于父亲节点n来说,其左儿子序号为2n,右儿子编号为(2n + 1);
即:
parent(i) = i / 2; //奇数节点会自动取整
lchild(i) = 2 * i;
rchild(i) = 2 * i + 1;
最大堆的基本骨架如下:
我们在main函数中开辟一个有100个节点的最大堆:
注意100是开辟的节点个数,而返回的count值是节点里元素的个数,故现在返回值为0
下面我们讨论如何向堆中添加新的元素
Shift Up
假如我们新添加了一个元素,那么我们先把它放在最后,然后维护最大堆的性质:
即让新添加的元素和其父节点比较,若新添加的元素大于父节点元素值,则交换位置,然后再继续比较并交换直到父节点大于子节点
据此分析我们尝试着写出插入元素的函数:
其中shiftUp如下:
注意红框中判断数组越界的问题.
其实在insert函数的"data[count + 1] = item;"中也有可能出现越界情况,故我们在private中定义一个int类型的capacity:
并于insert执行时先行assert即可保证容量足够:
查看结果是否满足最大堆:
更改元素个数依然成立:
Shift Down
将顶上的元素移走后将最后一个元素放置最顶上,并且计数器count--
然后将移动的这个元素与他的左右孩子比较,将其与左右孩子中较大的一个进行交换,并递归进行这一操作直到满足最大堆的性质即可
extractMax代码如下:
调用的shiftDown代码如下:
思路是先比较再将需要交换的位置赋给j即可
其中 j = 2 * k 为左孩子,要判断右孩子需要 j +1 <=count
然后我们编写测试用例:
结果如下:
可以看到这是一个递减的序列,说明经历了shiftDown的操作后改堆仍满足最大堆的定义,函数正确
基础堆排序
利用堆进行排序,只需要每次调用extractMax提取出堆中最大的元素,因为想从小到大排序,所以从尾到头的对数组进行写入即可,heapSort1代码如下:
两个for循环为n级别,而insert()和extractMax()都为logn级别的(因为shiftUp和shiftDown这种一层层查找的操作都是和层序相关的),所以堆排序的时间复杂度为O(nlogn)
同样的,我们对其进行横向比较后发现,在很多情况下,上述堆排序的速度比其他优化过的排序算法速度略逊一筹:
下面开始讨论如何对其进行优化
Heapify
1.完全二叉树叶子节点可以看做是最大堆,每个堆中元素只有一个;
2.我们从后向前的去考察不是叶子节点的节点,其中第一个非叶子节点的序号为:
最后一个叶子节点的序号/2;
3.若发现非叶子节点和它的叶子不满足最大堆性质,则做shiftDown;
4.重复2-3步直到结束
这是一个自下而上的建堆思路
它的算法复杂度分析如下:
我们大致可以这么想,我们一上来就砍去了一半的元素处理量,那么这个算法一定相对快些;
具体的解释如下:
将n个元素逐一插入到空堆中,算法复杂度为O(nlogn);
但是Heapify的过程算法复杂度为O(n);
证明详见:
堆新的构造函数如下:
注意堆的序号仍然是从1开始
于是堆排序的改进版本heapSort2代码如下:
从比较结果看来,heapSort2的速度均快于heapSort1:
但是堆排序的速度离归并排序和快速排序还有一定的距离,故在系统级别很少有使用堆排序的,堆更多用于动态数据的维护
原地堆排序
1.拥有一个最大堆,此时第一个元素是整个数组的最大值
2.将第一个元素与最后一个元素互换位置,即最大值就位
3.对除最后一位之外的元素进行一次shiftDown,把次大元素放到了一个位置
4.重复2-3直到完成排序
整个排序的空间复杂度为O(1)
由于是在数组中实现排序,故一般从0开始,对上述算法进行调整
节点i调整为:
左孩子:2 * i + 1
右孩子:2 * i + 2
父亲节点:(i - 1)/ 2
最后一个非叶子节点:(最大索引 - 1)/ 2
由于不需要开辟新空间,向新空间里赋值等操作,故其预期速度应该会比前两种堆排序的速度更快一些:
排序算法的稳定性 Stable
对于相等的元素,在排序后,原来靠前的仍然靠前,即相等元素相对位置没有发生改变,那么我们说该排序具有稳定性
注意排序稳定性的实现和具体程序有关,下面讨论的只是该排序算法是否能够做到稳定,并不是说只要写出了这个排序算法就一定稳定
下面我们来逐一分析各个算法的稳定性:
插入排序:
与前面一位相比,只有在小于的时候才交换位置,在等于的情况下并不交换位置,所以后面的元素不会超过前面相同的元素,故为稳定排序
归并排序:
在归并的过程中指针指向两个相同的元素时,只有当后面的元素小于前面的元素时才将后面的元素放进数组中,否则都是前面的元素放入其中,故为稳定排序
快速排序:
标定点为随机选择,很可能使相等元素顺序打乱,故为不稳定排序
堆排序:
将数组组建为堆的时候可能破坏先后顺序,故为不稳定排序
我们可以自定义比较函数使排序算法不存在稳定性的问题,以学生的分数与姓名比较来说,若不稳定我们只能在比较的时候加入对学生姓名的比较:
排序算法总结
理论上存在平均时间复杂度为O(nlogn),原地排序,额外空间O(1)且稳定的神秘排序算法,不过目前还没有研究出来
索引堆 Index Heap
在构建堆的过程中改变元素的位置会产生一系列问题:
1.如果元素本身十分复杂的话,如字符串,就会使得交换过程变得异常漫长
2.整个元素在数组中的位置发生改变导致我们很难索引到改变了的元素,不同于之前普通数组的O(1),我们需要将整个堆遍历一遍才能找到相对的元素,太过低效
对于索引堆来说,我们将索引indexes和数据data分开存储,将构建堆的过程变成索引移动的过程,因为索引是int类型,所以无论数据本身有多复杂,都不会对交换产生影响;其次,在数据改变后我们维持堆的性质之时,我们只要改变indexes即可
换句话说,在做数据比较的时候,我们比较的是data,而在比较完之后交换的是indexes
我们在堆的类中增加索引:
增加对indexes的初始化:
在插入函数insert中,我们的indexes是从1开始而不是0,但是从用户的角度看是从0开始的,所以我们要在内部进行更改:
shiftUp中,我们对data进行比较时需要通过indexes进行拿取,而在交换过程中用的是indexes进行交换而不变动data,所以修改如下:
删除操作extractMax也用同样的方式修改:
shiftDown函数修改如下:
索引堆可以增加一些特殊操作,比如extractMaxIndex:
记得要将内部从1开始的索引减1变成从0开始
我们也可以通过索引值取出数据:
而最重要的就是利用索引堆来修改数据的内容了,完成这个操作的change函数如下:
数据改变后为了维持堆的性质,我们需要考察修改后的元素的index能否向上挪动?能否向下挪动?
首先要找到一个indexes[ j ] = i,这时这个索引j就是表示data[ i ]在堆中的位置
为了找到j,我们可以用for来寻找,这里遍历一遍为O(n)
找到j之后,我们只需要对其进行shiftUp和shiftDown操作即可维护堆的性质,这里的shiftUp和shiftDown都是O(logn)级别的
在最差情况下change函数的时间复杂度是O(n + logn) = O(n)级别的
而O(n)级别的change其实还能够继续进行优化
change函数的反向查找
增加一行名叫rev的数组,用它来记录data所对应的indexes
reverse[ i ]表示索引i在indexes堆中的位置,它具有以下性质:
indexes[ i ] = j ;
reverse[ j ] = i ;
reverse[ indexes[ i ] ] = i ;
indexes[ reverse[ i ] ] = i ;
在类中增加reverse,并在初始化与析构函数中进行相应的改变:
当索引不存在是我们把reverse置0,因为堆中是从1开始赋indexes的,所以0就代表无索引
下面我们来进行维护操作,维护操作的思路就是在每次indexes发生变更的时候要加入对reverse的相应操作,具体看来,在insert操作中我们可以这样修改:
shiftUp中k / 2与k这两个位置的索引发生了交换,所以我们要据此修改reverse的值:
这样插入操作就修改完成了
extractMax和extractMaxIndex中,我们都要相应的对其进行更改,在这里以extractMax为例:
注意我们对reverse[ indexes[ count ] ],即索引indexes[ count ]在indexes堆中的位置设为0(即不存在)是因为indexes[ count ]在之后的count--操作过后就被删除了,所以没有可供reverse的索引
shiftDown修改同shiftUp:
下面我们可以开始修改change函数了,思路是找到indexes[ j ] = i, 其中的j就表示了data[ i ]在堆中的位置,这步操作的时间复杂度降至O(1),然后直接shiftUp再shiftDown即可,这步操作为O(logn),故change的时间复杂度为O(logn)级别,性能提升:
下面我们进一步考虑change的安全性,需要对待修改的传入参数i进行规定
值得注意的是,并不是说i在capacity的范围中就一定在堆中
于是我们编写一个contain函数进行判断:
getItem函数中也存在类似的问题,可以按照上述代码一并补齐
而contain函数因为有了reverse就很好进行判断了:
同样,从用户角度看的i需要加1后进行操作
多路归并排序
堆可以在多路归并排序中起作用,我们对待操作数组使用归并排序的常规操作方法是将其一分为二分别排序后进行归并,但是我们可以将其一分为n,并利用最小堆比较每个分组中第一个元素的大小,推出最小元素后将推出分组中第二个元素推进最小堆再次比较,重复至排序完成即可
我们可以将其推广至n个元素的d路归并排序,其中d的取值是一个平衡层数和每次操作次数的过程,从极端情况下来看,若d = n,则只需一层即可完成,每个分组只有一个元素,这时归并排序就退化为了堆排序
D叉堆 D-ary Heap
每一个节点只有左右两个孩子的堆为二叉堆
按照上述多路归并排序的思路,我们可以创建d叉堆,d的选择也是性能的平衡,d越大,层数越低,但在shiftUp和shiftDown时比较元素的次数就会增加
总结
堆在实际编程时有很多的用途
利用堆可以对系统任务优先级的及时调整,游戏角色AI自动攻击的选择等操作进行优化
如在含M个元素的较大数组中选出前N个元素,就可以用含有N个元素的最小堆进行维护,使得时间复杂度降低为O(NlogM)级别
扩展