LCT(link cut tree)入门

简述

我们有这样一个问题:修改点权,询问链上的点权和。这明显是个树链剖分模版。
但如果还有这些操作呢:断开一条边,连上一条边,保证一直是森林。这就是动态树的一种问题。
而 LCT 就是解决这些问题的优秀数据结构。

前言

建议是会 Splay,虽然 FHQ-Treap 也能写,但是多一个 log
Splay 只要会一些简单的序列操作和打懒标记就好了。
点进来的都会树剖会吧,不会的话也行。
Splay 表示树,splay 表示伸展操作。

动态树不是 LCT,LCT 是解决动态树问题的数据结构。

实现

实链剖分

在树剖中,最常用的是重链剖分。
而在动态树中,问题的瓶颈变成了怎么让树链的划分状况随树的形态快速修改。
但是,size 显然是难以维护的。
那么只要没有规则,不就可以快速修改了吗?
我们让每个节点的重儿子自己选择,这样在改变树形态的时候可以更自由地维护树链的变化。

这就是实链剖分。
我们选择在一条链上的边叫实边,链接不同链的边叫虚边。
节点用实边链接的是实儿子,其余是虚儿子。

每个节点至多有一个实儿子。
实链剖分和重链剖分的区别在于:一条链不一定会链接到叶子节点

辅助树

现在,这棵树这我们分成了若干实链,现在,我们就需要辅助树维护这些链。而这棵辅助树往往是 Splay。

显然辅助树上的节点和原树上的节点一一对应。
我们还需要在辅助树上的中序遍历就是一条实链。

那么,我们怎么将原树的虚实链对应到辅助树上呢?
我们在 Splay 记录了儿子和父亲两个信息。
那么记录链上的每个点的父亲。
但是对于虚链,不记录其父亲的儿子信息,即认父不认子
对于实链,我们需要修改其父亲的儿子。
每个 Splay 的根是一条实链的顶点,而根也有可能有父亲节点。

显然一棵树的辅助树的形态不止一种。
tu

那么我们得到了辅助树和原树之间的关系:

  • 一棵 Splay 表示一条实链。
  • 原树的虚边,由儿子所在 Splay 的根节点指向该点,但该点不指向虚儿子。
  • Splay 上虽然至多只有两个实儿子,但虚边可以有很多条。
  • 原树的根不等于辅助树的根,辅助树在不破坏 Splay 性质的情况下可以随意换根。
  • 原树的 father 指向和辅助树不同,注意区分。
    在辅助树上,更易于进行虚实链之间的变换,这一点会在后文进行讲解。

Splay 的操作

知道了实链剖分和辅助树的概念,就可以开始实现 LCT 的一些常用函数了。

get

判断 x 是它父亲的哪个儿子。

#define get(x) (rs(fa(x)) == x)

isroot

LCT 新增函数,判断 x 是否为这棵 Splay 的根。
根据虚边认父不认子的性质,如果 x 父亲的两个儿子都找不到它,那么 x 是 Splay 的根。

#define isroot(x) (rs(fa(x)) != x && ls(fa(x)) != x)

pushup

根据题目进行维护。

pushdown

LCT 绝大部分时候都要在 Splay 上维护区间翻转标记。
其他的根据题目实现,这里展示区间翻转的实现
翻转标记有两种不同含义:

  • 表示打标记的节点没有更改过,例如没有换过左右儿子。
  • 表示打标记的节点已经更改好了,例如左右儿子已经调换过。
    两种写法没有什么本质区别,但在后面一些地方 pushdown 的顺序写起来不一样。
    第一种写法大多时候代码更简单,但有些题,维护的信息与左右儿子的顺序有关,这个时候只能用第二种写法。
    第一种:
void pushdown(int x){
    if(!d[x].rev)return;
    swap(ls(x), swap(rs(x))), d[ls(x)].rev ^= 1, d[rs(x)].rev ^= 1, d[x].rev = 0;
}

第二种

void change(int x){d[x].rev ^= 1, swap(ls(x), rs(x));}
void pushdown(int x){
    if(d[x].rev)change(ls(x)), change(rs(x)), d[x].rev = 0;
}

