左偏树 Leftist Tree
这是一个由堆(优先队列)推广而来的神奇数据结构,我们先来了解一下它。
简单的来说,左偏树可以实现一般堆的所有功能,如查询最值,删除堆顶元素,加入新元素等,时间复杂度也均相等,与其不同的是,左偏树还可以在\(O(log_2n)\)的时间之内实现两个堆的合并操作,这是一般的堆无法做到的。
特点
当然,左偏树是一个树形数据结构,我们需要像线段树一样使用一个结构体来记录每一个节点上的若干信息,以便于进行查询,合并等操作,具体如下:
- 1.\(val\)值,代表该编号元素的权值
- 2.\(dis\)值,代表该节点到叶子节点的最短距离
- 3.\(l\)值,代表该节点的左儿子编号
- 4.\(r\)值,代表该节点的右儿子编号
- 5.\(f\)值,代表该节点的父亲编号
对于这样的二叉树结构,我们还是用数组中父子二倍的方式来储存的,当然当某个节点\(x\)为叶子节点时,满足\(l(x)=r(x)=0\),当某个节点\(x\)为根节点时,满足\(f(x)=x\)(类似于并查集,作用稍后会讲)。
如图,这就是一棵左偏树。
\(Code:\)
struct LeftistTree
{
int dis,val,l,r,f;
#define dis(x) (tree[x].dis)
#define val(x) (tree[x].val)
#define l(x) (tree[x].l)
#define r(x) (tree[x].r)
#define f(x) (tree[x].f)
}tree[N];
性质
左偏树需要具有以下几点重要的性质,请读者牢记。
- 1.堆性质:\(val(x)\leq val(l(x))\)且\(val(x)\leq val(r(x))\)(以小根堆为例)
- 2.左偏性质\(1\):对于任意\(x\),\(dis(r(x))\leq dis(l(x))\)
- 3.左偏性质\(2\):对于任意\(x\),\(dis(x)=dis(r(x))+1\)
对于堆性质,相信读者能够很好地理解。对于两个左偏性质,读者在此完全可以先认为:左偏树维护了一棵树左偏的形态,也就是说,对于每一个棵子树,尽量使右儿子离根节点近,即这棵树左重右轻。
还有一个有关时间复杂度的性质,证明如下:
节点数为\(n\)的左偏树,其最大\(dis\)值至多为\(log_2(n+1)-1\)
合并 (merge)
合并操作是左偏树最重要的操作,必须深刻理解。
假设我们已经得到了两个左偏树,我们考虑如果将其合并。我们要使整棵树的时间复杂度得到保证,也就是说要让树的层数尽量小。由于我们维护了每一个右儿子\(dis\)值尽量的小,所以我们可以将一个合并问题\(merge(x,y)\)转化为合并一棵树的右儿子和另一棵树,即\(merge(r(x),y)\)。当然,为了维护堆性质,我们还要保证\(val(x)<val(y)\)。
完成合并后,由于根节点的右儿子可能发生了变化,所以我们要对右儿子的父亲重新进行更新。
为了维护左偏性质\(1\)和\(2\),以便之后的合并操作,我们可能还需要再对左右子树进行交换,并顺带更新\(dis\)值,即令\(dis(x)=dis(r(x))+1\)。
由之前的证明可知,节点数为\(n\)的左偏树,其最大\(dis\)值至多为\(log_2(n+1)-1\),那么我们的合并操作的最大时间复杂度即为\[O(\max_{i \in tree(x)}\{dis_i\}+\max_{j \in tree(y)}\{dis_j\})\\=O(2log_2(n+1)-2)=O(log_2n)\]
,符合我们的需要。
\(Code:\)
inline int merge(int x,int y)//返回值的含义为合并后新树的根节点编号
{
if(!x||!y)return x|y;//如果x,y有一棵是空树,返回另一棵的编号
if(val(x)>val(y)||(val(x)==val(y)&&x>y))//维护堆性质,把权值大的合并到权值小的上
swap(x,y);
r(x)=merge(r(x),y);//递归合并x的右子树与y
f(r(x))=x;//更新右子树父亲
if(dis(l(x))<dis(r(x)))//维护左偏性质1
swap(l(x),r(x));
dis(x)=dis(r(x))+1;//维护左偏性质2
return x;
}
取出堆顶 (find)
除了合并外,左偏树当然要实现它的本职工作,查询最值。
由于我们已经维护好了左偏树,只要直接输出树根的权值即为最小值。
\(Code:\)
inline int find(int x)
{
return f(x)==x?x:f(x)=find(f(x));
//由于树的最大深度未知,直接查询可能会导致时间复杂度退化为O(n)
//所以要用并查集的路径压缩写法
}
int Min=val(find(p));//查询节点p所在左偏树的最小值,p已经被删除则返回-1
删除堆顶元素 (remove)
当然,删除最值元素的操作也是可以简易地实现的。我们只需要将该节点的权值赋为\(-1\),并将以其左右儿子为根的两棵子树合并即可。
值得注意的是,我们还要将删除节点的父亲节点赋为两棵子树合并后的节点编号。由于我们查询最值用的是路径压缩,所以树中某些节点的父亲可能已经直接压缩到了当前节点,而现在当前节点又要被删除,所以我们要将当前节点的父亲再赋值为删去后新树的根节点,在查询时可以再找回新树,以避免查询错误。
\(Code:\)
inline void remove(int x)
{
val(x)=-1;//清空权值
f(l(x))=l(x);f(r(x))=r(x);//重置左右儿子的父亲
f(x)=merge(l(x),r(x));//将父亲重新赋值为新树的根节点
}
至此,左偏树的基本代码已经实现,下面通过洛谷的一道模板题给出代码。
左偏树
Description
如题,一开始有N个小根堆,每个堆包含且仅包含一个数。接下来需要支持两种操作:
操作1: 1 x y 将第x个数和第y个数所在的小根堆合并(若第x或第y个数已经被删除或第x和第y个数在用一个堆内,则无视此操作)
操作2: 2 x 输出第x个数所在的堆最小数,并将其删除(若第x个数已经被删除,则输出-1并无视删除操作)
Input Format
第一行包含两个正整数N、M,分别表示一开始小根堆的个数和接下来操作的个数。
第二行包含N个正整数,其中第i个正整数表示第i个小根堆初始时包含且仅包含的数。
接下来M行每行2个或3个正整数,表示一条操作,格式如下:
操作1 : 1 x y
操作2 : 2 x
Output Format
输出包含若干行整数,分别依次对应每一个操作2所得的结果。
Sample Input
5 5
1 5 4 2 3
1 1 5
1 2 5
2 2
1 4 2
2 2
Sample Output
1
2
\(Code:\)
#include<bits/stdc++.h>
using namespace std;
#define filein(str) freopen(str".in","r",stdin)
#define fileout(str) freopen(str".out","w",stdout)
const int N=100000+20;
struct LeftistTree
{
int dis,val,l,r,f;
#define dis(x) (tree[x].dis)
#define val(x) (tree[x].val)
#define l(x) (tree[x].l)
#define r(x) (tree[x].r)
#define f(x) (tree[x].f)
}tree[N];
int n,m;
inline int find(int x)
{
return f(x)==x?x:f(x)=find(f(x));
}
inline int merge(int x,int y)
{
if(!x||!y)return x|y;
if(val(x)>val(y)||(val(x)==val(y)&&x>y))
swap(x,y);
r(x)=merge(r(x),y);
f(r(x))=x;
if(dis(l(x))<dis(r(x)))
swap(l(x),r(x));
dis(x)=dis(r(x))+1;
return x;
}
inline void remove(int x)
{
val(x)=-1;
f(l(x))=l(x);f(r(x))=r(x);
f(x)=merge(l(x),r(x));
}
inline void input(void)
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
f(i)=i;
scanf("%d",&val(i));
}
int op,x,y;dis(0)=-1;
for(int i=1;i<=m;i++)
{
scanf("%d",&op);
if(op==1)
{
scanf("%d%d",&x,&y);
if(val(x)==-1||val(y)==-1)continue;
int fx=find(x),fy=find(y);
if(fx^fy)
f(fx)=f(fy)=merge(fx,fy);
}
if(op==2)
{
scanf("%d",&x);
if(val(x)==-1)
{
printf("-1\n");
continue;
}
int fx=find(x);
printf("%d\n",val(fx));
remove(fx);
}
}
}
int main(void)
{
input();
return 0;
}