1. 自上而下的上滤:算法
在介绍过完全二叉堆标准的静态和动态操作接口之后,我们接下来讨论如何批量地建造一个堆。也就说对于任给的 n 个元素,我们希望将它构建成一个堆。这样一个过程也称作Heapification。
在完全二叉堆模板类中,我们可以找到这样一个构造函数,其功能是以任意指定的规模为 n 的数组 a 为蓝本,将其中的元素组成一个完全二叉堆。为此我们首先需要调用向量的 copyfrom 接口,将这个数组复制到内部,而实质的操作则是调用 heapify 这个算法将这个元素调整为堆。那么这个 heapify 算法又当如何实现呢?
如果不在乎计算成本,这算不上是一个难题,甚至我们有现成的解决方案。比如这就是(上图所示)一个现成的解决方案,我们称这个方案为蛮理算法,因为它的思路是直截了当的,也就是逐个地将每一个元素通过完全二叉堆标准的 insert() 接口插入其中。
比如一种相对更为紧凑的实现方式是这样: 为此我们只需按照层次遍历的次序,也就是自上而下、自左而右,逐一的对每一个元素做上滤处理。每经过这样一套上滤,就等效于插入了一个新节点,而当所有的节点都经过如此上滤之后,整个堆也就自然建成了。
来看一个具体的实例,考察一个由5个元素所构成的初始向量。当然这里画出的是在逻辑上与之对应的那棵完全二叉树。
- 我们首先来考虑根节点:作为第一个节点,它所对应的上滤是一个平凡的情况,实际上它只需原地不动,我们就可以认为已经将它作为第一个元素插入了这个堆。也就是说,我们直接就得到了一个元素构成的堆。正因为这一部并没有任何实质的动作,因此我们不妨将其忽略掉,而直接从编号为 1 的元素开始。在我们的图中,也就是这个灰色的节点。相对于当前的堆,这个节点恰好就是末元素。因此根据我们此前的插入算法,在对它进行一次上滤调整之后,就可以顺利地将它插入到堆中,于是我们就可以得到一个规模为2的堆。
- 再接下来需要考察2号,也就是这个灰色的节点(上图下排倒数右二)。同样地,相对于当前的堆,它也恰好是末元素。因此我们也只需对它做一趟上滤,即可将它插入到当前的堆中。于是这个堆的规模又将从2拓展至3。
- 接下来的故事都类似,也就是我们需要进而去考察编号为3的节点,并且通过一趟上滤将它插入到当前的堆中,从而使得堆的规模进而上升为4。
- 最后一个节点也是如此,我们也只需对它做一趟上滤,就可将它插入到当前这个堆中。
- 从而最终得到一个规模为5的堆。
从逐一插入各节点的角度来看,这个算法平淡无奇,其正确性也因此显而易见。那么这个简明的算法效率又如何呢?
2. 自上而下的上滤:效率
就从最坏情况的角度对上述算法的效率来做一分析。
回顾刚才的蛮力算法,我们需要自上而下、自左而右的处理每一个节点,而每一个节点都要相应地做一次上滤,因此我们也将这种建堆的模式称作自上而下的上滤。
不难看出,在最坏的情况下,每个节点都有可能要一直上滤至根节点,其对应的计算成本也应该线性正比于其深度,因此总体的时间成本也应该就是每一个节点深度的总和。
当然也可以精确地对此做一个计算。但实际上我们只需要对其中的部分节点进行计算就足以验证这个算法是低效的,比如我们不妨来考虑那些所有底层的叶节点。我们知道,在完全二叉树中,至少有一半节点是页节点,而且在渐进意义上它们的深度都是log(n)。
因此就大 O 记号而言,仅这部分节点所消耗的时间就会多达 n log(n)。没错,多达 n log(n)。我们认为这是一个不可接受的效率。为什么这么讲呢?应该记得,我们设计和实现优先级队列的最初动机在于,我们需要代价足够低廉,同时又能维护所有元素之间偏序关系的一种数据结构。没错,偏序。
而我们早已知道,在多达 n log(n) 的时间之内,我们完全可以对所有元素做全排序。是的,用 n log(n)的时间,得到了一个超出我们所预期的,更强的功能。因此反过来,如果我们只是满足于偏序,而无需考虑全序的话,或许应该能够指望成本更加低廉。好消息是,事实的确如此。
3. 自下而上的下滤:算法
为了得到改进的建堆算法,我们需要来考察这样一个典型的场景:
假设已经有了两个初始的堆,H0和 H1,而它们的堆顶 R0和 R1分别作为第三个节点 p 的左和右孩子。对于这样一种情况,在这种情况下,我们应该如何迅速地将这两个堆合并起来,从而构成一个更大的堆呢?实际上这并不是一个什么新问题,你能看得出来吗?
是的,完全二叉堆的 delMax 算法。在这个算法中,我们首先要将最大元,也就是堆顶摘除掉,并且用向量中当前的末元素来取而代之。是的,我们就来考察刚刚取而代之的那个时刻。
在那样一个时刻,难道不恰好就是我们所说的这样一个场景吗?我们来验证一下,作为此前完全二叉堆的一部分,它们依旧处处满足堆序性和结构性。因此,它们都各自成为一个堆。同时,它们也是新的这个根节点的子堆。如果你能看透这一点,也就自然可以得到相应的调整算法。
没错,我们只需套用此前 delMax 算法的后半部分。具体来说,就是对这个新的根节点做一次下滤:当然,下滤的方向可能有两个:或者沿着左路的分支一直进入到左侧的这个子堆,也可能沿着右侧的分支进入到右侧的这个子堆。
无论如何,一旦节点 p 的下滤得以完成,原先的两个子堆也自然的就完成了合并。将这种处理手法退而广之,并反复使用,我们就可以得到一个效率更高的批量建堆算法。这个算法出自于 floyd 之手,该算法处理各节点的次序与此前的蛮力算法恰好相反,也就是说,在树中应该是自下而上,自右而左逐个处理。而对于每一个节点,我们都只需做一趟下滤。 当然,对于叶子节点而言,下滤并没有实际的意义,因此这个算法只需考虑所有的内部节点。相应地,第一个接受处理的也应该是最后一个内部节点。
如果全堆的规模为 n,那么这个最末尾的内部节点在向量中所对应的秩就应该是 floor(n/2) - 1。我们刚才已经看到,对每一个内部节点实施的下滤,其实质效果等同于将左右子堆合并起来,因此这样一个自下而上,逐个下滤的过程也就等效于各子堆逐层向上合并,规模不断增加的过程。
因此最终当根节点的下滤也完成之后,所有的节点也自然地从整体上构成了一个完全二叉堆。
4. 自下而上的下滤:实例
来看这样一个实例,这是由9个节点所构成了一棵完全二叉树,因此在物理上的向量中,最末尾内部节点所对应的秩应该为 floor(9/2) - 1 = 3,也就是这个节点(上图节点3)。不要误解这里它的数值也为3,纯属巧合。
请注意,在初始情况下,无论如何,每一个业节点都可以认为是自成为一个子堆,因此在此局部恰好就构成了我们此前所说的那样一个典型的模式:局部的子树根以及下属的左和右两个子堆,我们的任务是将这两个子堆合二为一。
- 算法非常简明,为此只需对局部的子树根节点 3 做一次下滤,下滤的结果是这样:可以看到,不出意外,我们的确完成了两个子堆的合并。
- 接下来该轮到再往前一个的内部节点,也就是6。在此,我们又一次看到了这个典型的模式:一个局部的子树根,以及左右两个子堆,同样地,我们只需对局部子树根 6 做一次下滤,即可将此局部调整为一个更大规模的堆,就像这样(上图下左1)。
- 再接下来,应该轮到内部节点 1,请注意,这里依然是一个我们算法可以处理的模式:一个局部的子树根,以及左右两个待合并的子堆。依然套用现成的算法,我们只需对局部子树根1做一次下滤,即可完成局部的合并。合并的结果是这样(上图下左1)。
- 好,最终应该轮到全树的根节点2,此时我们依然可以看到这样一个算法可以处理的典型模式:根节点,以及左右两个待合并的子堆。对于我们的处理手法,你应该现在非常熟悉了:只需对根节点做一次下滤,即可完成整体的合并。最终的结果是这样:不出意外,我们的确得到了一个由所有元素构成的完全二叉堆。
这个算法的正确性也同样显而易见,那么它的效率究竟有多高呢?是否如我们所预期的那样,严格的优于此前的 n log(n)呢?
5. 自下而上的下滤:效率
纵观 Floyd 建堆算法,实质的计算成本来自于对每个节点的下滤。
我们可以看到,每一个节点都会经过一系列的交换,下降一定的高度,有的下降得少一些,有的下降得多一些。就最坏情况而言,每个内部节点所下降的层次数至多不过它最初的高度,因此整个Floyd 算法的计算成本无非就是每一个节点所对应高度的总和。 经过推算不难得知,这个总和在渐进意义上无非是 O(n) ,限于时间关系,在此不妨省去详细地推导过程。而利用节省下来的这部分时间,我们不妨就时间效率将Floyd 算法和此前的蛮力算法来做一对比:这一对比既有趣,也更有意义。
应该记得蛮力算法O(n log(n) )的效率是来源于对所有节点深度的求和。 是的,这里的差异就在于究竟是对高度求和还是对深度求和。而饶有趣味的一个问题是,分别采用这样两个貌似非常接近的指标来进行求和,为什么在渐进的意义上却有巨大的差异呢?对于这一现象,你又当如何解释呢?
没错,造成这种实质差异的根本原因就在于,在完全二叉树中,越是靠近底层,节点越多;而越是靠近顶层,节点的就越少。因此,如果以深度作为成本的指标,那么累计的总和也自然会更大。
打个未必恰当的比方,每一个完全二叉堆就犹如一个社会,如果将高度对应于收入的水平,那么高收入人群必然是凤毛麟角,而大部分都是中低收入者。而如果需要对所有的人征税,再自然不过的规则莫过于按照收入的高低来决定税收的比例。低收入者少纳税,高收入者多纳税,再合理不过了。
~
事实上Floyd 算法所对应的正是这样一种合理的税收政策,从这个角度来看,蛮力算法恰好颠倒了标准。就算法相当于为了迎合少数的富人,居然以收入作为反比来确定税赋的比例。因此,自然会不得人心,并最终受到惩罚。