优先队列是至少允许至少下列两种操作的数据结构:插入,和删除最小者(DeleteMin),它的工作是找出、返回并删除优先队列中最小的元素。简单的实现包括:
1. 使用一个简单链表在表头以O(1)执行插入操作,并遍历该链表以删除最小单元,这又需要O(N)
2. 始终让表保持排序状态;这使得插入代价高昂(O(N))而DeleteMin花费低廉(O(1))
3. 使用二叉查找树,它对这两种操作的平均运行时间都是O(log N)
4. 使用二叉堆。它不需要指针,并且以最坏情形时间O(log N)支持这两种操作。插入实际上将花费常数平均时间,若无删除干扰,该结构的实现将以现行时间建立一个具有N项的优先队列
二叉堆(binary heap)
堆是一棵被完全填满的二叉树(底层可能是例外),底层的元素从左到右填入。一棵高为h的完全二叉树有2^h到2^(h+1) - 1个节点,这意味着完全二叉树的高度是log N,显然它是O(log N)
可以用数组来表示完全二叉树而避免使用指针,对于数组中任一位置 i 上的元素,其左儿子在 2i 上,右儿子在左儿子后的单元(2i+1)中,它的父亲在[i/2]上
一个堆数据结构由一个数组,一个代表能容纳节点最大值的整数以及当前的堆大小组成
使操作被快速执行的性质是堆序性(heap order)。假如我们想要快速地找出最小元,则最小元应该在根上。如果我们考虑任意子树也应该是一个堆,那么任意节点就应该小于它的所有后裔
基本的堆操作
1. 插入
为将一个元素X插入堆中,我们在下一个空闲位置创建一个空穴。如果X放在该空穴中而并不破坏堆的序,那么插入完成;否则,我们把空穴父节点的元素移入该空穴中,这样,空穴就朝着根的方向上行一步,继续该过程直到X能被放入空穴为止。这种一般的策略叫做上滤(percolate up),新元素在堆中上滤直到找出正确的位置
我们可以使用insert例程通过反复实施交换操作直至建立正确的序来实现上滤过程,一次交换需要3条赋值语句;如果一个元素上滤d层,则总的赋值次数就达到3d。如果每次上滤都是仅将一个节点向下移动的话,则只需要d+1次赋值
如果要插入的元素是新的最小值,那么它将一直被推向顶端,在 i == 1 时终止。不过,我们采用将一个很小的值放到位置0处进行终止,这个值必须小于等于堆中的所有元素,我们称之为标记(sentinel)。这种想法类似于链表中头节点的使用,通过添加一条哑信息(dummy piece of information),我们避免了每个循环都要执行一次的测试,从而节省了时间
2. DeleteMin
找出最小元是容易的,困难的部分是删除它。当删除一个最小元时,在根节点处产生了一个空穴,因此堆中最后一个元素必须移动到正确的位置。我们将空穴的两个子节点中较小的移入空穴,这样就把空穴向下推了一层。重复该步骤直到X可以被放入空穴中。这种一般的策略叫做下滤(percolate down)。这种算法最坏情形运行时间为O(log N),平均而言,被放到根处的元素几乎下滤到堆的底层,因此平均运行时间为O(log N)
在堆的实现中经常发生的错误是当堆中存在偶数个元素时,可能会遇到一个节点只有一个儿子的情况
【定理】包含2^(b+1) - 1个节点,高为b的理想二叉树的节点高度和为 2^(b+1) - 1 - (b+1)
d-堆
d-堆是二叉堆的简单推广,它就像一个二叉堆,只是所有的节点都有d个儿子(因此二叉堆是2-堆)。d-堆比二叉堆浅的多,它的insert操作运行时间为O(log d N)。然而对于大的d,DeleteMin操作费时得多,因为虽然树浅了,但是d个儿子中最小的必须要找出,如果使用标准的算法,这回花费(d-1)次比较,于是它的操作时间将增加为O(d log d N)。如果d是常熟,那么这两种操作的运行时间都是O(log N)。虽然可以使用数组,但在寻找儿子或父亲时乘法和除法都有因子d,如果d不是2的幂,运行时间会大大增加
除了不能执行find外,堆最明显的缺点是将两个堆合并成一个堆是困难的操作。这种操作称为Merge,存在很多堆的方法使Merge的运行时间为O(log N)
左式堆
像二叉堆一样,左式堆(leftist heap)也具有结构特性和有序性,它想所有堆一样有相同的堆序性。此外,左式堆还是二叉树,二者唯一的区别是,左式堆不是理想平衡的,而且是趋向于非常不平衡
任意节点X的零路径长(null path length, NPL)Npl(X)定义为从X到一个没有两个儿子的节点的最短路径长。因此,具有0个或1个儿子节点的Npl为0,而Npl(NULL) = -1。任意节点的零路径长比它所有儿子零路径长的最小值多1
左式堆的性质是,对于堆中的每一个节点X,左儿子的零路径长至少与右儿子的零路径长一样大。沿左式堆的右路径是堆中最短的路径
【定理】在右路径上有r个节点的左式树至少有(2^r - 1)个节点
左式堆的基本操作是合并,插入只是合并的特殊情形。如果这两个堆中有一个是空的,则直接返回另一个堆,否则为了合并这两个堆,我们需要比较它们的根。首先,将具有较大根值的堆(H1)与具有较小根值堆(H2)的右子堆合并,然后让新的堆成为H1根的右儿子。如果H1的根被破坏,则交换其根的左右儿子即可;持续递归执行该过程。注意,如果零路径长不更新,那么所有的零路径长都将是0,而堆将不是左式的,只是随机的。执行合并的时间与右路径长的和成正比,因为在递归调用期间对每一个被访问的节点执行的是常数工作量,因此合并两个左式堆的时间界为O(log N)
为了执行DeleteMin,只要除掉根而得到两个堆,然后将这两个堆合并,因此执行一次DeleteMin的时间为O(log N)
斜堆
斜堆(skew heap)是左式堆的自调节形式,和左式堆的关系类似伸展树与AVL树的关系。斜堆是具有堆序的二叉树,但是不存在对树的结构限制。不同于左式堆,关于任意节点的零路径长的任何信息都不保留。斜堆的右路径在任何时刻都可以任意长,因此所有操作的最坏运行时间均为O(N)。任意连续M次操作,总的最坏情形运行时间是O(M log N);因此,斜堆每次操作的摊还时间为O(log N)
与左式堆相同,斜堆的基本操作也是合并。方法依旧是递归,操作与之前也相同,一个例外是不同于左式堆要通过交换左右儿子以保持其性质,斜堆除了右路径上所有节点的最大者不交换自己的左右儿子外,其余的交换是无条件的。因为右路径可能很长,所以递归实现可能由于缺乏栈空间而失败。斜堆的有一个有点,是不需要附加的空间来保留路径长以及不需要测试何时交换儿子
二项队列
二项队列(binomial queue)支持合并、插入和DeleteMin,每次操作最坏情形运行时间为O(log N),而插入操作平均花费常数时间。一个二项队列不是一棵堆序的树,而是堆序树的集合,称为森林(forest)。堆序中的每一棵树都是有约束的形式,叫做二项树(binomial tree)。每个高度上至多存在一棵二项树。高度为0的二项树是一棵单节点树;高度为k的二项树B k通过将一棵二项树B k-1附接到另一棵二项树B k-1的根上而构成。高度为k的二项树恰好有2^k个节点。如果我们把堆序施加到二项树上并允许任意高度上最多有一棵二项树,那么我们能够用二项树的集合唯一地表示任意大小的优先队列
最小元可以通过搜索所有的树的根来找出,由于最多有log N棵不同的树,因此最小元可以以时间O(log N)找到
合并操作是通过将两个队列加到一起来完成的,在最坏情形下花费时间O(log N)。插入实际上就是合并的特殊情形,我们只要创建一棵单节点树并执行一次合并,这种操作最坏情形运行时间也是O(log N)。更准确地说,如果元素将要插入的那个优先队列中不存在的最小二项树是B i,那么运行时间与 i+1 成正比。分析指出,对一个初始为空的二项队列进行N次insert将花费的最坏情形时间为O(N)
DeleteMin可以首先找出一棵具有最小根的二项树来完成。令该树为B k,并令原始的优先队列为H。我们从H的森林中除去二项树B k,形成新的二项树队列H',再除去B k的根,得到一些二项树B0,B1,...,B k-1,它们共同形成优先队列H''。合并H'和H'',操作结束
二项树的每一个节点包含数据本身、第一个儿子及其又兄弟。二项树中的诸儿子以递减次序排列