之所以叫回味,那是因为在高中的时候偶然也接触了这个数据结构。那时候好像是在学别的东西,然后我先学完了,于是就多学了这个。但是由于种种原因,可能是NOIP没考吧,然后就忘记的差不多了,但是隐约记得一些。前几天也是无意之中看到可并堆,正好也没有它的模板,于是就来“回味”了一番。
言归正传,左偏树,顾名思义,就是往左偏的一种树,具体表现就是对于任何一个节点,它的左儿子的dist>右儿子的dist,这的的dist是指该点到达往下叶子节点的距离。可以想象,这样的一棵树是高度不平衡的,这也决定了它不能够像平衡树那样快速的查找,说到底它就是一个合并的堆,所以也被称之为可并堆。其实可并堆还有斜堆等,但是左偏树的左偏性质使得它可以更加高效地完成合并操作,下面具体介绍如何做到的。
首先,这个数据结构的实现要依赖于并查集,因为对于每次合并的操作,我们都要改变两个节点的根,那么我们正好可以利用并查集来储存每个节点根的信息。在这里,我们特别的规定:对于合并操作merge(u,v),u和v并不一定是两棵树的根或者说堆顶,但是我们实际合并的却是两棵树的根。一开始时,每个节点独立为子树,然后再逐渐合并。
下面具体说说merge操作。merge定义一个返回值,因为合并两棵树一定会有一个新的根节点,我们就返回这个根节点。合并时,先比较两个根的大小,(以大根堆为例)让较大的树的右子树与小的树继续合并,因为右子树的dist更小,所以可以最大程度的减少递归调用次数,这就是左偏树合并效率高的原因。合并后,再维护左偏的性质,如果右儿子的dist已经左儿子的dist,那么交换左右儿子,同时更新父亲的dist。最后别忘了用并查集维护涉及到的点的新根。具体见代码:
inline int merge(int x,int y)
{
if (!x||!y) return x+y;
if (tree[x].num<tree[y].num) swap(x,y);
tree[x].r=merge(y,tree[x].r); //合并右儿子和相对小的根
f[tree[x].r]=x; //维护根节点
if (tree[tree[x].l].dist<tree[tree[x].r].dist) swap(tree[x].l,tree[x].r); //维护左偏性质
if (tree[x].r) tree[x].dist=tree[tree[x].r].dist+1; //维护dist
else tree[x].dist=0;
return x; //返回根
}
然后再说说del(x)操作,这个就更简单了,只需要把该节点的两个子树合并修改根即可。代码也很简洁:
inline int del(int x)
{
int l=tree[x].l,r=tree[x].r;
tree[x].l=tree[x].r=tree[x].dist=0; //清空节点信息
f[l]=l; f[r]=r;
return merge(l,r); //合并左右子树
}
如你所见,左偏树(可并堆)是一种编写简单、速度快、易于理解的一种数据结构,与同类型的二叉堆、Fibonacci堆、斜堆比起来具有综合性的优势。Fibonacci堆虽然快但是编程复杂度极高,所以综合来说这是个非常好的数据结构。下面附一个对比的表格,顺便把时间复杂度交代一下:
项目 | 二叉堆 | 左偏树 | 二项堆 | Fibonacci堆 |
构建 | O(n) | O(n) | O(n) | O(n) |
插入 | O(logn) | O(logn) | O(logn) | O(1) |
取最小节点 | O(1) | O(1) | O(logn) | O(1) |
删除最小节点 | O(logn) | O(logn) | O(logn) | O(logn) |
删除任意节点 | O(logn) | O(logn) | O(logn) | O(logn) |
合并 | O(n) | O(logn) | O(logn) | O(1) |
空间需求 | 最小 | 较小 | 一般 | 较大 |
编程复杂度 | 最低 | 较低 | 较高 | 很高 |