数据结构 | 第10章 优先级队列 - 堆

150 阅读6分钟

10.2 堆

列表或向量等结构的实现方式,对优先级的理解过于机械,始终都保存了全体词条之间的全序关系,故无法同时保证insert()和delMax()操作的高效率。比如执行delMax()操作时,只要能够确定全局优先级最高的词条即可,至于次高者、第三高者等其余词条,目前暂不关心。

有限偏序集的极值必然存在,故借助堆(heap)结构维护一个偏序关系即足矣。堆有多种实现形式,以下首先介绍其中最基本的一种形式——完全二叉堆(complete binary heap)。

10.2.1 完全二叉堆

  • 结构性与堆序性

    完全二叉堆应满足两个条件

    1. 逻辑结构须等同于完全二叉树,此即所谓的“结构性”,且不严格区分“堆节点”与“词条”。
    2. 就优先级而言,堆顶以外的每个节点都不高于其父节点,此即所谓的“堆序性”。
  • 大顶堆与小顶堆

    堆中优先级最高的词条必然始终处于堆顶位置,因此,堆结构的getMax()操作总是可以在O(1)时间内完成。

    对称的,也可以约定优先级最低的词条处于堆顶位置。

  • 高度

    n个词条组成的堆高度h = O(logn)。

  • 基于向量的紧凑表示

    尽管二叉树不属于线性结构,但作为其特例的完全二叉树,却与向量有着紧密的对应关系。

    image.png

    若将所有节点组织为一个向量,则堆中各节点(编号)与向量各单元(秩)也将彼此一一对应。

    这一实现方式的优势体现在:

    1. 首先,各节点在物理上连续排列,故仅需O(n)空间。

    2. 更重要的是,利用各节点的编号(秩),可便捷地判别父子关系。

      具体地,若将节点v的编号(秩)记作i(v),则:

      1)若v有左孩子,则i(lchild(v)) = 2·i(v) + 1;

      2)若v有右孩子,则i(rchild(v)) = 2·i(v) + 2;

      3)若v有父节点,则i(parent(v)) = ⌈i(v) / 2⌉ - 1;

    3. 由于向量支持低分摊成本的扩容调整,故随着堆的规模和内容不断地动态调整,除标准接口以外的操作所需的时间可以忽略不计。

  • 为简化完全二叉堆算法的描述及实现而定义的宏

     0001 #define  Parent(i)         ( ( ( i ) - 1 ) >> 1 ) //PQ[i]的父节点(floor((i-1)/2),i无论正负)
     0002 #define  LChild(i)         ( 1 + ( ( i ) << 1 ) ) //PQ[i]的左孩子
     0003 #define  RChild(i)         ( ( 1 + ( i ) ) << 1 ) //PQ[i]的右孩子
     0004 #define  InHeap(n, i)      ( ( ( -1 ) < ( i ) ) && ( ( i ) < ( n ) ) ) //判断PQ[i]是否合法
     0005 #define  LChildValid(n, i) InHeap( n, LChild( i ) ) //判断PQ[i]是否有一个(左)孩子
     0006 #define  RChildValid(n, i) InHeap( n, RChild( i ) ) //判断PQ[i]是否有两个孩子
     0007 #define  Bigger(PQ, i, j)  ( lt( PQ[i], PQ[j] ) ? j : i ) //取大者(等时前者优先)
     0008 #define  ProperParent(PQ, n, i) /*父子(至多)三者中的大者*/ \
     0009             ( RChildValid(n, i) ? Bigger( PQ, Bigger( PQ, i, LChild(i) ), RChild(i) ) : \
     0010             ( LChildValid(n, i) ? Bigger( PQ, i, LChild(i) ) : i \
     0011             ) \
     0012             ) //相等时父节点优先,如此可避免不必要的交换
    
  • 完全二叉堆接口(PQ_ComplHeap模板类)

     0001 #include "Vector/Vector.h" //借助多重继承机制,基于向量
     0002 #include "PQ/PQ.h" //按照优先级队列ADT实现的
     0003 template <typename T> struct PQ_ComplHeap : public PQ<T>, public Vector<T> { //完全二叉堆
     0004    PQ_ComplHeap() { } //默认构造
     0005    PQ_ComplHeap ( T* A, Rank n ) { copyFrom ( A, 0, n ); heapify ( _elem, n ); } //批量构造
     0006    void insert ( T ); //按照比较器确定的优先级次序,插入词条
     0007    T getMax(); //读取优先级最高的词条
     0008    T delMax(); //删除优先级最高的词条
     0009 }; //PQ_ComplHeap
     0010 template <typename T> void heapify ( T* A, Rank n ); //Floyd建堆算法
     0011 template <typename T> Rank percolateDown ( T* A, Rank n, Rank i ); //下滤
     0012 template <typename T> Rank percolateUp ( T* A, Rank i ); //上滤
    
  • 完全二叉堆getMax()接口

     0001 template <typename T> T PQ_ComplHeap<T>::getMax() {  return _elem[0];  } //取优先级最高的词条
    

