Link-cut tree 略解

一些前言

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

使用 LCT 简单维护即可。

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

问题引入

例题传送门

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

  • 修改节点 \(u\to v\) 路径值
  • 查询节点 \(u\to v\) 路径值

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

但是我们加入如下操作:

  • 断开边 \(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 的重要性质之一:在 \(\text{zig/zag}\) 旋转中,树的中序遍历不会发生改变
  • \(4.\) 在辅助树上,可以轻松完成轻实链的变换,这样就可以做到动态完成树链剖分

函数讲解

一些定义:

  • \(ch_{x,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 中的顺序可以是 \(y\to z\to x\)\(z\to y\to x\)
但是在 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 中

我们来举个栗子

芝士原树

可能辅助树是这样的

现在捏,我们要打通节点 \(A\to N\) (芝士原树)

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

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


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


重复操作,\(H\to root\) , \(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\) 设为整棵原树的根
为什么要这样干捏?
我们在处理路径时,对于路径 \(u\to v(dep_u\le dep_v)\) 除非 \(u\)\(v\) 祖先,否则这两个点不可能在一棵 Splay 树上 因为 Splay 树维护的是一条由浅到深的路径

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

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

那么怎么将 \(x\) 设为根捏?

我们知道, Splay 中存的是中序遍历
我们知道 \(x\) 是由 \(root\to x\) 这条边打通得到的
所以 \(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;
}

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

split(x,y)

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

\(x\to y\) 打通
先把 \(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;
}

断开边 \(x\to y\)
先把 \(x\) 作为根
我们考虑如何保证边 \(x\to y\) 存在

  • \(1.\) \(x\to y\) 联通
  • \(2.\) \(x\to y\) 间没有任何节点

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

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

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

查询路径操作

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

单点修改权值操作

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

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

Updata: 关于懒标记

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

应用

维护树链信息

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

维护联通性质

find 函数可以很轻易维护

维护边权

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

维护子树信息

不会 建议直接去 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 @ 2024-02-27 19:32  g1ove  阅读(9)  评论(0编辑  收藏  举报