Link-Cut-Tree

何为 LCT

支持动态加边删边的数据结构,是动态树的一种。

LCT 的主题思想

LCT 整体上来说是虚实链剖分,每个点选择一条与儿子相连的边作为实边,其他边为虚边,连在一起的一段实边我们称其为一条实链,由于实边可以所以定义,所以实链是不唯一的。

因为 LCT 中的任意两条实链之间都是由一条虚边相连的,所以我们可以将每一条实链(或者单个没有与任何实边相连的点)都用一颗 Splay 来维护。

Splay主要维护的信息就是当前节点是它父亲的左儿子还是右儿子以及区间翻转,这一点我们后面会提到。

那么如果使用 Splay 按深度来维护实链,那么最重要的就是如何维护众多的 Splay 在原树的关系,毕竟 Splay 的根会一直变,所以维护父子关系就是一个很大的问题,所以我们借助以下这个例子来解释:

上面这颗树已经定好了当前的实边,所以 Splay 分别维护了 \(\{1,4,7\},\{2,6,10\},\{3\},\{5\},\{8\},\{9\}\) 这些节点,而对于一个 Splay,存在多种形态,如维护 \(\{2,6,10\}\) 的 Splay 就具有以下形态:

那么接下来我们来思考如何维护原树上的关系,很明显,因为 Splay 的形态一直在改变,所以无论是维护原树上的父子关系还是 Splay 上的父子关系都不太可行。

所以我们引入辅助树,所谓辅助树就是将不是在同一个 Splay 且在原本树上右边的节点进行认父不认子操作,也就是原树的父节点没有这个儿子节点,而这个节点的父亲确是这个父节点,如下图就是一种可能的辅助树。

有了辅助树我们就可以思考,如何维护操作的呢?

从最简单的修改操作说起,把要修改的节点放到当前 Splay 根的位置,这样就可以直接修改值,而不会影响 Splay 的内部,然后向上维护就可以了。

然后来说断边与连边,如果我们想断开一条边,那我们首先需要判断两点直接是否有一条直连边,那就需要将他们之间的路径变成一条实链;如果想要连边我们只需要让其中一个点变为辅助树的根节点,在把另一个点到根的路径变为实链就可以直接判断两点是否已经相连。

然后说路径上的操作,我们思考一个问题,如果想要快速的直接进行操作,那显然如果查询的路径刚好是一条实链最好,因为只要刚好,就可以直接进行操作。

根据以上问题的解决方案,我们发现,我们需要完成的操作有剥离路径,打通路径,更改原树根节点,查找当前节点根节点。

更改原树的根节点,实际上只需要打通 \(x\) 至根节点的路径,这个时候,\(x\) 一定在 Splay 的顶端,但此时 \(x\) 的深度仍然是最小的,所以需要翻转 Splay,这时需要用文艺平衡树的方式打懒标记实现翻转。

查找当前节点的根,只需要打通当前节点到根的路径,将当前节点伸展至当前 Splay 的根节点,此时由于根的深度最小,所以此时根一定在 Splay 最底部,所以递归一直往下找,最后记得把根转回去。

剥离路径,这个问题就较为简单了,把 \(x\) 更改为根节点后打通 \(y\) 与其的路径就可以得到 \(x\)\(y\) 的路径了。

因此上述三种操作都只需要完成打通路径一操作就可以了,所以下面我们来重点讲解打通路径的实现方式。

由于虚实链的任意性,我们可以随意的设定,甚至于抛弃实链,由于打通的路径不能有任何一个多余的节点,所以考虑使用递归处理,对于当前节点 \(x\) 一定不会向下连实边,而对于其他节点,则需要将原本选的实边变成虚边,把连向上次处理的节点的边变为实边,由于越往上递归深度越小,所以在 Splay 上,上次处理的节点一定在当前处理的节点的右侧(在把当前处理节点变成其所在 Splay 的根之后),而原本连着实边的儿子同样也满足这一性质,所以将当前处理的节点的有右儿子变为上次处理的点可以直接实现换实边一操作。

至此,LCT 所需要维护的东西便告一段落,接下来思考 Splay 的实现。

实际上,Splay 没有太大的变化,唯一的变化是由于辅助树认父不认子导致的,我们需要一个判断此节点是否为当前 Splay 的根的函数,由于辅助树认父不认子,所以如果说当前节点既不是其父节点的左儿子,也不是其父节点的右二子,那么它就是这颗 Splay 的根。在 Splay 操作之前,由于你入手的节点可能在它的祖先中还存在没有下传懒标记的,所以就需要先递归到全树的根(如果是森林也不会有影响),再递归下传懒标记。

