Link-cut tree 略解

一些前言

每次做树剖时打开题解...

使用 LCT 简单维护即可。

内心:??? code 好√8短啊 又很奇怪 有种不知道却又高端大气的感觉
这次来说清楚 LCT 到底是个什么东东

问题引入

例题传送门

有一棵树,需要支持操作:

  • 修改节点 uv 路径值
  • 查询节点 uv 路径值

很明显,这是一道易研定真的树剖模板。

但是我们加入如下操作:

  • 断开边 u,v
  • 链接边 u,v

这下 我们要使用 Link-Cut tree (LCT) 来维护这个问题。

前置知识

  • splay 树 (必备)
  • 重链剖分 (最好知道 不是都看这个了怎么能没学过树剖呢)

一些宏定义

ls(x) x 的左儿子
rs(x) x 的右儿子
fa(x) x 的父亲

一些概念

就像树剖一样,我们用一棵大线段树来维护一棵树,每条轻重链对应着一棵独立的线段树,只有在询问子树时才会打破这些线段树直接的壁垒 我们称该线段树为原树的辅助树

LCT , 又叫实链剖分,在算法中辅助树是由一些 Splay 构成的
一些性质:

  • 1. 在 LCT 中,有若干棵辅助树,每棵辅助树对应着原森林的一棵完整的树
  • 2. 辅助树中的每个节点与原树的节点一一对应
  • 3. 每棵 Splay 连接着原树的一些路径,其中辅助树的中序遍历一定是原树从上到下连成的一条完整的链
  • 4. 辅助树中若干棵 Splay 不是独立的 按理来说,每棵 Splay 的根节点的父亲应为空 但在 LCT 中,每棵 Splay 根节点的父亲对应着原树中对应节点的父节点
  • 5. 因此,所有的操作都会在辅助树中进行,辅助树是一棵与原树对应且完整的树,并可以很方便的维护

等等!性质 4 是什么鬼?Splay 不是只有两个儿子节点吗?
这是一种十分特别的链接方式,我们称这种链接为虚边 这种虚边特别之处在于:儿子认父亲,父亲不认儿子
相对的,对于父亲儿子两两相认的边,我们称作实边

这有些抽象,可以借助图片理解
芝士原树:

辅助树可能是这个样子的:

这些实边都是用一棵 Splay 来维护着的。

原树和辅助树的关系

  • 1. 原树中的节点都只会在一个 Splay 中 也就是说,没有一个节点会在两个 Splay 里 ,每个 Splay 存储的节点是不同的
  • 2. 原树和辅助树的节点父亲没有任何关系
  • 3. 辅助树可以在 Splay 的帮助下轻松做到换根操作,这就是 Splay 的重要性质之一:在 zig/zag 旋转中,树的中序遍历不会发生改变
  • 4. 在辅助树上,可以轻松完成轻实链的变换,这样就可以做到动态完成树链剖分

函数讲解

一些定义:

  • chx,0/1: 节点 x 的左/右儿子
  • tr[x].v 该节点的权值
  • tr[x].sum 路径的权值
  • tr[x].lz 翻转懒标记 与文艺平衡树差不多

先是一些与 Splay 没有区别的函数:

chk(x)

bool chk(int x){return rs(fa(x))==x;}

Pushup(x)

void Pushup(int x){tr[x].sum=tr[ls(x)].sum^tr[rs(x)].sum^tr[x].v;}

Pushdown(x)

void Pushdown(int x)
{
	if(!tr[x].lz) return;
	swap(ls(x),rs(x)),tr[ls(x)].lz^=1,tr[rs(x)].lz^=1;
	tr[x].lz=0;
}

一些有修改的函数和新的函数:

isroot(x)

isroot(x)可以很轻松的判断当前点是否为当前 Splay 的根

根的定义就是没有父节点
那么如果当前节点的父节点不认当前节点,即父亲的左右儿子都不是当前节点,那么当前节点为此 Splay 树的根

bool isroot(int x){return ls(fa(x))^x&&rs(fa(x))^x;}

updata(x)

一个下放标记的函数 目的只有下放懒标记 至于用处后面会提到

void updata(int x)
{
	if(!isroot(x)) updata(fa(x));
	Pushdown(x);
}

很多题解中,在这个过程是开一个栈来完成的 当然用栈理论上更快一些 实测也就快一点点...
你可以这样写:

int stk[N],top;
void updata(int x)
{
	while(!isroot(x)) stk[++top]=x,x=fa(x);
	stk[++top]=x;
	while(top) Pushdown(stk[top--]);
}

rotate(x)

void rotate(int x)
{
	int y=fa(x),z=fa(y),k=chk(x),w=ch[x][k^1];
	if(!isroot(y)) ch[z][chk(y)]=x;
	ch[y][k]=w,fa(w)=y;
	ch[x][k^1]=y,fa(y)=x;fa(x)=z;
	Pushup(y);
	Pushup(x);
}

