1. 算法
接下来这一节,来讨论完全二叉堆的元素删除算法。
我们知道,对于优先级队列而言,内容读取操作只限定于特定的节点。 具体来说只能是获取最大元或者删除最大元。
我们也知道,得益于堆序性,完全二叉堆中的最大元必然就是堆顶。因此,如果只是单纯地获取这个元素,我们只需常数的时间。然而为了删除这个节点,我们却需要做更多额外的工作。
首先,我们自然是将这个节点在物理上摘除掉,当然此后在逻辑上这就不再是一个结构完整的完全二叉树。为尽快地恢复结构性,不防将末元素移送到首元素的位置上,以取代这个被摘除的堆顶。经如此处理之后,结构性的确得以恢复,然而堆序性却有可能因此遭受破坏。
我们来观察一下,再将末元素前移至堆顶之后,在哪些位置有可能会违背堆序性呢?从图中不难看出,唯一的可能只能是这个新的堆顶节点与它的孩子们违反堆序性,那么,如何加以修复呢?
没错,交换。 如果 e 的确小于它的至少一个孩子,我们就将它与其中更大的那个孩子互换位置,交换之后的情况应该是这样(上图上左3)。可以看到,在经过这样一次交换之后,在最高的层次上已经不再会出现逆序的情况。 当然,其它的节点也依然会维持原有的堆序性。
然而故事可能依然没有完结,因为下降一层之后的 e 又会有新的孩子,而且如果不幸,它有可能至少会小于两个孩子中的某一个。看得出来如何解决这个新的难题吗?没错,依然是交换。在这种情况下,我们需要将 e 与它更大的那个孩子进行交换。 就这个例子而言,交换之后的结果应该是这样(上图下左1)。当然,经过这样的一次交换之后,在这个层次以上的所有逆序缺陷都应该能够得以修复。
然而不幸的是,在 e下降一层之后,在接下来的这个层次上有可能会再次发生逆序。不过我们对此已经习以为常了。果真出现这种情况的话,我们也可以故伎重演,令e和它更大的那个孩子互换位置。就这个例子而言,交换之后的效果是这样(上图下左2)。当然这种情况的确可能会再次的以及持续地发生下去,不过不要紧,每次出现这样的情况,我们都可以通过交换来加以解决。
与节点的插入过程相仿,这里也存在某种单调性。在原本最底层的节点 e 被前置为堆顶之后,每经过一次交换,它都会下降一层。这就意味着尽管逆序的情况有可能多次的持续发生,但问题出现的高度必然会持续地下降。因此这样一个过程也形象地称作为"下滤"( percolate down),正是得益于这种不断下降的单调性,我们才可以保证这个算法必然终止。
2. 实例
来看这样一个具体的实例。
- 首先确认,这的确是一个完全二叉堆,而且按照我们的习惯,上边是它的逻辑结构,下边是它所对应的物理实现。不是意外这个数据集中的最大源 5 的确位于堆顶,或者说在向量中是首元素。现在就假设我们需要将这个最大元摘除掉。按照刚才所设计的算法,为了在此后尽快地恢复结构性,我们需要将这个末元素 1,前置到堆顶的位置取而代之。
- 此时情况应该如这个图(上图左二)所示:也就是说此前的末元素 1 的确被前移至堆顶的位置。然而正如我们所预期的,此前位于底层,相对而言数值更小的元素,在被放置到堆顶的位置之后,通常都很难胜任堆顶的角色。正所谓高处不胜寒,比如就这个例子而言,此时无论是它的左孩子还是右孩子,在数值上都要严格地比它大,也就说在此局部违反了堆序性。所幸的是,在其它的位置,堆序性依然能够得以延续。
- 接下来为了恢复这里的堆序性,按照刚才所设计的算法,我们应该在两个孩子之间挑选出数值更大的那个。就这个例子而言,自然是数值为4的这个结点。于是我们只需令这个节点 1 与它的左孩子 4 互换位置。需要再次提醒大家的是,这种逻辑上的互换在物理上都是在向量的内部完成的。只不过与其说是在交换完全二叉堆的节点,不如更准确地说是在交换向量中的元素。
- 这次交换之后的情况应该如这个图(上图左三)所示,我们可以看到,在向量内部,4和1在物理上交换了位置,而相应的在逻辑上,节点 1 和4也互换了位置。可以看到这样一次交换的确修复了这一层次的逆序问题,然而此前惹是生非的那个节点 1 在下降了一层之后有可能依然会带来麻烦。是的,它依然没有完全符合堆序性。我们看到,尽管此时它的右孩子比它要小,但是它的左孩子却依然比它大。
- 为了修复这一缺陷,我们依然需要将违反堆序性的这样一对父子交换位置,这也是最后一次交换。因为正如我们在这个图中所看到的,节点 1 在交换之后已经成为了一片叶子,根本没有了后代,因此逆序性也无从谈起。
因此最终整个数据结构又重新恢复为一个完全二叉堆。
那么,这样一个连续不断地逐层下滤的过程又当如何具体实现为代码呢?实现的效率又能达到多高?
3. 实现
这里,我们就给出完全二叉堆节点删除算法的一种可能实现。
- 按照我们刚才的设计,首先需要取出堆顶元素进行备份,以便最终返回。接下来需要将末元素前置为首元素,暂且充当堆顶的角色。
- 以下需要调用 percaleteDown 算法,在一棵规模为 n 的完全二叉树中对根节点实施下滤,从而使得这棵完全二叉树成为一个名副其实的完全二叉堆。
- 以下是下滤算法的具体实现,我不妨再次重复一下这个算法的语义,也就是在一棵规模为 n 的完全二叉堆,对秩为 i 的元素实施下滤。
- 与上滤过程一样,下滤过程也是一个反复迭代的过程,在这个迭代中的每一步,我们都需要在节点 i 以及它最多两个孩子中找出最大者 j,只要 i 不是 j,就意味着至少有一个孩子要比 i 更大,堆序性在此处没有满足。在这种情况下,正如我们所涉及的那样,只需令 i 以及它更大的那个孩子 j 彼此互换。
- 这个循环有两种退出的可能:第一种情况就是 i 已经大于它的任何一个孩子,而第二种情况则是 i 持续下滤到最底层并成为一片叶子,这个条件是由名为 properParent 的这个宏隐式的给出的。
总而言之,一旦while循环退出,我们即可返回当前的下滤终点,整个算法也随即大功告成。
这个算法的正确性不难理解,而接下来更为重要的一个问题自然又是这个算法,或者更准确地讲,这个主体的循环需要耗费多少时间呢?其总体的效率是否和插入算法一样,也能达到我们预期的设计目标呢?
4. 效率
纵观整个下滤调整的过程,我们所做的工作主体无非两类:第一类也就是所谓的“比较”,第二类则是交换,非常幸运的是,我们每下滤一层,这两类操作都只需要执行常数次,因此就渐进意义而言,整体的时间复杂度不会超过堆的高度。
与上滤算法一样,作为完全二叉树,这里堆的高度也绝不会超过 log(n),这也是整个删除算法的渐进时间复杂度。
当然就常系数意义而言,我们依然存在改进的余地,比如交换操作所涉及的3 * log n 次赋值语句同样可以优化为 log n。
最后一点需要指出的是,下滤过程中的比较操作,与上滤过程中的比较操作在模式上和成本上都有实质上的区别。你应该记得,在上滤过程中,每个节点只需和它唯一的那个父亲进行比较。也就说,在每一层次上我们只需要一次比较。而下滤过程的每一步所涉及的都是一个节点以及它的两个孩子。为了从它们当中找出最大的那个,我们不得不做两次比较,当然对于二叉堆来说还不是什么了不起的差异,而在多叉堆中,这一差异将会变得至关重要。