代码讲解

定义

我们接下来的代码进行一下约定:

  1. \(key\),值;
  2. \(fa\),父节点;
  3. \(son_{0/1}\),左右儿子,0为左儿子,1为右儿子;
  4. \(sum\),当前节点的子树和;
  5. \(tag\),翻转懒标记。
struct node
{
	int key;//值
	int fa;//父节点
	int son[2];//左右儿子,0为左儿子,1为右儿子
	int sum;//当前节点的子树异或和
	int tag;//翻转懒标记
}tr[N];

push_up

向上传递信息,既然是异或和那就是左右儿子的子树的异或和的异或和异或上当前节点的值。

void push_up(int x){tr[x].sum=tr[tr[x].son[0]].sum^tr[x].key^tr[tr[x].son[1]].sum;}//向上传递 

push_down

下传翻转懒标记。

和文艺平衡树一模一样,如果说该节点要操作偶数次,则不会有改变,反之,则需要翻转一次。利用异或的性质即可。

void push_down(int x)//下传翻转懒标记 
{
	if(tr[x].tag)//是否要翻转 
	{
		int ls=tr[x].son[0];
		swap(tr[ls].son[0],tr[ls].son[1]);//直接交换 
		tr[ls].tag^=1;//下传 
		int rs=tr[x].son[1];
		swap(tr[rs].son[0],tr[rs].son[1]);//直接交换 
		tr[rs].tag^=1;//下传 
		tr[x].tag=0;//清空 
	}
	return;
}

check_root

判断此节点是否为其所在的 Splay 的根。

思路里有提到过,直接判断它的父亲有没有这个儿子。

bool check_root(int x)//判断x是否为其所在Splay的根 
{
	if(x!=tr[tr[x].fa].son[0]&&x!=tr[tr[x].fa].son[1]) return 1;//如果它的父亲没有这个儿子,则它就是此Splay的根节点 
	return 0;
}

update

将当前 Splay 上的懒标记,从根的位置递归到当前点。

只要没走到当前 Splay 的根就一直往上走,等走到顶了,再递归回来下传懒标记。

void update(int x)
{
	if(!check_root(x)) update(tr[x].fa);//递归至根节点 
	push_down(x);//下传懒标记 
	return;
}

get_son

判断该节点是其父亲的左儿子还是右儿子。

直接判断即可。

int get_son(int x)//判断该节点是其父亲的左儿子还是右儿子 
{
	if(x==tr[tr[x].fa].son[1]) return 1;
	return 0;
}

rotate

Splay 的旋转操作。

和模板没什么太大变动,直接调用 get_son 找儿子。

void rotate(int x)//旋转  
{
	int y=tr[x].fa;
	int z=tr[y].fa;
	int k=get_son(x);
	if(!check_root(y)) tr[z].son[get_son(y)]=x;
	tr[x].fa=z;
	tr[y].son[k]=tr[x].son[k^1];
	tr[tr[x].son[k^1]].fa=y;
	tr[x].son[k^1]=y;
	tr[y].fa=x;
	push_up(y);
}

splay

伸展操作。

和模板也没什么他大区别,就是在伸展前记得调用 \(update\)

void splay(int x)//伸展 
{
	update(x);//记得处理完没处理的懒标记 
	while(!check_root(x))
	{
		if(!check_root(tr[x].fa))
		{
			if(get_son(x)^get_son(tr[x].fa)) rotate(x);
			else rotate(tr[x].fa);
		}
		rotate(x);
	} 
	push_up(x);
	return;
}

access

打通路径。

最核心的操作,思路中已经明确讲述了实现,代码没有什么难点,就是每次改的时候记得 splay 一下,用一个 \(p\) 记录上次处理的节点,\(p\) 的初始值为 \(0\)

void access(int x)//打通x至根节点的路径 
{
	int p=0;//初始值赋为0 
	while(x)//只要没到辅助树上的根就继续操作 
	{
		splay(x);//将x转到其所在Splay的根节点 
		tr[x].son[1]=p;//替换掉原来的实边 
		push_up(x);//向上维护 
		p=x;//更新 
		x=tr[x].fa;//继续操作 
	} 
	return;
}

change_root

换一个根。

打通过后的翻转。