10.2.2 元素插入

堆中的节点与其中所存词条以及词条的关键码完全对应。

  • 算法

    插入操作insert()的实现分为两个步骤:首先将新词条接至向量末尾,再对该词条实施上滤调整。

    image.png

  • 上滤

    每交换一次,新词条e都向上攀升一层,故这一过程也形象地称作上滤 (percolate up)。

    上滤调整乃至整个词条插入算法整体的时间复杂度,均为O(logn)。

  • 最坏情况与平均情况

    新词条有时的确需要一直上滤至堆顶,但此类最坏情况通常极为罕见。以常规的随机分布而言,新词条平均需要爬升的高度,要远远低于直觉的估计。这也体现了优先级队列相对于其它数据结构的性能优势。

  • 实例

    image.png

  • 实现

    算法思路:

    对向量中的第i个词条实施上滤操作,只要i有父亲,则将i之父记为j,一旦当前父子不再逆序,上滤即完成。否则,父子交换位置,并继续考查上一层。

  • 改进

    一次swap()通常需要三次赋值。

    由于参与这些操作的词条之间具有很强的相关性,则可以改进为平均每层只需一次赋值;而若能充分利用内部向量“循秩访问”的特性,则大小比较操作的次数甚至可以更少。

10.2.3 元素删除

  • 算法

    delMax()方法的实现也分为两个步骤:首先取出堆顶并备份,代之以末词条,再对新堆顶实施下滤。

    =image.png

  • 下滤

    与上滤一样,由于使用了向量来实现堆,根据词条e的秩可便捷地确定其孩子的秩,而堆中的缺陷只能来自于e与其新孩子违背堆序性(每个节点都不高于其父节点)。

    每经过一次交换,词条e都会下降一层,故这一调整过程也称作下滤 (percolate down) 。

    下滤调整乃至整个删除算法整体的时间复杂度,均为O(logn)。

  • 实例

    image.png

  • 实现

    算法思路:

    使用了宏ProperParent()。

    对向量前 n 个词条中的第 i 个实施下滤操作,定义 j 为 i 及其孩子中的父者,若 i 非 j ,则二者换位,并继续考查下降后的 i ,最后返回下滤抵达的位置(亦 i 亦 j )。

10.2.4 建堆

