注:这里的堆仍是以小根堆为例。算法
咱们想要设计一种堆能像二叉堆那样高效地支持合并操做,也就是$O\left( n \right)$时间处理一次Merge,并且只使用一个数组看起来很困难对吧,毕竟合并操做须要把一个数组复制到另一个数组中去,对于相同大小的堆这会花费$\theta \left( n \right)$。也正因如此,咱们能够看到,在此前全部支持高效合并的高级数据结构都须要用到指针。可是!在实践中就有一些问题,它会使操做效率变低,由于处理指针通常而言比乘除法操做更耗费时间。数组
那该怎么作呢?二叉堆进化——左式堆,又叫左偏树。像二叉堆那样,左式堆也具备结构特性和有序性,有着和二叉堆相同的heap property。他和二叉堆的惟一区别是:左式堆不是理想平衡的,而是趋向于极其不平衡,在拓扑形态上更倾向于向左倾斜的。那么,为何要引入这一新的变种呢?让咱们从它的设计动机以及结构定义提及。前面说了,咱们的目的是完成高效合并,现有的不少方法已经足够合并了,可是太慢。而数据结构的精髓就是不断优化性能,因此须要引入新的结构来完成这个目的。下面先来逐一分析各类拍脑壳的算法,而后逐渐逼近此次的主角。数据结构
最平凡的思路是以大的为基础,把小的堆里元素逐一取出插入到A中,B为空时就完成了。性能
能够一句话归纳:Insert(DeleteMax(B),A);优化
但这太慢了,简直龟速。分析一下,咱们把两个堆的规模记做n和m,不失通常性地,假设A不小于B,也就是:$\left| A \right|\; =\; n\; \; \geq \; m\; =\; \left| B \right|$。因此整个算法迭代$m$次,在每一次里deleteMax(B)要花费$\log m$的时间,把这个元素汇入A中花费$\log \left( n\; +\; m \right)$的时间。因此一共是$O\left( \; m\; \cdot \; \left( \log m\; +\; \log \left( n\; +\; m \right) \right) \right)\; =\; O\left( m\; \cdot \; \log \left( n\; +\; m \right) \right)\; =\; O\left( m\cdot \; \log n \right)$的时间。恐怕你本身恐怕都不知足这个效率,由于的确有改进的空间。咱们会再想起Floyd批量建堆算法,嗯没错,更高效的办法是先把两个堆混合起来,而后经过下滤,维护整个堆的结构特性,把它整理为一个彻底二叉堆。归纳一下就是BuildHeap(n+m, union(A,B));而Floyd算法只须要线性时间,也就是总共$O\left( n\; +\; m \right)$,这个效率就高一些了。ui
但这还不能使人满意,缘由在于:Floyd算法的输入默认是无序的,而咱们的两堆分别都是有序的,刚才这个算法没有利用到咱们已知的信息,若是利用上了这部分有序的信息,就能够加速执行了。从这个角度出发,咱们有理由相信:必定存在更高效的数据结构和相应算法。的确如此,Clark Allan Crane历经探索,发明了一个新的结构:左式堆,并于1972年以此发表了他的博士论文$Linear\; Lists\; and\; Priority\; Queues\; as\; Balanced\; \mbox{Bi}nary\; T\mbox{re}es$。这种结构在保持堆序的前提下附加少许条件,就能在合并过程当中只须要调整不多的节点,插入和删除都仅须要$O\left( \log n \right)$的时间。比刚才的$O\left( n\cdot \log n \right)$和$O\left( n \right)$都有了长足的进步。他的这个新条件就是“单侧倾斜”,节点分布都偏向左侧,而算法高效的诀窍是合并操做只涉及右侧,而右侧节点不多。spa
好比这就是左式堆的典型图解,左长右短,它能够把右侧节点严格控制在$O\left( \log n \right)$之内,这也印证了上面说的合并操做的复杂度在O(logn)范围内。这也引起出一个定理:在右侧路径上有 $r$ 个节点的左式堆必然至少有 $2^{r\; }-\; 1$ 个节点。设计
那它是怎么作到这么快的呢?如今讨论这个还为时过早,由于首先要回答另外一个问题。前面第三天然段提到过:它不是理想平衡的,而是趋向于极其不平衡。不平衡的话结构性就荡然无存了,但咱们须要明白的是:对于Heap,堆序才是本质特征,其余的都无所谓,在必要时刻均可以牺牲掉,毕竟,计算机科学就是一门关于权衡的学问。3d
如今咱们来讨论一下左式堆的性质,引入一个概念:零路径长(null path length,npl)定义为从某个节点X到一个叶子的最短路径长。上图中节点内标示的就是。所以具备0 or 1个儿子的节点的npl是0,定义$npl\; \left( \; null\; \right)\; =\; -1$。那很天然,对于每一个节点的npl有以下计算公式:指针
$npl\left( \; x\; \right)\; =\; \; 1\; +\; \min \left( \; npl\left( \; lc\; \right)\; ,\; npl\; \left( \; rc\; \right)\; \right)$
看上去和某个公式有点眼熟啊,求树高度的算法,和这个很相似,就是把min换成了max,经过类比咱们或许对这两个概念能有更深的认识。
有了这个指标,咱们就能够以此来度量堆结构的倾斜性。若是左孩子的npl不小于右孩子,就称之为左倾(政治上追求进步2333),若是每一个节点都符合这个性质,就称为左倾堆or左式堆。又由于npl定义是在两个孩子中取一个小值+1,那么咱们只考虑右边就好了。总结以下:
左倾:对任何节点 $x$,都有$npl\left( \; x->\; lc\; \right)\; \geq \; npl\left( \; x->rc\; \right)$
推论:对任何节点 $x$,都有$npl\left( \; x\; \right)\; =\; 1\; +\; npl\left( \; x->rc\; \right)$
咱们也能够推论:左式堆的任何一个子堆也必然是左式堆。第三天然段说过左式堆倾向于节点向左倾斜,但这只是大体的倾向,实际状况不必定都向左。
下面讨论实现,先说合并,而后是插入和删除。
先说一下类型声明
#ifndef LeftHeap_h #define LeftHeap_h struct TreeNode; typedef struct TreeNode *LefHeap; LefHeap Init(); int FindMin(LefHeap H); LefHeap Merge(LefHeap H1,LefHeap H2); //#define Insert(X,H) (H=Insert1((X),H)) void Insert(int x,LefHeap H); int DeleteMin(LefHeap H); LefHeap Insert1(int x,LefHeap H); LefHeap DeleteMin1(LefHeap H); #endif /* LeftHeap_h */ struct TreeNode{ int value; LefHeap left; LefHeap right; int npl; };
采用递归的模式能够很是简明的描述合并算法,对于通常情形:
能够借助递归将a、b两个堆合并的问题转化为这样一个问题:
具体来讲也就是咱们要将a的右子堆取出,而且递归地与刚才的堆b完成合并,合并所得的结果继续做为a的右子堆。固然,为了保证a在此后继续知足左倾性,在此次合并返回以后,咱们还须比较a_L与合并以后这个堆的npl值,若是有必要,咱们还需令两者互换位置。递归写法以下
void swap(LefHeap h1,LefHeap h2){ LefHeap temp=h1; h1=h2; h2=temp; } static LefHeap Merge1(LefHeap H1,LefHeap H2); void SwapChildren(LefHeap H1){ swap(H1->left, H1->right); } LefHeap Merge(LefHeap a,LefHeap b){ //递归基 if(!a) return b; if (!b) return a; /*执行到这一句以后就说明两个堆都不为空,此时咱们要比较两个根节点在数值上的大小,若是有必要应将两者互换名称。从而保证在数值上a老是不小于b,以便在后续递归的过程当中将b做为a的后代。 */ if (a->value < b->value) swap(a, b); //通常状况下首先确保a更大,而后执行合并 a->right=Merge(a->right, b); //以后咱们要保证a的左倾性: if(!a->left || a->left->npl < a->right->npl) //若是有必要,咱们就交换a的左右子堆,以确保右子堆的npl更小 SwapChildren(a); //而后更新a的npl a->npl=a->right->npl+1; return a;//返回合并后的堆顶 }
具体例子以下:
最终:
要注意的是,在合并以后,原始的两个堆就别再碰了,由于他们自己的变化会影响合并的结果。执行合并的时间与右侧路径的长度之和成正比,由于在递归期间每个被访问节点执行的是常数工做量。所以合并的时间界限为$O\left( \log n \right)$,也能够分两趟用非递归的方式来作:第一趟,经过合并2个堆的右路径创建一颗新树。为此咱们要以升序(or降序,反正要保持有序)安排a,b右路径上的节点,保持左孩子不变。在这个例子中,新的右路径是3,6,7,8,18。第二趟构成左式堆,在那些性质被破坏的节点上进行交换,交换这些节点的两个孩子。
对于插入,能够经过把插入项看做单节点堆并执行一次Merge。
void Insert(int x,LefHeap H){ LefHeap fresh; fresh=malloc(sizeof(struct TreeNode)); fresh->value=x; fresh->npl=0; fresh->left=fresh->right=NULL; H=Merge(fresh, H); }
删除的话,就是除掉根获得两个堆,而后再合并,所以时间仍是$O\left( \log n \right)$
int DeleteMin(LefHeap H){ LefHeap l=H->left; LefHeap r=H->right; int t=H->value;//前三句都是铺垫,对相关的数据做备份而已。 free(H);//根节点的物理摘除由这一句来完成。 //此后只需将此时被隔离开的左子堆与右子堆从新地合并起来。 H=Merge(l, r); return t; }
能够看到,按照这一方式,不管是左式堆的删除仍是刚才的插入操做,实质的计算无非都集中在合并接口上。此前介绍过,合并能够高效率地在$O\left( \log n \right)$的时间内完成,那如此实现的删除以及刚才实现的插入操做也能达到这样的计算效率。一样的计算效率,更为简明的实现方法,咱们还有什么理由不采用这种方式呢?
实际上关于分合之道,左式堆的发明者Crane堪称个中高手。除了左式堆,他还针对其它的许多数据结构给出了高效的合并算法。好比对于咱们已经熟悉的AVL树,Crane也给出了一个高效的合并算法,有兴趣不妨找找相关文章。
下一篇文章讨论二项队列,与以往不一样的是,它并不是是一颗堆序的树,而是森林。
p.s. 这段时间要备考托福,因此下篇文章大概会在11月左右发。