void change_root(int x)//改根 
{
    access(x);//打通x至根的路径 
	splay(x);//伸展x 
	//翻转,让x的深度从最小变为最大 
	swap(tr[x].son[0],tr[x].son[1]);
	tr[x].tag^=1;
	return;
}

find_root

找根。

打通过后一路向下。

int find_root(int x)
{
    access(x);//打通x至根的路径 
	splay(x);//伸展x 
	push_down(x);//传递信息 
	while(tr[x].son[0])//一直向下找,因为根节点深度最小 
	{
		x=tr[x].son[0];//递归下去 
		push_down(x);//递归下传懒标记 
	}
	splay(x);//伸展回去,此时x为根。 
	return x;		 
}

split

剥离路径。

让一个点变为根,打通另一个点至根节点的路径。这个函数需要返回值,因为输出 \(x\)\(y\) 路径上的异或和时会用到其 Splay 根节点的 \(sum\) 值。

int split(int x,int y)//剥离路径 
{
	change_root(x);//将x变为辅助树的根 
	access(y);//打通y与x的路径 
	splay(y);//伸展上去 
	return y;//此时的y为这颗Splay的根
}

连边。

通过把 \(x\) 变为根判断是否要连接。

void link(int x,int y)//连边 
{
	change_root(x);//将x变为辅助树的根 
	if(find_root(y)!=x) tr[x].fa=y;//是否在一起 
	return;
}

cut

断边。
通过 split 判断是否能断开。

void cut(int x,int y)//断边 
{
	split(x,y);//剥离出来x至y的路径 
	if(tr[y].son[0]==x&&tr[x].son[1]==0)//如果说y的左儿子是x,没有右儿子说明它们直接相连,因为split会将y放在根节点的位置,所以x的深度会低一点 
	{
		tr[y].son[0]=tr[x].fa=0;//断开 
		push_up(y);//向上维护 
	}
	return;
}

fix

修改。

将修改节点变为其所在 Splay 的根,再修改。

void fix(int x,int key)//修改 
{
	splay(x);//转到当前节点所在Splay的根节点,这样不会对当前Splay的值有影响 
	tr[x].key=key;//更改 
	push_up(x);//向上维护 
	return;
}

main

主函数。

输入的时候直接把权值读入到LCT上。

signed main()
{
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	n=read();
	m=read();
	rep1(i,1,n) lct.tr[i].key=read();//直接读入到lct上 
	while(m--)
	{
		int opt=read();
		int x=read();
		int y=read();
		if(opt==0) cout<<lct.tr[lct.split(x,y)].sum<<endl;//路径上的异或和 
		if(opt==1) lct.link(x,y);//连边 
		if(opt==2) lct.cut(x,y);//断边 
		if(opt==3) lct.fix(x,y);//修改值 
	}
	return 0;
} 

完整代码

AC Code of Luogu P3690 【模板】动态树(LCT)