给定一组词条,高效地将它们组织成一个堆,这一过程也称作“建堆” (heapification) 。本节以完全二叉堆为例介绍相关的算法,这些算法也同样适用于其他类型的堆。

  • 蛮力算法

    从空堆起反复调用标准insert()接口,即可将输入词条逐一插入其中。

    累计耗时:O(log1 + log2 + log3 + ... + logn) = O(log(n!)) = O(nlogn)

  • 自上而下的上滤

    蛮力算法的实现过程:对任何一颗完全二叉树,只需自顶向下、自左向右地针对其中每个节点实施一次上滤,即可使之成为完全二叉堆。

  • Floyd算法

    image.png

    问题:任给堆H0和H1,以及另一独立节点p,如何高效将H0∪{p}∪H1转化为堆?

    解法:只需对p实施下滤操作,即可将全树转换为堆。

  • 实现

     0001 template <typename T> void heapify ( T* A, const Rank n ) { //Floyd建堆算法,O(n)时间
     0002    for ( Rank i = n/2 - 1; 0 <= i; i-- ) //自底而上,依次
     0003       percolateDown ( A, n, i ); //下滤各内部节点
     0004 }
    

    PQ:Priority Queue

    ComplHeap:完全(Complete)二叉堆

    heapify:堆化

  • 实例

    image.png

    图(a):将9个词条组织为一颗完全二叉树。输入词条集均以向量形式给出。

    图(b):对3实施下滤调整,{8}和{5}合并为{8, 3, 5}。

    图(c):对1实施下滤调整;对6实施下滤调整。

    图(d):对2实施下滤调整。

    从算法推进的方向来看,前述蛮力算法与Floyd算法恰好相反——若将前者理解为“自上而下的上滤”,则后者即是“自下而上的上滤”。

  • 复杂度

    考查高度为h、规模为n = 2h+1 - 1的满二叉树

    运行时间为O(n)

    由于在遍历所有词条之前,绝不可能确定堆的结构,故以上已是建堆操作的最优算法。

10.2.5 就地堆排序

本节讨论完全二叉堆的另一具体应用:对于向量中的n个词条,如何借助堆的相关算法,实现高效的排序。相应地,这类算法也称作堆排序 (heapsort) 算法。

既然此前归并排序等算法的渐近复杂度已达到理论上最优的O(nlogn),故这里将更关注于如何降低复杂度常系数——在一般规模的应用中,此类改进的实际效果往往相当可观。同时,我们也希望空间复杂度能够有所降低,最好是除输入本身以外只需O(1)辅助空间。

若果真如此,则不妨按照1.3.1节定义称之为就地堆排序 (in-place heapsort) 算法。

  • 原理

    算法总体思路和策略与选择排序算法 (3.5.3节) 基本相同

    将所有词条分成未排序和已排序两类,不断从前一类取出最大者,顺序加至后一类中

    算法启动之初,所有词条均属于前一类;此后,后一类不断增长;当所有词条都已转入后一类时即完成排序

    image.png

    不变性:二者非空时,H (Heap) 中的最大词条不大于S中的最小词条。

    迭代过程:首单元词条M与末单元词条X交换,再对X实施一次下滤调整。

  • 复杂度

    在每一步迭代中,交换M和X只需常数时间,对x的下滤调整不超过O(logn)时间。故全部n步迭代累计耗时不超过O(nlogn)。

    纵观算法的整个过程,除了用于支持词条交换的一个辅助单元,几乎不需要更多的辅助空间,故的确属于就地算法。

  • 实例

    利用以上算法,对向量{ 4, 2, 5, 1, 3 }进行堆排序。

    首先,采用Floyd算法将该向量整理为一个完全二叉堆。

    image.png

    5步迭代完成排序:

    image.png

  • 实现

     0001 template <typename T> void Vector<T>::heapSort ( Rank lo, Rank hi ) { //0 <= lo < hi <= size
     0002    T* A = _elem + lo; Rank n = hi - lo; heapify( A, n ); //将待排序区间建成一个完全二叉堆,O(n)
     0003    while ( 0 < --n ) //反复地摘除最大元并归入已排序的后缀,直至堆空
     0004       { swap( A[0], A[n] ); percolateDown( A, n, 0 ); } //堆顶与末元素对换,再下滤
     0005 }
    

“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 18 天,点击查看活动详情