堆,很essential的数据结构,可惜严老大那本书没有怎么重视的感觉,但《算法导论》上有。可能严老大有自己的考虑。但从个人体会来看,应该还是非常有必要好好研究研究这个数据结构的,且不说堆是实现优先级队列的基础设施,更不说堆是众多图算法——如Dijkstra最短路径算法、Prim算法——的实现利器,单就堆排序及一些选择性算法——如寻找最大的K个值——而言,堆就不可忽视。
我们学习和研究一种数据结构,思维曲线一般可以遵从以下三步骤:
这里,我说的是针对学习和研究,在实际解决问题的过程中,往往是先分析需求,这里侧重的指复杂度需求,然后思考进行什么样的操作满足这种需求,再接下来,我们可能就要想,要实现这种操作,我们需要怎样组织信息呢?这个,就是数据模型了。另外,在学习与研究中,有时候并不能完全区分第二步和第三步,我想这个应该也不必多说为什么。在本系列文章中,一些明显的复杂度我就不分析了,一大坨数学符号看了头大,追求形式化理论化不免曲高和寡。但对于比较重要也比较复杂的复杂度分析,我将给出分析过程。
确定了思维曲线,接下来就是确定具体内容。堆其实有很多变种,都是在现实中遇到了麻烦,然后先辈牛人们就想出了新的变种堆。本文主要学习和研究基本二叉堆及其泛型扩展——d堆,二项堆,斐波那契堆,杨氏图表、笛卡尔树等等,Wikipedia上列出的堆的变种,我都争取进行分析和研究,这个过程,都是一个厚积薄发的过程。
学习和研究的目的,不应该仅仅满足于知识的掌握,这是常识,尽管这同样非常重要——这是我们在以后分析具体问题时的知识储备。我们还应该尽可能从一次学习和研究中提升自己建立模型的能力,这个过程可能很缓慢,不会像林志玲的脸蛋,望一眼就让人神魂颠倒灵魂出窍,但是意义却是不言自明的吧。所以这篇文章除了依据图1的思维曲线来学习研究堆,也希望可以总结出一些提升个人独立建立模型能力的感悟。最后我们上点干货,给出堆的C++、Python实现代码(文中以C++代码为解说,附件为C++与Python的完整实现),并具体解决几个具体问题。
我们认识堆基本上都是从堆排序算法开始,至少我是。根据《算法导论》,堆最早可能确实就是基于排序的需求,J. W. J.Williams发明了堆排序算法,但是Williams那篇论文我在网上搜不到(如果谁有,希望可以分享一下,嘿嘿),所以W爷爷到底是基于一个什么样具体的问题(需求)的“折磨”想出了这么好的办法,我也不得而知,所以只好猜猜了。
根据堆最经典的应用之一——堆排序的特点,我琢磨着应该还是基于对选择排序的不满。选择排序简单直观,每次从遍历序列从中找出最大值放入已排序序列中,显然,不平凡也不甘平凡的发现了,这个遍历做的事情显然太少了,一个(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]后修改数组中的元素次序,这个复杂度会比老老实实再遍历一次剩余数组的开销低吗?
这就是对该模型的操作问题,以及操作的复杂性问题。带着这个问题,我们可以进入第二步了。
任何数据结构模型,都无外乎构造、插入、删除、查找(读取)、修改等等。
从前述中我们得知,堆可以看作在“精化(refinement)”的完全二叉树,完全二叉树的数组表示中,数组后半段A[n/2]~A[n-1]都输树的叶子节点,因此,每个叶子节点都可以看做是只有一个元素的堆,建立堆的过程,就是从叶子节点的父节点开始,逐步上溯(递归思想),使得整个数组具备堆的特点,为此我们先引入一个辅助函数,它完成对指定的数组段得“堆化”的过程,这个函数就叫heapify。
//A[]为待“堆化”的数组,start和len确定了待“堆化”的子数组
template<typename T>
inline void heapify(T A[], start, int len){
int son = start>>1+1; // 左子节点索引,因为下标从0开始
T item = A[start];
while(son <= len){
if(son < len-1 &&(A[son] < A[son+1])) ++son; // 移至右子节点
if(item >= A[son]) break; // (*)
a[son>>1] = a[son]; //a[son>>1]就是父节点了,(*)行没有break出去,说明违反堆性质
son <<=1; //孙子节点
}
a[son>>1] = item;
}
读懂代码往往是需要想象力的,尤其是算法性质的代码。而想象力最好的体现办法就是画图,请见图3(本图摘自《算法导论》第75页)。
每次循环中,从A[i], A[son], A[son+1]三者中选出最大的,将其“上移”。其实就这么简单。而这个复杂度应该也是很明显的O(logN)吧。
有了heapify,建堆的过程应该就很简单了,前面说了,从叶子节点的父节点开始,逐步上溯(递归思想),使得整个数组具备堆的特点,具体操作上,就是对从叶子节点的父节点开始的数组前半段,依次调用heapify。
template<typename T>
make_heap(T A[], int len){
for(int i = len>>1; i; --i)
heapify(A, i, len-1);
}
图我就不画了,请见《算法导论》第77页图6-3。
这个建堆的过程是一个典型的递归过程——是的,你懂的,递归可不一定就要一个劲自己个调用自己个,循环也可以。每次循环中都不断检查子树是否满足堆性质,不满足就调整,调整完就将循环下标前移。
插入与删除其实也简单,主要是有一种情况要考虑到,就是新插入的元素和删除的元素可能改变现有的堆结构性质。所以要进行相关的验证,验证失败后就要再次heapify了。
template<typename T>
bool insert_heap(T A[], T item, int& heapsize)
{
int index = ++heapsize; // 初始时当然就是插入到数组尾部了
if(index == MAX_SIZE){
std::cerr<<"heap size exceeded/n";
return false;
}
A[index] = item;
heapify(A, 0, heapsize-1);
return true;
}
下面给出的是不使用heapify的insert_heap()版本,基本上没什么区别,我写出来主要是为了扩展下思维和视野,意识到解决问题的方案永远不止一种,很非常好滴呀;另外,STL中使用的是这个版本的思路。不过我个人还是偏向于heapify版本的实现,简洁,复用性也体现了。
template<typename T>
bool insert_heap(T A[], T item, int& heapsize)
{
int index = ++heapsize; // 初始时当然就是插入到数组尾部了
if(index == MAX_SIZE){
std::cerr<<"heap size exceeded/n";
return false;
}
while(index > 0 && A[index >>1] < item){
A[index] = A[index >>1];
index >>=1;
}
A[index] = item;
return true;
}
初始时当然默认新插入的元素在数组尾部了,在侯捷老师《STL源码剖析》中将其抽象为一个hole,嗯,我觉得很好,下面是从这本书的繁体版174页图4-21。看着这个图,插入的过程应该是非常好理解的。
在这里想提一个小插曲,其实建堆的过程,我们也可以使用insert函数,就是每次从读取一个数据,然后插入到堆中,《算法导论》第六章习题就提到了这一点。
对于堆的删除操作,一般来说,删除的仅仅是堆顶元素,很少有指定一个索引值删除某元素或是指定元素值删除。所以正文部分就不讨论了,不过这个操作我们也实现一番,留到第三小节去,,下面是堆顶元素的删除函数。
template<typename T>
bool delete(T A[], int& heapsize){
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
T tmp = A[0];
A[0] = A[heapsize];
A[heapsize--] = tmp;
heapify(A, 0, heapsize);
}
上面这段代码很简单,先将堆顶元素与堆尾元素交换,然后使堆的有效长度减1,然后重新对堆进行heapify。与插入操作的版本一样,下面给出的是仿STL的实现,如果您对STL源码没什么兴趣,可以不看。
template<typename T>
bool delete(T A[], int& heapsize){
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
T item = A[heapsize - 1]; // 保存下最后一片叶子的值
A[heapsize - 1] = A[0]; //原来堆顶的值放到数组尾部
--heapsize; //因为原来的item已delete出去了,所以数组实际长度减1
int index = 0;
int child_index = index*2 + 2; //取右子节点
while(child_index < heapsize){
if(A[child_index] < A[child_index - 1]) //取子节点数值最大者
--child_index;
A[index] = A[child_index];
index = child_index;
child_index = index*2 + 2;
}
//处理边界情况,完全二叉树最后一片叶子为左子节点
if(child_index == heapsize){
A[index] = A[child_index - 1];
index = child_index - 1;
}
insert_heap(A, item, index);
}
与删除操作一样,查找(读取)操作应该是读取堆顶元素,这个O(1)时间就可以实现了,也非常简单,直接返回A[0]就百事OK;对于修改操作,一般的也还是修改堆顶元素,变大或者变小,根据情况重新heapify一下就可以,如果是指定一个索引值修改某元素或是指定元素值进行修改,我想,现实中也不会完全没这种可能,所以我们在1.3节也给出相应的实现。下面先给出一般的读取与修改的代码。
template<typename T>
inline bool getTop(T A[], int heapsize, const T& res)
{
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
res = A[0];
return true;
}
template<typename T>
bool modify(T A[], int heapsize, T dest)
{
if(!heapsize){
std::cerr<<"heap is empty/n";
return false;
}
A[0] = dest;
// 我们一直都假设堆为最大堆
if( dest < A[0])
heapify(A, 0, heapsize-1);
return true;
}
对于合并(merge)操作,一般书上讲到二叉堆不会提到合并操作,因为这不是二叉堆所擅长的,在本系列的后面的部分中专门分析二项堆和斐波那契堆的时候还会提到,这里提到合并操作,既是考虑到思维的全面性,也是为了后面分析二项堆/斐波那契堆的时候有个对比。单独考虑二叉堆的合并,假设第一个堆的大小为N,第二个为M,主要有两个办法, 一种是依次取出第二个堆中的元素插入到第一个堆中,这种办法的复杂度为O(MlogN);另一种是先将第二个堆中的元素拷贝到第一个堆中,再执行一个heapify(),这种办法的复杂度为O(M+M+N),一般认为第二种情况下的复杂度优于第一种情况,事实上《算法导论》这是这么说的。下面是基本代码。
Merge操作的实现请见1.3节。
限于篇幅,完整的C++实现只能上传到CSDN下载频道去,本节主要给出二叉堆的接口,当然,我们学习数据结构,其实学的就是实现,关于这一切,我都上传到了这里,里面包含了相应的测试文件。下面仅给出接口部分。
template<typename T, typename Compare = heap_aux::greater_equal<T> >
class binaryheap{
public:
binaryheap(const Compare& _comp = Compare());
binaryheap(int size, const Compare& _comp = Compare());
binaryheap(std::vector<T> _coll, const Compare& _comp = Compare());
~binaryheap(){}
public:
inline const int get_heapsize() const;
inline const T& getTop() const;
inline const T& get_elem(int index) const;
public:
void insert(T item);
void delete_heap(int index = 0);
void modify_top(T dest);
void merge(const binaryheap& other);
private:
inline void make_heap();
inline void heapify(int start, int len);
private:
std::vector<T> coll;
int heapsize;
Compare comp;
};
在我的测试中,分为正确性测试与耗时测试两部分,都比较好懂。值得一提的是,与STL的make_heap相比较,binaryheap不知道为什么快了那么多,最快都快了50.4%,如下图所示。难道STL精深的封装与嵌套,竟要如此耗费了如此大的开销??
下期预告:以上基本上都还是一些理论性质的东西,下期我们将给出完整的二叉堆和D堆的C++实现,Python实现留待以后,因为我现在时间并不是非常充足。下期还将给出几个和堆关系紧密的现实算法问题及ACM训练题。所谓“古来学问无余力,少壮工夫老始成。纸上得来终觉浅,绝知此事要躬行”。
走,我们编程去!~
PS. 个人联系方式:
微博: http://t.sina.com.cn/g7tianyi
del.icio.us: http://delicious.com/fairyroad
豆瓣:http://www.douban.com/people/Jackierasy/
e-mail: [email protected]