经典的二叉堆已经可以在O(logn)的复杂度的情况下维护堆这样的数据结构,也有d-堆可以维护成O(logdn)(虽然pop操作的复杂度是dlogdn),然而这两种堆不能满足logn的合并操作,它们的经常是O(nlogn),即每次将一个堆中的堆顶拿出来放到另一个堆里。虽然有很多情况不经常合并,但有时候我们就是想要合并堆,还想频繁地合并,这时候二叉堆的性能就显得不是很好了。左偏树首先解决了合并问题。
左偏树像二叉堆一样是个二叉树,但是并不是完全二叉树。左偏树定义时用到了一个附加值:npl。npl[x]指节点x通向NULL的最短路径。一般NULL的npl为-1;若A只有一个子节点或者没有,那么npl[A]=0;否则,npl[A]=min{npl[left[A]],npl[right[A]]}+1。乍一看像是高度的定义。
左偏树的定义如下:对于左偏树T,要么只有一个节点,要么左右子树L、R满足:npl[L]≥npl[R]。
满足条件的左偏树有性质:对于树根T,总有:npl[T]=npl[right[T]]+1。对于T,若npl[T]=l,则整棵树的节点数不少于2l-1。
性质1很好得出;性质二书上利用数学归纳法。不过简单讲就是左子树的npl大于等于l-1,一直取左子树npl=l-1,由性质1得右子树npl=l-1,可得左右子树全等,那么这就是一棵满二叉树,n=2l-1,由于左子树总是可以多任意多节点,所以这样就可以得到性质二。性质二反过来讲相当于若T有n个节点,那么右路径的长度不超过logn(右路径为从根一直通向右子树直到NULL的路径)。左偏树满足这个性质后,总是把合并放到右路径上解决,这样保证了合并也是O(logn)的时间复杂度。
合并是这样的:合并两棵树时,令根键值小树的根为新根,再递归合并新根的右子树和另一棵树。回溯时若发现某一结点不满足左偏树的定义,即左子树的npl小于右子树的npl,那么交换左右子树,这样合并操作总能保持O(logn)。这样做之后,插入即合并一个节点和原树,删除堆顶即合并树根的两个子树作为新根。
代码:
#include<cstdio> #define min(a,b) (a<b?a:b) #define nil 0 #define MXN 100000+1 int val[MXN],left[MXN],right[MXN],npl[MXN],recycle[MXN]; int root,ntop,rtop=-1; int newnode(int k){ int nw; if(rtop==-1) nw=++ntop; else nw=recycle[rtop--]; val[nw]=k; left[nw]=right[nw]=nil; npl[nw]=1; return nw; } void update(int now){ npl[now]=min(npl[left[now]],npl[right[now]])+1; return; } void swap(int &a,int &b){ int t=a; a=b,b=t; return; } int merge(int A,int B); int merge1(int A,int B); int merge(int A,int B){ if(A==nil) return B; if(B==nil) return A; if(val[A]<val[B]) return merge1(A,B); else return merge1(B,A); } int merge1(int A,int B){ if(left[A]==nil) left[A]=B; else{ right[A]=merge(right[A],B); if(npl[left[A]]<npl[right[A]]) swap(left[A],right[A]); npl[A]=npl[right[A]]+1; } return A; } void insert(int k){ int ins=newnode(k); if(root==nil) root=ins; else root=merge(root,ins); return; } int top(){ return val[root]; } void pop(){ recycle[++rtop]=root; int y=merge(left[root],right[root]); root=y; return; } int main(){ int p,x; while(1){ scanf("%d",&p); if(p==0) break; if(p==1) printf("%d\n",top()); if(p==2){ scanf("%d",&x); insert(x); } if(p==3) pop(); } return 0; }
另外讲一下左偏树的变种:斜堆。斜堆只是把npl这个限制条件拿掉,每次合并不论情况总是交换左右子树,这样使得期望的复杂度是O(logn)。像Splay一样,斜堆是自调整式数据结构,不需要记录任何额外变量。但是斜堆存在的劣势是右路径有时可能变得较长,面对较大数据递归时可能超过深度。不过左偏树和斜堆都可以写出非递归的程序,所以这不是太大的问题。
注:虽然在堆里实现查找多数情况下不现实,但是有时候堆还是被要求具有更改某些节点键值的不合理操作。如果想要具有减小键值这一操作(暂时不管如何找到应该减小键值的节点),那么左偏树或者斜堆都要记录父亲指针,进行向上调整。不过这无法保证对数时间复杂度。这是更强大的可并堆出现的原因之一。