1. 上滤
好,接下来我们就来学习在一个完全二叉堆中,如何有效地插入一个新的元素。我们将会看到插入过程中的核心技巧是所谓的 ”上滤“ 过程。
为了在完全二叉堆中引入一个新的词条 e,我们只需在物理上将它作为末元素,直接插入至对应的向量之中。没错,向量。
请记住,尽管在逻辑上我们可以将优先级队列理解为一棵完全二叉树,但在物理上它依旧是一个不折不扣的向量,向量在物理上增加一个末元素,等效于在这棵完全二叉树底层向空缺的部分拓展一个节点。
可以看到,将新的词条作为末元素接入向量的好处在于,可以使得完全二叉堆的结构性得以延续。当然,另一条件,也就是堆序性如果也能够得以延续,那么我们也就大功告成。然而很遗憾,在新的节点引入之后,堆序性未必能够延续。
当然,即便如此,情况也不致太糟糕。具体来说,唯一可能违背堆序性的只有新插入的这个节点与它的父亲。也就是说,新插入的这个节点拥有一个父亲,而且在数值上,新插入的这个节点要大于它的父亲。
既然找到了问题的症结,我们也就自然得到了解决的办法。没错,在这种情况下我们只需将新插入的节点 e 与它的父亲互换位置,从而转换为这样一种状态。不难看出经过这样的转换之后,不仅顺利地解决了 节点e 与它此前的父亲之间的逆序性,同时其他所有节点之间的堆序性也不致受到影响。当然,问题未必就此完全地解决。
因为 e 有可能依然会有一个新的父亲。而且如果不巧,e 有可能依然会大于这个新的父亲,也就是说,e 和它新的父亲再次违反堆序性。你应该知道如何解决这个问题了。是的,我们只需再次套用此前的策略,令 e 和它新的父亲互换位置,从而进入这样一种状态。同样经过这样一次交换,在刚才这一层的逆序性得到了修复,而且同时不致影响到其他的节点。接下来如果依然存在问题,也只可能是 e 和它这个最新的父亲违背堆序性。果真如此,我们可以继续令e和它的父亲互换位置,从而转入这样一种状态。
好消息是,这样一个反复交换的过程,满足某种单调性。 不难看出,每经过这样一次交换,e 的高度就会上升一层,同时违反堆序性的情形如果存在,也必然会上升一层。这一过程,亦即所谓的上滤(percolate up)
既然这样一个逐层调整的过程充其量不过抵达到树根,因此也迟早会终止于某个位置。而一旦这个过程终止,也就意味着堆序性在整个完全二叉堆中得到了彻底的恢复。
2. 实例
注意:上图所标注的数值都是元素的优先级而非其所对应的秩。
我们来看一个简单的实例,不难验证,这是一个由5个词条所构成的完全二叉堆。上面的这棵完全二叉树是它的逻辑结构,而下面则是其在物理上对应的向量结构。
- 可以看到在物理上向量的首元素也就是逻辑上完全二叉树的根节点。根据我们的约定,它正是整个数据集中的最大者。
- 接下来,假设我们需要插入一个数值为 5 的词条。按照刚才所涉及的算法,我们首先将它作为末元素加入到向量之中,在逻辑上这完全等效于在完全二叉树的底层向右侧拓展了一个节点。可以看到,这种拓展的确没有破坏完全二叉堆的结构性。然而正如这个例子中的情况,新引入的这个词条有可能和它的父亲违背堆序性,因为5大于0。于是按照我们刚才所拟定的策略,令二者互换位置。请注意,这一兑换物理上实际上是在向量之中进行的。
- 经过交换之后的结果是这样(上图3),可以看到局部的堆序性的确得到了恢复,同时也不致于影响到其他的各个节点。然而新插入的这个节点上升一层之后,有可能会有一个新的父亲,比如这里的4。而且很不幸,因为5依然大于4,所以我们说在这个局部同样违反了堆序性。好在不要紧,因为我们可以继续沿用刚才拟定的策略,并违反堆序性的两个节点互换位置。同样在物理上,这种交换也是在向量内部进行的。
- 经过这次交换之后,的确4和5 互换了位置,新插入的节点5继续上升一层,这一局部的堆序性得到了恢复,而且同样不致影响到其它的节点。而新插入的这个节点5在上升了一层以后,已经悄然间成为了这个堆的堆顶。它根本就没有父亲,因此堆序性在这一局部也就自然成立。更重要的是,至此堆续性在整个完全二叉堆中处处得到满足。
我们也就顺利地完成了这次插入操作。可以看到,整个插入算法的实质过程,无非是令新引入的这个节点不断地与它的父亲交换位置。每交换一次,新引入的这个节点都会上升一层。那么这样一个过程如何兑现为具体的代码呢?
3. 实现
在这里我们给出完全二叉堆插入算法的一种可行实现方式:
- 首先,将待插入的词条接入向量之中,为此我们所借用的是向量的插入接口。你应该记得,在默认的情况下,这个接口会将新元素作为末元素插入其中。既然是末元素,新插入这个词条在向量中对应的秩就应该是 n - 1。
- 因此接下来我们可以调用 percolateUp 算法完成对这个新节点的上滤调整。
- percolateUp 算法可以实现如下:
(1) 迭代式的上滤调整过程是以while 循环来实现的。如果当前词条的秩为 i,我首先要检查它的父亲是否存在。也就是说 i 是否已经成为堆顶。 倘若词条 i 的确成为了堆顶,这个循环就会立即退出,算法也随即终止。
(2)不失一般性,如果 i 的父亲存在,我们就记为 j。
(3)接下来通过依次比对,我们就可以判断出这对父子节点是否逆序。一旦它们不再逆序,这个循环也会随即退出。
(4)否则如果的确在这个位置违反堆序性,我们就按照刚才的算法将两个元素互换位置,同时更新节点的秩,也为下一步迭代做好准备。
这个算法的正确性不难验证。那么接下来的问题便是这个算法需要运行多长时间呢?对应的时间复杂度是否能达到我们所预期的目标呢?
4. 效率
应该记得,在完全二叉堆中插入新元素之后的调整过程之所以称作是”上滤“,是因为每经过一步迭代,新插入元素的位置都会上升一次。换而言之,整个算法在完全二叉堆的每一层次上至多只需做一步迭代。
我们知道完全二叉树是理想平衡的二叉树,其树高可以严格地控制在 log( n) 的范围内。我们刚才也已看到,每一步迭代只需常数的时间。因此所有的迭代所需的时间累计也不过log( n) 。
就渐进的意义而言,这已经实现了我们最初的设计目标。当然就常系数的意义而言,这里依然还有改进的余地。你应该记得,在我们刚才所给出的实现中,每一次交换都是通过一个名为 swap 的过程来完成。这样的一次操作通常都意味着三次赋值操作,因此在最坏情况下,我们累计需要做多达 3*log( n) 赋值。
针对这一问题,一种简明的改进方法就是,首先将新插入的词条做备份,每次如有必要交换,我们只是下移它的父节点。直到能够确定 e 已经无需继续上滤时,我们才将此前备份的这个词条纳入于最终的这个位置。 如此我们就可将赋值操作的次数从 3*log( n) 减至 log( n) + 2,从而在常系数意义上有所改进。
当然,另一类操作,也就是父子词条之间的大小比较操作也可以有一定的改进。
关于完全二叉堆,另一个好消息是它的平均性能要远远优于刚才所分析的最坏情况。 实际上,新插入节点需要持续上升足够多层,乃至最终能够抵达树根的情况,出现的可能性是极低的。更加精细的估算表明,在通常的随机分布下,每个节点上升的平均高度实际上只不过是常数。这也是完全二叉堆低成本、高效率的重要证据。