需求与动机
什么是优先级队列
优先队列是计算机科学中的一类抽象数据类型。优先队列中的每个元素都有各自的优先级,优先级最高的元素最先得到服务;优先级相同的元素按照其在优先队列中的顺序得到服务。优先队列往往用堆来实现。
应用需求
在医院门诊,如果只有一个医生,多位病人。按照通常流程来说,采用先到先服务的顺序(FIFO)。但是如果病人中有人患有心脏病,那么显然这个病人需要得到优先治疗。
在计算机系统中,绝大多数支持多任务,这种多任务调度也类似于医院门诊。CPU相当于医生,计算任务相当于病人。每个任务都有一个优先级指标(priority),优先级高的任务可以被CPU优先处理。
在算法中,也有大量应用。比如堆排序、霍夫曼编码等。
计算模式
以上问题可以被归纳为这样一种模式:服务端(医生、CPU、霍夫曼算法…)通过一种叫call-by-priority(循优先级访问)的方式访问客户数据(病人,任务,待编码字符…),客户数据都有一个优先级指标。这种访问方式需要对应一种数据结构,可以记录、维护所有数据的优先级指标,并通过接口对这些数据进行操作。
功能接口
以上为优先级队列(简称PQ)的接口定义规范。可以说PQ是一种抽象数据类型(ADT),不同的实现方式可以产生不同的数据结构,比如栈和队列(根据插入次序设置优先级)。
那么,这样一种数据结构具体怎么实现呢?
基本实现
采用不同数据结构实现PQ的接口效率对比
数据结构 | insert(T)效率 | getMax()效率 | delMax()效率 |
---|---|---|---|
向量 | θ(n) | θ(n) | O(1) |
有序向量 | O(n) | O(1) | o(1) |
列表 | O(1) | θ(n) | θ(n) |
有序列表 | O(n) | O(1) | O(1) |
BBST | O(logn) | O(logn) | O(logn) |
虽然BBST的效率是最好的,但是BBST的功能比PQ需要实现的功能多得多:比如PQ对于查找和删除只针对最大元素,而BBST是针对所有元素。
因此可以使用一种成本更低,效率更高的实现方式。这种结构应该介于基本数据结构向量和复杂数据结构平衡二叉树之间。
完全二叉堆
要介绍完全二叉堆,先要介绍完全二叉树。这里通过使用向量来表示一个完全二叉树,从而实现PQ结构。
1. 可以看到完全二叉树的节点与向量的节点是一一对应的(层次遍历),只要定义向量的每个节点的父节点、左孩子和右孩子,即可构建出完全二叉树。
2. 这种做法的好处是:物理结构是线性的向量,但是逻辑结构是完全二叉树。这种结构称为“完全二叉堆”。
完全二叉堆模板类:
可以看到,其作为PQ实现了insert,getMax,delMax三个标准接口,还实现了批量建堆的接口。另外,这些接口的实现利用了三个内部方法:下滤、上滤和Floyd建堆算法。
- 堆序性
- 堆序性是完全二叉堆关于节点间次序的约束:任何一个节点在数值上都不大于其父节点。
- 因此,完全二叉堆的最大元素为根节点,对应的就是向量首元素。
- 插入与上滤
- 在完全二叉堆中插入元素,只需以末元素插入向量即可。这样做的好处是不会破坏完全二叉树的结构性,新加入节点后仍然保持完全二叉树特征。但是这样会破坏堆序性——新插入的元素的值可能大于其父节点。
- 如果新元素大于其父节点,那么将该元素与其父元素对换位置即可重新恢复堆序性。若交换后的元素仍然大于其父节点,则继续交换,直到抵达根节点。这样一种逐层上升的过程称之为“上滤”。上滤完成后,完全二叉堆得到了完全恢复。
插入与上滤实现代码:
- 可以看到,上滤的一次过程的时间为常数。而完全二叉树的树高在logn范围内。因此上滤过程的时间复杂度也在O(logn)范围。
- 这个效率可以更优化:在每次swap()交换时,都使用了三次赋值操作,相当于3logn时间。如果先将插入的元素进行一次备份,在每次交换时,只将其父节点下移,直到不能再上滤时,再把备份元素加入到最终位置。这样可以把赋值操作降低到logn+2。
- 另外,完全二叉堆的平均效率要比最坏效率好很多,平均上滤的高度是常数。因此,完全二叉堆是“低成本,高效率”的典型数据结构。
- 删除与下滤
删除最大元素之后,完全二叉堆的结构已经被破坏。为了重建,可以把最后一个元素移至根元素。移动之后,堆序性被破坏。这里与上滤的过程相似,依次把根元素与其孩子节点进行比较,如果比它们至少其中之一小,那么与它们中较大的节点交换。交换之后该元素下降了一层。然后继续重复下滤过程,直至满足堆序性为止。此过程完成后,整个堆的堆序性都得到了恢复。
代码实现:
与上滤算法一样,下滤算法的时间复杂度也是O(logn),交换过程也可以优化为logn次。但是与上滤算法每次只需比较一次不同的是,下滤算法每次需要和两个子节点进行比较。因此在常系数意义上,下滤算法花费时间更多。这一差异在二叉堆难以体现,在多叉堆中则比较明显。
- 批量建堆
根据任意的n个元素,将其构建成一个完全二叉堆。这个过程也称作“heapification”。算法思路是首先调用向量的copyFrom接口将数组复制到内部,再使用heapify()算法建堆。
上一个算法称为“蛮力”算法。大致思路是按照层次遍历的顺序(从上至下,从左往右)依次插入元素,并完成上滤操作。
这个算法的效率是底下的:就拿叶子节点而言,其深度为log(n)。对于每一次上滤操作的时间复杂度都为O(logn),一共有n/2个叶子节点,因此总体时间复杂度为O(nlogn)。
而这种采用自下而上的下滤算法的思路是,先按照层次遍历构建二叉树。然后以自下而上,从右往左的顺序对非叶子节点使用delMax算法的后半部分建堆。如图所示:
- 对于数量为n的向量而言,其最后一个非叶子节点对应的秩为?n2??1。
- 这种算法的时间复杂度为O(n)。其比蛮力算法高效的原因在于:蛮力算法对多个元素进行了重复比较与交换操作。而下滤算法对一层的元素进行排序之后,不会再被比较和交换。蛮力算法其实是实现了所有元素的全排序,而下滤算法仅实现了“偏序”。
堆排序
堆排序和选择排序的方法类似。
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
堆排序也是不断调用delMax方法取出堆顶最大元素进行排序。
但是堆排序相对于选择排序O(n^2)的时间复杂度更高效:每次delMax为logn时间,一共只需nlogn。不仅在时间上,堆排序在空间上也很高效。
由于堆是建立在向量基础上,我们可以充分利用向量空间,使空间复杂度为常数。如上图,将向量空间分为堆空间和已排序元素两个部分,不断从堆空间取出最大元素并移至已排序空间,同时将堆空间最后一个元素移至堆顶。反复这个过程,直至堆空间为空。
算法模板:
排序过程:
左式堆
左式堆的设计是为了堆的高效合并。
一种直接的合并方法是:先将两个堆连接在一起,再通过建堆算法建堆。这种算法的效率为O(n+m)。
在建堆算法中,默认所有元素都是无序的。而现在要合并的两个堆都是已经满足堆序性的结构,因此理论上存在更优的合并策略。这种策略就是引用左式堆。
左式堆是一种特别的堆结构,它满足堆序性,却不满足堆结构:它不是一棵完全二叉树。对于堆而言,结构并不是本质要求。
- NPL
为了使左式堆在逻辑意义上满足二叉树结构,可以引入空节点来填补节点空缺。NPL(空节点路径长度),是指一个节点到一个外部节点的最小距离,用来衡量左式堆倾斜度的指标。
- 左式堆的定义:
任意一个节点的左孩子的npl值都大于等于其右孩子的npl值。
那么对于含有n个节点的左式堆而言,其右侧链长度应为logn。
- 合并算法
左式堆由于不再满足完全二叉树结构,物理上的紧凑性也难以满足。此时再使用向量已不适宜,转而采用二叉树作为派生基类。模板类如下:
左式堆合并的思路为:存在两个左式堆A,B,假设A的根节点大于B。那么以递归的方法,将A的右子堆与堆B合并。合并完成后的堆继续作为A的右子堆。在合并完成后,还需要检查A的左孩子和右孩子的NPL值,如果NPL(L)小于NPL(R),将左右孩子替换。
上图为两个左式堆合并的完整过程。代码如下:
由于左式堆合并过程一直在右侧链进行,因此其时间复杂度不会超过右侧链的长度logn。
- 插入与删除
左式堆的插入其实也是合并操作,将带插入的节点视为只有一个节点的二叉树,与原堆进行合并,即实现了插入操作。
删除操作也是合并。在左式堆的根节点被删除之后,将剩下的左右子堆合并成新的左式堆即可。
由于合并操作的时间复杂度为O(logn),插入删除操作的时间复杂度也为O(logn)。
总结
- 从需求出发,存在一种“循优先级访问”的方式,必须设计这样一种数据结构满足这个要求,这个结构称为“优先级队列”(PQ)。
- 对PQ的功能做了定义之后,分别计算了使用现有数据结构实现的效率,发现只有BBST可以达到三个操作都是O(logn)的时间。但是BBST作为一种高级数据结构,其功能远大于PQ。引出堆这样一种较BBST简单但效率不低于BBST的结构。
- 介绍完全二叉堆的结构和堆序性特点,以及其三个内部方法:上滤、下滤和Floyd建堆。发现其三个标准接口的效率为O(1),O(logn),O(logn)。
- 根据堆的堆序性特点可以对元素进行排序,即每次把最大元素删除即可。堆排序的时间复杂度为nlogn。
- 堆在很多方面效率都很高,但在堆的合并上的效率不太让人满意。由此派生出左式堆这一结构完成堆的高效合并。左式堆的逻辑结构不再是完全二叉树,物理结构换成了二叉树。其重要的特性是NPL,保证了合并的效率为O(logn),且插入删除操作也保持在O(logn)。