浅谈左偏树入门
前言
要维护一段区间内的最值时,我们可以用堆(平衡树)来操作。
但是,如果要合并两个堆,复杂度就极高了。
所以,我们就要使用左偏树这个神奇的数据结构,来实现堆的合并。
引子 左偏树是什么?
左偏树是什么?
当然是向左偏的树。(废话)
实际上,现在来解释左偏树的概念还是有点难的,所以我们要先来看一些定义。
与左偏树相关的一些定义
外节点
外节点,顾名思义,就是在外部的节点。
它的定义是有至少一个子节点是空节点的节点是外节点。
注意是至少一个,而不是全部,不然就变成叶节点了。
而记录外节点有什么用呢?
当我们需要插入一个节点时,如果找到了外节点,就可以轻松将其插入到这个外节点的某一空子节点。
距离
左偏树一个很重要的概念就是节点的距离,我们可以将其记作\(Dis(x)\)。
比较简单,一个节点的距离指的就是它到离它最近的外节点的距离。
对于该节点本身是外节点和该节点是空节点两种特殊情况,我们分别规定它们的距离为\(0\)和\(-1\)。
左偏树的一些性质
首先,我们要知道,左偏树是满足堆性质的(废话,它就是用来实现堆合并的)。
而左偏树另一比较重要的性质是左偏性。
让我们回到前面的那个问题,什么是左偏树?
现在我们可以对它进行解释了:对于左偏树中的任意一个节点,我们必须满足\(Dis(LeftSon)\ge Dis(RightSon)\),即左儿子离外节点的距离必须大于等于右儿子离外节点的距离。
而这就是左偏树的左偏性了。
左偏树其实还有一个比较重要的性质,这个性质在上传信息的操作中起到了重要的作用:对于左偏树中的任意一个节点,\(Dis(x)=Dis(RightSon)+1\)。
证明: 根据左偏性,可以得到\(Dis(RightSon)\le Dis(LeftSon)\),而左偏树中距离的定义是一个节点到离其最近的外节点的距离,故为\(Dis(RightSon)+1\)。
关于左偏树的合并操作
左偏树的核心操作就是它的合并(毕竟其他操作堆都能轻松实现)。
假设我们要合并两个根节点分别为\(x\)和\(y\)的左偏树。
首先,我们要判断\(x\)与\(y\)中是否存在空节点,如果有,可以直接退出函数。
然后,我们要比较权值大小(假设是小根堆),如果\(Val_x>Val_y\),则交换\(x\)和\(y\)(维护堆性质)。
接下来,我们要将\(x\)的右儿子取出,与\(y\)进行合并,然后将合并后得到的根作为\(x\)新的右儿子。
为什么要选右儿子?
因为左偏树的左偏性啊!
也就是说,选右儿子,可以更早找到空节点将另一棵左偏树插入。
这就保证了左偏树合并操作的时间复杂度。
要注意的是,在合并后,右儿子的距离可能会小于左儿子,此时就需要交换左右儿子,从而维护左偏性。
代码如下:
I int Merge(RI x,RI y)//将根节点为x和y的两棵左偏树合并
{
if(x==y||!x||!y) return x|y;(O[x].V>O[y].V||(O[x].V==O[y].V&&x>y))&&(swap(x,y),0),//如果有空或相同直接退出,否则比较权值大小维护堆性质
O[O[x].S[1]=Merge(O[x].S[1],y)].F=x,O[O[x].S[0]].D<O[O[x].S[1]].D&&(swap(O[x].S[0],O[x].S[1]),0);//将x的右儿子取出与y进行合并,然后比较左右儿子距离,维护左偏性
return O[x].D=O[O[x].S[1]].D+1,x;//更新距离,返回合并后的根节点x
}
关于左偏树的其他操作
理解了合并,左偏树的其他操作就很简单了。
-
删除
删除操作,其实就是合并被删除节点的两个子树。代码如下:
I void Pop(int x)//弹出堆顶
{
O[O[x].S[0]].F=O[O[x].S[1]].F=0,Merge(O[x].S[0],O[x].S[1]);//合并两棵子树
}
-
求堆顶元素
我们可以对每一个节点记录它的\(Father\)。
以前我一直写的是直接不断往上跳,但实际上左偏的那条链可能很长,复杂度假掉了。
因此我们需要写成并查集形式:
I int getfa(CI x) {return O[x].F?O[x].F=getfa(O[x].F):x;}//并查集
在删除堆顶的时候,我们把原本堆顶的父节点改成新的堆顶,保证并查集的正确性。
因此修改一下删除函数:
I void Pop(int x)//弹出堆顶
{
if(!~O[x].V) return;O[x=getfa(x)].V=-1,//删除
O[O[x].S[0]].F=O[O[x].S[1]].F=0,O[x].F=Merge(O[x].S[0],O[x].S[1]);//合并两棵子树,更新父节点
}
模版题:【洛谷3377】【模板】左偏树(可并堆)
到这里,其实左偏树讲得也差不多了。
相信大家都发现了,左偏树也没有想象中那么难。
下面以这道模板题为例,贴一份完整代码:
#include<bits/stdc++.h>
#define Tp template<typename Ty>
#define Ts template<typename Ty,typename... Ar>
#define Reg register
#define RI Reg int
#define Con const
#define CI Con int&
#define I inline
#define W while
#define N 100000
using namespace std;
int n;
namespace FastIO
{
#define FS 100000
#define tc() (FA==FB&&(FB=(FA=FI)+fread(FI,1,FS,stdin),FA==FB)?EOF:*FA++)
#define pc(c) (FC==FE&&(clear(),0),*FC++=c)
int OT;char oc,FI[FS],FO[FS],OS[FS],*FA=FI,*FB=FI,*FC=FO,*FE=FO+FS;
I void clear() {fwrite(FO,1,FC-FO,stdout),FC=FO;}
Tp I void read(Ty& x) {x=0;W(!isdigit(oc=tc()));W(x=(x<<3)+(x<<1)+(oc&15),isdigit(oc=tc()));}
Ts I void read(Ty& x,Ar&... y) {read(x),read(y...);}
Tp I void writeln(Ty x) {x<0&&(pc('-'),x=-x);W(OS[++OT]=x%10+48,x/=10);W(OT) pc(OS[OT--]);pc('\n');}
}using namespace FastIO;
class LeftistTree
{
private:
struct node {int V,D,F,S[2];}O[N+5];I int Merge(RI x,RI y)//合并
{
if(x==y||!x||!y) return x|y;(O[x].V>O[y].V||(O[x].V==O[y].V&&x>y))&&(swap(x,y),0),
O[O[x].S[1]=Merge(O[x].S[1],y)].F=x,O[O[x].S[0]].D<O[O[x].S[1]].D&&(swap(O[x].S[0],O[x].S[1]),0);
return O[x].D=O[O[x].S[1]].D+1,x;
}
I int getfa(CI x) {return O[x].F?O[x].F=getfa(O[x].F):x;}//找根节点
public:
I LeftistTree() {O[0].D=-1;}I void Init(CI x,CI v) {O[x].V=v;}
I void Union(CI x,CI y) {~O[x].V&&~O[y].V&&Merge(getfa(x),getfa(y));}//合并
I int Top(CI x) {return ~O[x].V?O[getfa(x)].V:-1;}//求根节点值
I void Pop(int x)//弹出根节点
{
if(!~O[x].V) return;O[x=getfa(x)].V=-1,
O[O[x].S[0]].F=O[O[x].S[1]].F=0,O[x].F=Merge(O[x].S[0],O[x].S[1]);
}
}L;
int main()
{
RI Qt,i,op,x,y;for(read(n,Qt),i=1;i<=n;++i) read(x),L.Init(i,x);
W(Qt--) read(op),op==1?(read(x,y),L.Union(x,y)):(read(x),writeln(L.Top(x)),L.Pop(x));
return clear(),0;
}
关于例题
有两道比较好的左偏树例题:
【BZOJ2809】【APIO2012】dispatching:这应该是比较裸的模板题,适合刚学左偏树的人练手。
【BZOJ4003】【JLOI2015】城池攻占:这道题就有一定的扩展了,需要在左偏树上加上懒惰标记,也是挺有意思的题目。