实链剖分之 Link-Cut Tree

重链剖分

Link-Cut Tree

回顾重链剖分,可以发现,它维护的是一棵静态的树。

当题目是森林时,尤其是有连边、断边等操作时,重链剖分不好维护。

所以,需要一种更为灵活的算法,也就是 LCT。

重链剖分是用的线段树维护原树。之前提到过,区间树除了用线段树可以实现,Splay 也是可以的。所以 LCT 一般用 Splay 维护树上信息。

而且,LCT 所使用的 Splay 和维护序列的 Splay 还有一些差别,属于 Splay 的扩展。

如果你不会 Splay。

模板

口胡

这一段好像没那么必要。

Splay 有一个特点:维护父指针。这是其他平衡树都没有的操作。

正是这个父指针的存在,使得 LCT 能够较好的维护树上信息。

说了这么多,到底怎么用 Splay 实现 LCT 来维护一片森林的呢?

首先,大多数博客里都会出现这样两个名词:原树、辅助树。

原树就是指的我们需要进行实链剖分的森林,那么辅助树就是维护实链的一林子 Splay 树。

一般来说,LCT 只需要维护辅助树就可以知道原树的一些性质。

既然是实链剖分,那么可以借鉴重链剖分那里的一些名字,比如:

  • 实儿子、虚儿子
  • 实边、虚边
  • 实链

剖分完之后,我们就可以使用 Splay 维护一条实链,就像线段树维护一条条重链一样,保证 Splay 的节点中序遍历之后在实链中深度单调递增。

和重链剖分一样,每个节点最多有一个实儿子。不过,和重儿子不同的是,实儿子可以改变。

由于 Splay 是一棵 BST,为了区分虚儿子和实儿子,一个比较方便的方法就是通过单双向边。

显然,Splay 上每个节点都应该有一个父节点(根的父节点为 0 号节点)。

既然每个节点都认父亲,那么可以让父亲节点只认实儿子。

也就是说,每个节点都有向父亲节点连的单向边,但父亲节点只向实儿子连边,形成双向边。

这样的话,不仅能将原树上所有的点联系起来,而且对于辅助树上的操作(区间翻转)只会影响当前实链。

辅助函数

  1. Splay 相关

还是熟悉的几个函数:pd_sonpushuppushdown

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;
}
  1. 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);
}

由于需要断边操作,所以连边必须两个点直接连起来,而不能只在两棵树的根节点连。

显然,一个点不能有两个父节点。

又显然,原树的根节点的父节点为 \(0\),更容易操作。

所以,我们先将其中一个点弄成根节点,然后再将与另一个点连虚边。

题目中不保证要连接的两点之间没有边,所以需要判断两点之间是否存在边,不存在才可以继续连。

void link(int x,int y)
{
	make_rot(x);
	if(find_rot(y)!=x)T[x].fa=y;
}

断边-cut

断边思路和连边类似,都是先将其中一个节点弄成根。

然后断实边。

同样,断边之前需要判断是否连边。

显然,将一个节点弄成根之后,与其直接相连的点必然是其后继。

那么需要两个判断:

  1. 两节点是否在同一棵树上。

  2. 另一个节点是否为当前点的后继。

第一条可以直接找根判断。

而第二条,由于找根的时候的一些操作,只需要判断另一个节点的父节点是不是当前点、另一个节点是否存在左子节点就好了。

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 维护只能维护点权和链上信息。

但听说,好像也能维护边权和子树信息。

我太蒻了。

自己弄了个题单,还没有做完。

做完之后可能会出一些例题讲解吧。如果有时间的话。

posted @ 2022-10-25 11:21  Zvelig1205  阅读(103)  评论(0编辑  收藏  举报