实链剖分之 Link-Cut Tree
重链剖分
Link-Cut Tree
回顾重链剖分,可以发现,它维护的是一棵静态的树。
当题目是森林时,尤其是有连边、断边等操作时,重链剖分不好维护。
所以,需要一种更为灵活的算法,也就是 LCT。
重链剖分是用的线段树维护原树。之前提到过,区间树除了用线段树可以实现,Splay 也是可以的。所以 LCT 一般用 Splay 维护树上信息。
而且,LCT 所使用的 Splay 和维护序列的 Splay 还有一些差别,属于 Splay 的扩展。
口胡
这一段好像没那么必要。
Splay 有一个特点:维护父指针。这是其他平衡树都没有的操作。
正是这个父指针的存在,使得 LCT 能够较好的维护树上信息。
说了这么多,到底怎么用 Splay 实现 LCT 来维护一片森林的呢?
首先,大多数博客里都会出现这样两个名词:原树、辅助树。
原树就是指的我们需要进行实链剖分的森林,那么辅助树就是维护实链的一林子 Splay 树。
一般来说,LCT 只需要维护辅助树就可以知道原树的一些性质。
既然是实链剖分,那么可以借鉴重链剖分那里的一些名字,比如:
- 实儿子、虚儿子
- 实边、虚边
- 实链
剖分完之后,我们就可以使用 Splay 维护一条实链,就像线段树维护一条条重链一样,保证 Splay 的节点中序遍历之后在实链中深度单调递增。
和重链剖分一样,每个节点最多有一个实儿子。不过,和重儿子不同的是,实儿子可以改变。
由于 Splay 是一棵 BST,为了区分虚儿子和实儿子,一个比较方便的方法就是通过单双向边。
显然,Splay 上每个节点都应该有一个父节点(根的父节点为 0 号节点)。
既然每个节点都认父亲,那么可以让父亲节点只认实儿子。
也就是说,每个节点都有向父亲节点连的单向边,但父亲节点只向实儿子连边,形成双向边。
这样的话,不仅能将原树上所有的点联系起来,而且对于辅助树上的操作(区间翻转)只会影响当前实链。
辅助函数
- Splay 相关
还是熟悉的几个函数:pd_son
、pushup
和 pushdown
。
bool pd_son(int i)
{
return T[T[i].fa].hz[1]==i;
}
int pushup(int i)
{
T[i].sum=T[i].val^T[T[i].hz[0]].sum^T[T[i].hz[1]].sum;
return i;
}
void pushdown(int i)
{
if(!T[i].tag)return;
swap(T[i].hz[0],T[i].hz[1]);
T[T[i].hz[0]].tag^=1;
T[T[i].hz[1]].tag^=1;
T[i].tag=0;
}
- LCT 相关
由于 LCT 是维护一群 Splay,而且每个 Splay 的根也有父节点,所以显然单纯的和普通 Splay 一样根据父节点是不是 \(0\) 来判断是不是 Splay 的根是行不通的。
由于 Splay 维护的是实链,如果当前节点向上连虚边,就说明这是一棵 Splay 的根。
bool pd_rot(int i)
{
return (T[T[i].fa].hz[0]^i)&&(T[T[i].fa].hz[1]^i);
}
某些时候,我们需要进行区间翻转操作(原因下文会提到),那么这条链上可能会有很多点会有翻转的懒标记。但是 splay 的时候,链上懒标记的存在会影响 splay 的正确性。所以需要将一条链上的懒标记全部下放:
void pushall(int i)
{
if(!pd_rot(i))pushall(T[i].fa);
pushdown(i);
}
基本操作和实现
温馨提示,Splay 的根和原树的根是不一样的,请注意区分。
旋转-rotate 和伸展-splay
rotate 和普通 Splay 的区别不大,只是需要判断虚实边的问题。
如果当前点的父节点是一棵 Splay 的根的话,也就是说父节点与祖父节点连的是虚边,那么当前节点与祖父节点之间也应该连虚边。
void rotate(int i)
{
int fa=T[i].fa,gf=T[fa].fa;
bool pdi=pd_son(i),pdf=pd_son(fa);
if(!pd_rot(fa))T[gf].hz[pdf]=i;T[i].fa=gf;
if(T[i].hz[pdi^1])T[T[i].hz[pdi^1]].fa=fa;
T[fa].hz[pdi]=T[i].hz[pdi^1];
T[i].hz[pdi^1]=fa,T[fa].fa=i;
pushup(fa),pushup(i);
}
splay 差别也不大,伸展前将懒标记全部下放,伸展到当前 Splay 的根即结束。
void splay(int i)
{
pushall(i);
for(int fa=T[i].fa;!pd_rot(i);rotate(i),fa=T[i].fa)
if(!pd_rot(fa))rotate(pd_son(i)^pd_son(fa)?i:fa);
}
打通-access
LCT 最核心的操作,没有之一。
顾名思义,access 的作用就是将当前点到原树的根的路径全部打通为实边。
这样就可以进行一些奇奇怪怪的操作了。
打通为实边其实很简单,每次将当前点伸展到所在 Splay 的根,然后更新右儿子,直到伸展到原树的根为止。
代码十分的简练:
void access(int i)
{
for(int s=0;i;s=i,i=T[i].fa)
splay(i),T[i].hz[1]=s,pushup(i);
}
换根-make_rot
第一步,打通当前点到根节点的路径。
第二步,将当前节点伸展到根。
第三步,为当前节点打上 tag。
由于我们在 Splay 上维护的是实链,而且是中序遍历按深度递增。
那么换根之后,原来的根节点的深度变为此链上最大的了,当前点(新的根节点)的深度变为了此链上最小的了。
也就对应了这棵 Splay 的区间翻转操作。
void make_rot(int i)
{
access(i);splay(i);
T[i].tag^=1;
}
找根-find_rot
找根十分简单,十分易懂。
对于一棵原树,其根节点必定是 Splay 的最左边的点。
先打通,在伸展,最后一直向左找。
为了防止卡链,找到之后还需要将点伸展到根。
int find_rot(int i)
{
access(i);splay(i);
while(1)
{
pushdown(i);
if(!T[i].hz[0])break;
i=T[i].hz[0];
}
splay(i);
return pushup(i);
}
连边-link
由于需要断边操作,所以连边必须两个点直接连起来,而不能只在两棵树的根节点连。
显然,一个点不能有两个父节点。
又显然,原树的根节点的父节点为 \(0\),更容易操作。
所以,我们先将其中一个点弄成根节点,然后再将与另一个点连虚边。
题目中不保证要连接的两点之间没有边,所以需要判断两点之间是否存在边,不存在才可以继续连。
void link(int x,int y)
{
make_rot(x);
if(find_rot(y)!=x)T[x].fa=y;
}
断边-cut
断边思路和连边类似,都是先将其中一个节点弄成根。
然后断实边。
同样,断边之前需要判断是否连边。
显然,将一个节点弄成根之后,与其直接相连的点必然是其后继。
那么需要两个判断:
-
两节点是否在同一棵树上。
-
另一个节点是否为当前点的后继。
第一条可以直接找根判断。
而第二条,由于找根的时候的一些操作,只需要判断另一个节点的父节点是不是当前点、另一个节点是否存在左子节点就好了。
void cut(int x,int y)
{
make_rot(x);
if(find_rot(y)==x&&T[y].fa==x&&!T[y].hz[0])
T[x].hz[1]=T[y].fa=0,pushup(x);
}
提取路径-split
就上述操作而言,我们只会提取一个节点到根的路径。
但我们还会将一个节点弄成根。
那么提取任意路径,不就是这两个操作结合起来吗?
提取完之后,需要一个节点代表整个实链。这个代表节点,链上随便一个点都可以,但习惯上还是用两端点其中一个代表实链。
void split(int x,int y)
{
make_rot(x);
access(y);splay(y);
}
总结
这篇文章其实是为了回忆一下 LCT,如果为了巩固和看代码,本文是一个不错的选择。
但如果是初学 LCT,这篇文章并不是一个好的选择。
AC Code(其实就是将上述所有代码拼到一起)
const int inf=1e5+7;
int n,m;
struct Splay{
int fa,hz[2];
int val,sum,tag;
}T[inf];
bool pd_son(int i)
{
return T[T[i].fa].hz[1]==i;
}
bool pd_rot(int i)
{
return (T[T[i].fa].hz[0]^i)&&(T[T[i].fa].hz[1]^i);
}
int pushup(int i)
{
T[i].sum=T[i].val^T[T[i].hz[0]].sum^T[T[i].hz[1]].sum;
return i;
}
void pushdown(int i)
{
if(!T[i].tag)return;
swap(T[i].hz[0],T[i].hz[1]);
T[T[i].hz[0]].tag^=1;
T[T[i].hz[1]].tag^=1;
T[i].tag=0;
}
void pushall(int i)
{
if(!pd_rot(i))pushall(T[i].fa);
pushdown(i);
}
void rotate(int i)
{
int fa=T[i].fa,gf=T[fa].fa;
bool pdi=pd_son(i),pdf=pd_son(fa);
if(!pd_rot(fa))T[gf].hz[pdf]=i;T[i].fa=gf;
if(T[i].hz[pdi^1])T[T[i].hz[pdi^1]].fa=fa;
T[fa].hz[pdi]=T[i].hz[pdi^1];
T[i].hz[pdi^1]=fa,T[fa].fa=i;
pushup(fa),pushup(i);
}
void splay(int i)
{
pushall(i);
for(int fa=T[i].fa;!pd_rot(i);rotate(i),fa=T[i].fa)
if(!pd_rot(fa))rotate(pd_son(i)^pd_son(fa)?i:fa);
}
void access(int i)
{
for(int s=0;i;s=i,i=T[i].fa)
splay(i),T[i].hz[1]=s,pushup(i);
}
void make_rot(int i)
{
access(i);splay(i);
T[i].tag^=1;
}
int find_rot(int i)
{
access(i);splay(i);
while(1)
{
pushdown(i);
if(!T[i].hz[0])break;
i=T[i].hz[0];
}
splay(i);
return pushup(i);
}
void link(int x,int y)
{
make_rot(x);
if(find_rot(y)!=x)T[x].fa=y;
}
void cut(int x,int y)
{
make_rot(x);
if(find_rot(y)==x&&T[y].fa==x&&!T[y].hz[0])
T[x].hz[1]=T[y].fa=0,pushup(x);
}
void split(int x,int y)
{
make_rot(x);
access(y);splay(y);
}
int ask(int x,int y)
{
split(x,y);
return T[y].sum;
}
void change(int i,int k)
{
splay(i);
T[i].val=k;
pushup(i);
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
T[i].val=re();
for(int i=1;i<=m;i++)
{
int op=re(),x=re(),y=re();
if(op==0)wr(ask(x,y),'\n');
if(op==1)link(x,y);
if(op==2)cut(x,y);
if(op==3)change(x,y);
}
return 0;
}
事实上,LCT 是一种十分灵活的数据结构,题目的形式更是千奇百怪。
看这篇博客,好像 LCT 维护只能维护点权和链上信息。
但听说,好像也能维护边权和子树信息。
我太蒻了。
自己弄了个题单,还没有做完。
做完之后可能会出一些例题讲解吧。如果有时间的话。