堆,很essential的数据结构,可惜严老大那本书没有怎么重视的感觉,但《算法导论》上有。可能严老大有自己的考虑。但从个人体会来看,应该还是非常有必要好好研究研究这个数据结构的,且不说堆是实现优先级队列的基础设施,更不说堆是众多图算法——如Dijkstra最短路径算法、Prim算法——的实现利器,单就堆排序及一些选择性算法——如寻找最大的K个值——而言,堆就不可忽视。
我们学习和研究一种数据结构,思维曲线一般可以遵从以下三步骤:
这里,我说的是针对学习和研究,在实际解决问题的过程中,往往是先分析需求,这里侧重的指复杂度需求,然后思考进行什么样的操作满足这种需求,再接下来,我们可能就要想,要实现这种操作,我们需要怎样组织信息呢?这个,就是数据模型了。另外,在学习与研究中,有时候并不能完全区分第二步和第三步,我想这个应该也不必多说为什么。在本系列文章中,一些明显的复杂度我就不分析了,一大坨数学符号看了头大,追求形式化理论化不免曲高和寡。但对于比较重要也比较复杂的复杂度分析,我将给出分析过程。
确定了思维曲线,接下来就是确定具体内容。堆其实有很多变种,都是在现实中遇到了麻烦,然后先辈牛人们就想出了新的变种堆。本文主要学习和研究基本二叉堆及其泛型扩展——d堆,二项堆,斐波那契堆,杨氏图表、笛卡尔树等等,Wikipedia上列出的堆的变种,我都争取进行分析和研究,这个过程,都是一个厚积薄发的过程。
学习和研究的目的,不应该仅仅满足于知识的掌握,这是常识,尽管这同样非常重要——这是我们在以后分析具体问题时的知识储备。我们还应该尽可能从一次学习和研究中提升自己建立模型的能力,这个过程可能很缓慢,不会像林志玲的脸蛋,望一眼就让人神魂颠倒灵魂出窍,但是意义却是不言自明的吧。所以这篇文章除了依据图1的思维曲线来学习研究堆,也希望可以总结出一些提升个人独立建立模型能力的感悟。最后我们上点干货,给出堆的C++、Python实现代码(文中以C++代码为解说,附件为C++与Python的完整实现),并具体解决几个具体问题。
我们认识堆基本上都是从堆排序算法开始,至少我是。根据《算法导论》,堆最早可能确实就是基于排序的需求,J. W. J.Williams发明了堆排序算法,但是Williams那篇论文我在网上搜不到(如果谁有,希望可以分享一下,嘿嘿),所以W爷爷到底是基于一个什么样具体的问题(需求)的“折磨”想出了这么好的办法,我也不得而知,所以只好猜猜了。
根据堆最经典的应用之一——堆排序的特点,我琢磨着应该还是基于对选择排序的不满。选择排序简单直观,每次从遍历序列从中找出最大值放入已排序序列中,显然,不平凡也不甘平凡的programmers发现了,这个遍历做的事情显然太少了,一个for(i=0; i<n; i++){foo(i);}都可以优化成for(i=0; i<n; i+=2){foo(i); foo(i+1);},凭什么选择排序这个大尾巴狼就没得优化空间?选择排序的基因思想是,每次从(剩余的)源序列中找出最大值。打个比方,这就像是比赛,如果你能从第一局打到最后一局,那你就是最强者(最大值),我们选出这个最强者(最大值)的过程就是选择的过程,选出来之后,将它标记,然后再从剩下的选手中以同样的方法选出第二强的,依次下去,直到只剩一个选手(边界)为止。从这个比方中,我们差不多也看出这种办法的毛病了:它浪费了中间很多次比赛的结果记录。还是举个例子,对于选手序列S={C, B, E, A, D},并设定强弱关系为A>B>C>D>E。
1) 第一轮,C和B比赛一场,B胜利了;于是B就继续和E赛一场,B胜了;B又和A赛一场,A胜利;A再和D赛一场,A再次胜出,A就被选出来。
2) 第二轮,C又和B赛一场,于是B就继续和E赛一场, B胜利,于是B继续和D赛一场;
3) ……
看出来了吧,这里C和B的比赛多余了,同样的,B和E的比赛又重复了。
问题找到了,怎么想办法克服呢?哈哈,you got it! 保存下这个结果!再继续往下想,怎么保存?就我不深的计算机学习经验,对于“大与小”、“胜与负”等等这种简明的二元关系,比较容易联想到的就是树了。我们知道,树的本质思想就是分治,是二元。对于树,父节点可以抽象为一个指定判断标准下的被比较对象,相应地,左子节点抽象为与父节点相比较后达该判断标准的对象,右子节点为未达标对象。在排序中,左子节点就是比俺嫩的,右子节点就是比俺熟的,基于这种思想,我们就有了二叉排序树(二叉搜索树);另外一种二元分治思想的具体化方式就是,凡是子节点就是比俺嫩的(或是比俺熟的),基于这种思想的就是我们的堆了。
言归正传,从这个具体的例子,我们可以抽象到选择排序,选择排序就是白白浪费了许多具有明显意义的中间结果,每次我们再去找下一个最大值的时候,都要重新老老实实重新遍历一遍整个剩余序列,programmers就开始琢磨着,如果再去找下一个最大值的时候,可以直接取出来就好了!有人就跳起来了,那不就是已排好序的序列了吗?好吧,那我退一步,我看能不能做到不用遍历整个剩余序列的办法就取出来呢?从O(N)降低到O(logN),行不?对此我们想到了要保存这些结果,再进一步地,我们想到了可以借助树的思想。对于树,除了前面的二元分治思想,另一种本质性思想是递归,递归性。这个应该很好理解,递归的本质又可以理解为任何一个小局部都服从某种规则,构成同样服从该规则的大局部,也就是整体。说了这么多,我们差不多可以引入堆的概念了。
(二叉)堆,如图2所示(本图摘自机械工业出版社《算法导论》第二版73页),它可以被视为一棵完全二叉树。完全二叉树的主要特点是,树的每一层都是满的,最后一层可能除外,而最后一层从一个节点的左子树开始填充。
再具体看堆的特点,堆之所以为堆的特点。对于根节点16的两个儿子,14和10都比根节点16小,这就是一种二元思想的体现了,存储在父节点与子节点之间的关系,就是在比较中获得的大小关系。再看递归思想,14的子节点8、7又比自己小,10的节点9、3也比自己小,以此类推。
堆从具体内存布局上看,是一种数组对象——完全二叉树嘛!显然,这棵完全二叉树中每个节点都与表示二叉堆的数组中元素存在某种对应关系,这在图2中也已体现出来。假设表示二叉堆的数组为A,那么A[0]表示树根,也就是堆顶,那么,对于某个数组下标为i的节点Ni与其父节点为Pi、左子节Li、右子节点Ri(假设左子节点或右子节点存在)之间的关系如下:
Pi = A[i/2], Li = A[2*i], Ri = A[2*i+1].
再次回到选择排序,堆排序的过程,就是在遍历数组时,将数组转换为具有图2中堆特性的数组:
A[i/2]>=A[2*i]
且A[i/2]>= A[2*i+1]
这样,最大值的时候,我们就知道是A[0]了,取出A[0]后,弹出来,修改一下数组的元素次序,下次最大值还是在A[0]处。这里的问题在于,取出A[0]后修改数组中的元素次序,这个复杂度会比老老实实再遍历一次剩余数组的开销低吗?