与 Splay 的变化不大
唯一的变化在于爷爷节点的链接一定要放在第一位
因为此时要判断父亲节点是不是根节点,是根节点爷爷节点是不认父亲这个儿子的,这条边不需要链接
而只要父亲节点的转动,就无法判断是不是根节点
所以 在普通 Splay 中的顺序可以是 yzxzyx
但是在 LCT 中只能写后者

记得一定要 Pushup !

Splay(x)

与 Splay 的变化不大
在 LCT 中,我们不关心转到哪个节点的儿子,只关心转到该 Splay 中的根节点 因此不在是靠爷爷节点判断了

注意到 Splay 中的 updata 函数 这个后面会单独提出来讲

void splay(int x)
{
	updata(x);
	while(!isroot(x))
	{
		int y=fa(x);
		if(!isroot(y))
			if(chk(x)^chk(y)) rotate(x);
			else rotate(y);
		rotate(x);
	}
}

接下来是非常重要的函数

access(x)

函数的作用是打通根节点 (这里的根节点是辅助树的根节点和辅助树的节点) 到节点 x 的链,并且规定这条链所有链是实链,即在同一 Splay 中

我们来举个栗子

芝士原树

可能辅助树是这样的

现在捏,我们要打通节点 AN (芝士原树)

算法实现步骤是从下往上链接
首先第一步把节点 N Splay 到当前节点的根
然后我们只关心路径 AN
因此 N 是中序遍历最后一个点
所以 N 是不能有右儿子的 所以直接让 N 的右儿子滚蛋 单方面设置 rs(N)=0 即可
于是就变成了这样:

下一步,我们发现 N 的父亲是 I
同理的,为了好操作,直接把 I Splay 到根


这个时候其实 IK是有一条边的 而且还是右子树
根据中序遍历,遍历了左子树和当前的点 右子树必须指向的是 N 继续遍历剩下的
所以直接让子节点 K 滚蛋 换成 N 这样的目的就是为了满足中序遍历


重复操作,Hroot , r(H)=I

最后一次操作,整棵树变成这样:

因此,回到本质,操作步骤是:

  • 1. 把当前节点转到根
  • 2. 将当前节点的右儿子设为之前的节点
  • 3. 更新节点信息
  • 4. 往父亲节点更新

普通code

void access(int x)
{
	int p=0;
	while(x)
	{
		splay(x);
		rs(x)=p;
		Pushup(x);
		p=x;
		x=fa(x);
	}
}

压行:

void access(int x)
{
	for(int p=0;x;p=x,x=fa(x))
		splay(x),rs(x)=p,Pushup(x);
}

注意到 循环截止条件是当前遍历完辅助树的根 即 x=0

makeroot(x)

void makeroot(int x)
{
	access(x);
	splay(x);
	tr[x].lz^=1;
}

核心操作 也是最难操作之一
操作目的是把 x 设为整棵原树的根
为什么要这样干捏?
我们在处理路径时,对于路径 uv(depudepv) 除非 uv 祖先,否则这两个点不可能在一棵 Splay 树上 因为 Splay 树维护的是一条由浅到深的路径

那么我们把 u 变成根不就好了!

第一步:打通根节点到节点 x 的路径
第二步:将 x 转到根节点

那么怎么将 x 设为根捏?

我们知道, Splay 中存的是中序遍历
我们知道 x 是由 rootx 这条边打通得到的
所以 x 一定是中序遍历最后一个点 即不存在右子树
我们可以这样理解,中序遍历保证的深度从小到大的一条链
如果 x 变为根,只有这条链发生变化,深度为从大到小
那么怎么遍历变为从大到小呢?
和文艺平衡树思想类似 我们不是把遍历顺序从 左根右 变为 右根左
只需要翻转这个区间即可(就是交换左右子树)
仔细推一下,发现除了这条链,其它地方不做任何操作也没有任何关系

updata(x)

上面提过这个函数
现在就来说明它的作用了,它是为了在 Splay 时从上而下传递懒标记
为什么在文艺平衡树中不用呢?
因为文艺平衡树在查找前驱后继时把这件事干完了
这里没有查找前后继的操作 所以要保证函数更新

find(x)

int find(int x)
{
	access(x),splay(x);
	while(ls(x)) Pushdown(x),x=ls(x);
	splay(x);
	return x;
}

目的是找到 x 所在 原树 的根节点
首先打通根节点到 x 的路径
根据中序遍历,遍历到的第一个节点没有左儿子的点就是根节点 就是维护该实链的顶端节点

link(x,y)

void link(int x,int y)
{
	makeroot(x);
	if(find(y)^x) fa(x)=y;
}

链接一条 xy 的边
x 作为根节点,判断 y 的根节点是否为 x ,保证它们在不同子树
然后链接一条虚边即可

split(x,y)

void split(int x,int y)
{
	makeroot(x);
	access(y);
	splay(y);
}

xy 打通
先把 x 作为根,打通 y ,把 y 转到根,查询根节点的 sum 即为路径的权值

cut(x,y)