alldown

之前进行区间修改的时候,需要查排名,会将从根到 x 路径上的懒标记下放。
但 LCT 不关心排名,我们就需要 alldown 函数来下放。

//递归
void alldown(int x){
    if(!isroot(x))alldown(fa(x));
    pushdown(x);
}
//栈
void alldown(int x){
    int stk[N], top = 0;
    do stk[++top] = x, x = fa(x) while(!isroot(x));
    while(top--)pushdown(stk[top + 1]);
}

rotate

rotate 与原义相同,把 x 向上旋转一层。
由于我们要判断 y 是否是根,不能再简单判断 z 是否是 0
y 和父亲的连边断开再判断失效,所以我们把这句话提到前面。

void rotate(int x){
	int y = fa(x), z = fa(y), c = get(x);
	if(!isroot(y))d[z].ch[get(y)] = x;
	fa(d[y].ch[c] = d[x].ch[!c]) = y, fa(fa(d[x].ch[!c] = y) = x) = z;
	pushup(y), pushup(x);
}

Splay

把判断是否为根的语句换成 isroot 即可。注意 Splay 之前要先 alldown 这条路径。

void splay(int x){
	for(int f = fa((alldown(x), x));f = fa(x), !isroot(x);rotate(x))
		if(!isroot(f))rotate(get(f) ^ get(x) ? x : f);
}

汇总

Splay 部分代码
struct node{
	int rev, size, ch[2], fa, s, val;
}d[N];
#define ls(x) d[x].ch[0]
#define rs(x) d[x].ch[1]
#define fa(x) d[x].fa
#define get(x) (rs(fa(x)) == x)
#define isroot(x) (rs(fa(x)) != x && ls(fa(x)) != x)
void pushup(int x){d[x].s = d[ls(x)].s + d[rs(x)].s + d[x].val;}
void change(int x){d[x].rev ^= 1, swap(ls(x), rs(x));}
void pushdown(int x){if(d[x].rev)change(ls(x)), change(rs(x)), d[x].rev = 0;}
void alldown(int x){
	if(!isroot(x))alldown(fa(x));
	pushdown(x);
}
void rotate(int x){
	int y = fa(x), z = fa(y), c = get(x);
	if(!isroot(y))d[z].ch[get(y)] = x;
	fa(d[y].ch[c] = d[x].ch[!c]) = y, fa(fa(d[x].ch[!c] = y) = x) = z;
	pushup(y), pushup(x);
}
void splay(int x){
	for(int f = fa((update(x), x));f = fa(x), !isroot(x);rotate(x))
		if(!isroot(f))rotate(get(f) ^ get(x) ? x : f);
}

新的操作

access

access(x) 的作用是把 x 到根的路径上的点放进一棵 Splay 里,且这棵 Splay 里没有这条路径以外的点。
LCT 的所有函数都需要 access 操作
左边是 access(N) 后的原树,右边是辅助树的更改过程。

我们整理一下过程。

  • 把当前节点 splay 到根;
  • 令它的右儿子等于上次旋转的节点,并 pushup。
  • 跳到当前点的父亲,重复以上步骤。
int access(int x){
    int s = 0;
    for(; x; s = x, x = fa(x))splay(x), rs(x) = s, pushup(x);
    return s;
}

这里我们返回了最后一次虚实链变换时虚边父亲节点的编号。该值有两个含义:

  • 连续两次 access 操作时,第二次操作的返回值等于这两个节点的 LCA.
  • 表示 x 所在的 Splay 树的根,且父亲一定为空。

makeroot

在维护一个路径信息时,往往路径是先向上再向下的,这样的路径无法出现在同一棵 Splay 里。
但是我们还想维护它的信息,怎么办呢?
我们可以把原树的根换掉!makeroot(x) 的作用就是把 x 换成原树的根。
那我们先 access(x),把 xrt 弄到一棵 Splay 上,然后 splay(x) 一下。
但是 Splay 里的点是按中序遍历存的,设原来的原树根是 rt ,考虑换成 x 会对哪些点的上下顺序产生影响:
只有 xrt 这条路径上的点上下顺序反过来了。也就是把这一段的 Splay 区间翻转。

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

