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