void cut(int x,int y)
{
	makeroot(x);
	if(find(y)==x&&fa(y)==x&&!ls(y)) fa(y)=rs(x)=0;
}

断开边 xy
先把 x 作为根
我们考虑如何保证边 xy 存在

  • 1. xy 联通
  • 2. xy 间没有任何节点

对于 1 ,直接判断 y 在原树的根是不是 x 即可
对于 2 呢?根据中序遍历,x 是原树根节点 同时也旋转到了它所在 Splay 的根节点
因此 辅助树中 x 不存在左子树
那么往右遍历必须保证右儿子就是 y 的同时且 不能拥有左儿子 因为有左儿子说明还有别的点
如果都成立,那么直接双向砍边,都设为 0 即可

如果保证合法就很简单,split 联通一下然后处理 y 右儿子即可

注意这里的顺序一定不能写错啊 find 函数很重要的作用就是打通了 xy 的路径!

查询路径操作

很简单,对于 xy ,先 split(x,y) 联通 y 是根查询 ysum 即可
这是因为已经存储好了这一条路径的权值

单点修改权值操作

考虑到只有根节点修改权值 任何子节点都无需更新
修改 x 的话直接把 x 作为根 再转到根节点 修改权值即可

makeroot(x),splay(x),tr[x].v=y;

Updata: 关于懒标记

上面介绍的懒标记实际上是不太严谨的 应该在访问到当前点时当前点信息已经更新好了
然后处理儿子和更新儿子懒标记
这样子是为了保证儿子的顺序正确访问
举个例子,比如最大字段和

应用

维护树链信息

树链加减连接后懒标记维护即可
这时请务必使用第二种懒标记 按照线段树的思想写即可

维护联通性质

find 函数可以很轻易维护

维护边权

LCT 不像树剖,有固定儿子,可以直接把边权映射到点权上
因此只能拆边,把边拆成一个点向连接两边连一下 照样处理即可
比如一条边 uv 那么建立点 w 代表边,并连接 uw ,wv (当然这里都是双向的)

维护子树信息

不会 建议直接去 oi-wiki 吧(((

code

#include<bits/stdc++.h>
#define ll long long
#define N 100005
#define ls(x) ch[x][0]
#define rs(x) ch[x][1]
#define fa(x) fa[x]
using namespace std;
int n,m;
struct point{
	int sum,v,lz;
}tr[N];
int ch[N][2],fa[N];
bool chk(int x){return rs(fa(x))==x;}
bool isroot(int x){return ls(fa(x))^x&&rs(fa(x))^x;}
void Pushup(int x){tr[x].sum=tr[ls(x)].sum^tr[rs(x)].sum^tr[x].v;}
void Pushdown(int x)
{
	if(!tr[x].lz) return;
	swap(ls(x),rs(x)),tr[ls(x)].lz^=1,tr[rs(x)].lz^=1;
	tr[x].lz=0;
}
int stk[N],top;
void updata(int x)
{
	while(!isroot(x)) stk[++top]=x,x=fa(x);
	stk[++top]=x;
	while(top) Pushdown(stk[top--]);
}
void rotate(int x)
{
	int y=fa(x),z=fa(y),k=chk(x),w=ch[x][k^1];
	if(!isroot(y)) ch[z][chk(y)]=x;fa(x)=z;
	ch[y][k]=w,fa(w)=y;
	ch[x][k^1]=y,fa(y)=x;
	Pushup(y);
	Pushup(x);
}
void splay(int x)
{
	updata(x);
	while(!isroot(x))
	{
		int y=fa(x);
		if(!isroot(y))
			if(chk(x)^chk(y)) rotate(x);
			else rotate(y);
		rotate(x);
	}
}
void access(int x)
{
	for(int p=0;x;p=x,x=fa(x))
		splay(x),rs(x)=p,Pushup(x);
}
void makeroot(int x)
{
	access(x);
	splay(x);
	tr[x].lz^=1;
}
int find(int x)
{
	access(x),splay(x);
	while(ls(x)) Pushdown(x),x=ls(x);
	splay(x);
	return x;
}
void link(int x,int y)
{
	makeroot(x);
	if(find(y)^x) fa(x)=y;
}
void split(int x,int y)
{
	makeroot(x);
	access(y);
	splay(y);
}
void cut(int x,int y)
{
	makeroot(x);
	if(find(y)==x&&fa(y)==x&&!ls(y)) fa(y)=rs(x)=0;
}
int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++) scanf("%d",&tr[i].v);
	while(m--)
	{
		int opr,x,y;
		scanf("%d%d%d",&opr,&x,&y);
		if(opr==0) split(x,y),printf("%d\n",tr[y].sum);
		if(opr==1) link(x,y);
		if(opr==2) cut(x,y);
		if(opr==3) makeroot(x),splay(x),tr[x].v=y;
	}
	return 0;
}

几个注意的点

  • findroot 函数中一定要 Pushdown
  • Splay 前要 updata
  • link 里的函数是 makeroot 不是 splay
posted @   g1ove  阅读(17)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示