find

find(x) 的作用是查找 x 所在原树的根。
我们知道,access(x) 也是把 x 到原树的根这一段变成实链。
那么先 access(x),再 splay(x),以 x 为根的这个 Splay 就表示从原树的根到 x 的实链。
根据 Splay 的性质,原树的根是这条链从上到下第一个点,也就是这棵 Splay 中序遍历里的第一个点。
Splay 中序遍历里的第一个点,不难发现就是 x 一直往左儿子走,直到没有左儿子,这个点就是原树的根。

注意懒标记和判断先后顺序表示。
找到根后要 splay(x) 来保证复杂度。

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

split

split(x, y) 的作用是把 xy 的路径变成一棵 Splay。
我们先 makeroot(x) 接下来执行 access(y),就找到了这条路径。
还有个问题是这样不知道 Splay 的根,所以后面一般会再做一步 splay(y)

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

link(x,y) 表示给 xy 之间连一条边。
那么我们先 makeroot(x),让 x 成为自己这棵树的根,然后判断它们两个是否已经连通。
如果不连通,直接把点 x 作为虚儿子单向指向 y 即可。

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

cut

cut(x, y) 表示把 xy 的边断开。
我们先 Split(x, y),这时候 y 是根,x 一定是它的儿子,双向断开即可。
不过还要判是否有边,我们发现,它们右边当且仅当:

  • xy 连通。
  • xy 的路径之间没有其他点。
    由于 Splay 是中序排序,且 y 是根,那么他们有边仅当 x 的父亲是 y,且 x 没左儿子。
void cut(int x, int y){split(x, y);if(fa(x) == y && !ls(x))fa(x) = ls(y) = 0;}

时间复杂度

为均摊 log(n)

证明

来源 oi.wiki

LCT 中的大部分操作都基于 access,其余操作的时间复杂度都为常数,因此我们只需要分析 access 操作的时间复杂度。
其中,access 的时间复杂度主要来自于多次 splay 操作和对路径中虚边的访问,接下来分别分析这两部分的时间复杂度。

  1. splay
  • 定义 w(x)=logsize(x),其中 size(x) 表示以 x 为根的所有虚边和实边的数量之和。
  • 定义势能函数 Φ=xTw(x),其中 T 表示所有节点的集合。
  • 由 Splay 的时间复杂度 分析易知,splay 操作的均摊时间复杂度为 O(logn)
  1. 访问虚边
    定义两种虚边:
  • 重虚边:从节点 v 到其父节点的虚边,其中 size(v)>12size(parent(v))
  • 轻虚边:从节点 v 到其父节点的虚边,其中 size(v)12size(parent(v))

对于虚边的处理,可以使用势能分析,定义势能函数 Φ 为所有重虚边的数量,定义均摊成本 ci=ti+ΔΦi,其中 ti 为实际操作的成本,ΔΦi 为势能的变化。

  • 走过重虚边后,会将重虚边转换为实边,该操作会减少 1 的势能,因为它通过加强重要连接来优化树的结构。且由于其实际操作成本为 O(1),抵消了势能的增加,故不会增加均摊成本,所有的均摊成本集中在轻虚边的处理上。

  • 每次 access 操作最多遍历 O(logn) 条轻虚边,因此至多消耗 O(logn) 的实际操作成本,转化得到 O(logn) 条重虚边,即势能以 O(logn) 的代价增加。

  • 由此,最终访问虚边的均摊复杂度为实际操作成本和势能变化的和,即 O(logn)

综上所述,LCT 中 access 操作的时间复杂度是 splay 和 虚边访问的复杂度之和,因此最后的均摊复杂度为 O(logn),即 n 个节点的 LCT,做 m 次 access 操作的时间复杂度为 O(nlogn+mlogn),从而 LCT 操作的均摊复杂度也为 O(logn)

## 应用 ### 维护树链信息 ### 维护连通性 ### 维护边权
posted @   fush's_blog  阅读(2)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示