/*
	Luogu name: Symbolize
	Luogu uid: 672793
*/
#include<bits/stdc++.h>
#define int long long
#define pii pair<int,int>
#define x first
#define y second
#define rep1(i,l,r) for(register int i=l;i<=r;++i)
#define rep2(i,l,r) for(register int i=l;i>=r;--i)
#define rep3(i,x,y,z) for(register int i=x[y];~i;i=z[i])
#define rep4(i,x) for(auto i:x)
#define debug() puts("----------")
const int N=1e5+10;
const int inf=0x3f3f3f3f3f3f3f3f;
using namespace std;
int n,m;
struct Link_Cut_Tree
{
	struct node
	{
		int key;//值
		int fa;//父节点
		int son[2];//左右儿子,0为左儿子,1为右儿子
		int sum;//当前节点的子树和
		int tag;//翻转懒标记
	}tr[N];
	void push_up(int x){tr[x].sum=tr[tr[x].son[0]].sum^tr[x].key^tr[tr[x].son[1]].sum;}//向上传递 
	void push_down(int x)//下传翻转懒标记 
	{
		if(tr[x].tag)//是否要翻转 
		{
			int ls=tr[x].son[0];
			swap(tr[ls].son[0],tr[ls].son[1]);//直接交换 
			tr[ls].tag^=1;//下传 
			int rs=tr[x].son[1];
			swap(tr[rs].son[0],tr[rs].son[1]);//直接交换 
			tr[rs].tag^=1;//下传 
			tr[x].tag=0;//清空 
		}
		return;
	}
	int get_son(int x)//判断该节点是其父亲的左儿子还是右儿子 
	{
		if(x==tr[tr[x].fa].son[1]) return 1;
		return 0;
	}
	bool check_root(int x)//判断x是否为其所在Splay的根 
	{
		if(x!=tr[tr[x].fa].son[0]&&x!=tr[tr[x].fa].son[1]) return 1;//如果它的 父亲没有这个儿子,则它就是此Splay的根节点 
		return 0;
	}
	void change_root(int x)//改根 
	{
	    access(x);//打通x至根的路径 
		splay(x);//伸展x 
		//翻转,让x的深度从最小变为最大 
		swap(tr[x].son[0],tr[x].son[1]);
		tr[x].tag^=1;
		return;
	}
	int find_root(int x)
	{
	    access(x);//打通x至根的路径 
		splay(x);//伸展x 
		push_down(x);//传递信息 
		while(tr[x].son[0])//一直向下找,因为根节点深度最小 
		{
			x=tr[x].son[0];//递归下去 
			push_down(x);//递归下传懒标记 
		}
		splay(x);//伸展回去,此时x为根。 
		return x;		 
	}
	void update(int x)
	{
		if(!check_root(x)) update(tr[x].fa);//递归至根节点 
		push_down(x);//下传懒标记 
		return;
	}
	void rotate(int x)//旋转 
	{
		int y=tr[x].fa;
		int z=tr[y].fa;
		int k=get_son(x);
		if(!check_root(y)) tr[z].son[get_son(y)]=x;
		tr[x].fa=z;
		tr[y].son[k]=tr[x].son[k^1];
		tr[tr[x].son[k^1]].fa=y;
		tr[x].son[k^1]=y;
		tr[y].fa=x;
		push_up(y);
	}
	void splay(int x)//伸展 
	{
		update(x);//记得处理完没处理的懒标记 
		while(!check_root(x))
		{
			if(!check_root(tr[x].fa))
			{
				if(get_son(x)^get_son(tr[x].fa)) rotate(x);
				else rotate(tr[x].fa);
			}
			rotate(x);
		} 
		push_up(x);
		return;
	}
	void access(int x)//打通x至根节点的路径 
	{
		int p=0;//初始值赋为0 
		while(x)//只要没到辅助树上的根就继续操作 
		{
			splay(x);//将x转到其所在Splay的根节点 
			tr[x].son[1]=p;//替换掉原来的实边 
			push_up(x);//向上维护 
			p=x;//更新 
			x=tr[x].fa;//继续操作 
		} 
		return;
	}
	int split(int x,int y)//剥离路径 
	{
		change_root(x);//将x变为辅助树的根 
		access(y);//打通y与x的路径 
		splay(y);//伸展上去 
		return y;
	}
	void link(int x,int y)//连边 
	{
		change_root(x);//将x变为辅助树的根 
		if(find_root(y)!=x) tr[x].fa=y;//是否在一起 
		return;
	}
	void cut(int x,int y)//断边 
	{
		split(x,y);//剥离出来x至y的路径 
		if(tr[y].son[0]==x&&tr[x].son[1]==0)//如果说y的左儿子是x,没有右儿子说明它们直接相连,因为split会将y放在根节点的位置,所以x的深度会低一点 
		{
			tr[y].son[0]=tr[x].fa=0;//断开 
			push_up(y);//向上维护 
		}
		return;
	}
	void fix(int x,int key)//修改 
	{
		splay(x);//转到当前节点所在Splay的根节点,这样不会对当前Splay的值有影响 
		tr[x].key=key;//更改 
		push_up(x);//向上维护 
		return;
	}
}lct;
int read()
{
	int x=0,f=1;
	char ch=getchar();
	while(ch<'0'||ch>'9')
	{
		if(ch=='-') f=-1;
		ch=getchar();
	}
	while(ch>='0'&&ch<='9')
	{
		x=(x<<1)+(x<<3)+(ch^48);
		ch=getchar();
	}
	return f*x;
}
signed main()
{
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	n=read();
	m=read();
	rep1(i,1,n) lct.tr[i].key=read();//直接读入到lct上 
	while(m--)
	{
		int opt=read();
		int x=read();
		int y=read();
		if(opt==0) cout<<lct.tr[lct.split(x,y)].sum<<endl;//路径上的异或和 
		if(opt==1) lct.link(x,y);//连边 
		if(opt==2) lct.cut(x,y);//断边 
		if(opt==3) lct.fix(x,y);//修改值 
	}
	return 0;
} 
posted @ 2023-11-24 17:00  Symbolize  阅读(34)  评论(0)    收藏  举报