动态树(Link-Cut Tree)简介
极难4让树木自由生长:动态树(Link-Cut Tree)完全入门
你有没有想过,如果一棵树可以随时“长出”新树枝、折断旧树枝,而且你还能快速知道任意两个枝杈之间最长或者最短的距离,该怎么办?普通的树链剖分(树剖)只能处理静态的树——树一旦建好就不能再改。但现实中的关系网、社交网络、甚至游戏里的技能树都是会变的。动态树(Link-Cut Tree,简称LCT) 就是专门解决这种动态森林问题的“神兵利器”。
LCT的核心思想是:用Splay树来维护树上的“实链”,通过 access 操作动态地改变虚实关系,从而实现连边、断边、换根、路径查询等操作,每次操作的平均时间复杂度只有 O(log n)(基于Splay的势能分析)。
本文将从零开始,带你理解LCT的每一个关键概念,配合生活实例、图解和完整代码,让你轻松掌握这个高级数据结构。
1. LCT 能做什么?
link(u, v):在u和v之间连一条边(前提是它们原本不在同一棵树里)。cut(u, v):断开u和v之间的边(前提是边确实存在)。find_root(u):找到u所在树的根节点。makeroot(u):把u变为它所在树的根(树换根)。split(u, v):把u到v的路径“提取”出来,方便查询路径上的信息(比如最大值、和、异或和等)。- 单点修改:更新某个节点的权值,并更新路径信息。
举个例子:你正在设计一个社交网络,每个用户是一个节点,好友关系是边。用户可以加好友(link)、删除好友(cut),你可以随时查询两个用户之间是否存在路径(连通性),或者他们之间所有用户的“活跃度”总和(路径查询)。LCT就是完成这些任务的利器。
2. 生活中的比喻:从“排队”到“实链”
想象班级排队做操:同学们站成一列,老师每次可以叫一个同学“出列”变成排头,也可以把两列合并成一列。这里的“列”就是一条链,而LCT中的 Splay树 就是把一条链上的节点按深度顺序存起来(中序遍历就是从上到下)。而整个森林由很多条这样的链组成,通过虚边连接。
- 实链:当前被选中、用Splay树维护的一段路径。比如老师正在管理的这一列。
- 虚边:表示两条实链之间的连接(但不在同一棵Splay树中)。比如两列排队的人虽然属于同一个班级,但暂时没有合并成一列。
LCT的精髓就在于:你可以通过 access 操作,把某个节点到根节点的路径上所有虚边都变成实边,从而提取出一条完整的实链。
3. 核心概念详解
3.1 实链剖分与辅助树
- 实链剖分:每个节点最多有一条实边连向它的重儿子(注意这里是动态的,根可以变)。其余的边都是虚边。
- 辅助树(Auxiliary Tree):由多棵Splay树组成,每一棵Splay树维护一段实链。Splay树的中序遍历对应链上节点从上到下的顺序。
- 认父不认子:虚边只记录父指针(
fa),但不记录子指针。也就是说,Splay树的根节点通过fa指向它在原树中的父节点(如果存在),但那个父节点并不知道有这个子节点(除非通过实链)。
生活例子:假设一个班级分成好几个小组,每个小组内成员手拉手站成一列(实链),组长(Splay树的根)则和上一组的组长通过“眼神示意”连接(虚边)。access 操作就是让班长下令:“所有小组列队,从你开始一直到最前面的人,全部手拉手站成一个大长队!”
3.2 核心操作:access
access(x) 是LCT中最基础也最重要的操作。它的作用是把从 x 到它所在树的根节点之间的路径变成一条实链,并且 x 是这条链的最后一个节点(深度最大)。做法如下:
- 从
x开始,不断重复以下步骤,直到x变成根(即x的父指针为0):- 把
x在它所在的Splay树中旋转到根(splay(x))。 - 把
x的右儿子改成上一次操作处理过的节点(即之前那条链的头部),这样就相当于把虚边变成了实边。 - 更新节点信息(
pushup),然后x沿着虚边跳到它的父节点。
- 把
简单记忆:access(x) = 把 x 到根的路径全部打通,变成一条实实在在的链。
3.3 核心操作:makeroot
makeroot(x) 让 x 成为它所在树的根。做法:
- 先
access(x),把x到原来根的路径打通。 - 再
splay(x),让x成为Splay树的根。 - 最后给
x打上翻转标记(rev),这样整棵Splay树的中序就反过来了,原来深度最大的x变成了深度最小(即根)。
为什么可以这样? 因为Splay树的中序遍历代表实链从上到下的顺序,翻转后顺序相反,深度也就反过来了。
3.4 其他操作:find_root、split、link、cut
find_root(x):先access(x)打通路径,再splay(x),然后一直往左儿子走(因为中序遍历最左边的节点是深度最小的节点,即根),走到叶子就是根。注意最后要splay一下该节点,保证复杂度。split(u, v):提取u到v的路径。做法:makeroot(u),然后access(v),最后splay(v)。此时v的Splay树中存的就是u到v路径上的所有节点,t[v].sum(或别的信息)就是路径总信息。link(u, v):在u和v之间加边。先makeroot(u),如果find_root(v) != u(说明它们不在一棵树里),则把u的父指针指向v(加一条虚边)。cut(u, v):断开边(u, v)。先makeroot(u),再access(v)并splay(v)。此时如果u是v的左儿子(且u没有右子树),说明它们直接相连,就可以断开:把v的左儿子置0,u的父指针置0,并更新信息。
判断是否直接相连的条件:
find_root(v) == u(它们在同一棵树里)。t[v].fa == u且t[v].ch[0] == 0(因为makeroot(u)后,u是根,v在它的右子树里,并且u到v是直接边,所以v的左儿子应该为空,否则v有更深的子孙)。
4. 新手最容易犯的 5 个错误
- 忘记
pushdown:在splay之前,必须把从根到当前节点的路径上的所有翻转标记都下传(一般用栈存储路径)。否则翻转标记没下传,旋转会导致树结构错误。 access时没有更新右儿子:每一步必须把last赋给当前节点的右儿子,并pushup,否则新链的边没连上。cut时条件判断太宽松:不能只判断find_root(v) == u,还必须验证t[v].fa == u && t[v].ch[0] == 0。否则可能误删了非直接边。isroot判断错误:一个节点是Splay树的根,当且仅当它的父指针指向的节点(fa[x])的左右儿子都不是它。注意:虚边和实边的父指针都在fa里,所以用t[t[x].fa].ch[0] != x && t[t[x].fa].ch[1] != x。- 单点修改后忘记
pushup:修改节点值后,一定要splay该节点(确保信息正确),然后pushup。或者调用splay(u)后再修改,然后再pushup。
5. 完整代码示例(支持路径异或和)
下面给出 C++ 和 Python 的完整实现,以 路径异或和 为例(你可以轻松改成最大值、和等)。代码中每一行变量都加了中文注释。
5.1 C++ 代码
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 100005; // 最大节点数
struct Node {
int ch[2]; // 左右儿子,0:左,1:右
int fa; // 父指针(包括实边和虚边)
int val; // 节点权值
int sum; // 子树异或和
int rev; // 翻转标记(懒标记)
} t[MAXN];
// 判断 x 是不是它所在Splay树的根(不是原树根)
bool isroot(int x) {
int f = t[x].fa;
return t[f].ch[0] != x && t[f].ch[1] != x;
}
// 上传信息:计算异或和
void pushup(int x) {
t[x].sum = t[x].val ^ t[t[x].ch[0]].sum ^ t[t[x].ch[1]].sum;
}
// 翻转一个子树
void pushrev(int x) {
if (!x) return;
swap(t[x].ch[0], t[x].ch[1]); // 交换左右儿子
t[x].rev ^= 1; // 标记翻转
}
// 下传懒标记
void pushdown(int x) {
if (t[x].rev) {
pushrev(t[x].ch[0]);
pushrev(t[x].ch[1]);
t[x].rev = 0;
}
}
// 旋转(左旋或右旋,根据k决定)
void rotate(int x) {
int y = t[x].fa;
int z = t[y].fa;
int k = (t[y].ch[1] == x); // 0:左旋,1:右旋
if (!isroot(y)) t[z].ch[t[z].ch[1] == y] = x;
t[x].fa = z;
t[y].ch[k] = t[x].ch[k ^ 1];
if (t[x].ch[k ^ 1]) t[t[x].ch[k ^ 1]].fa = y;
t[x].ch[k ^ 1] = y;
t[y].fa = x;
pushup(y);
pushup(x);
}
// 把 x 旋转到它所在Splay树的根
void splay(int x) {
vector<int> stk; // 栈用于保存从根到x的路径
int y = x;
stk.push_back(y);
while (!isroot(y)) y = t[y].fa, stk.push_back(y);
// 从根到x依次下传标记
while (!stk.empty()) pushdown(stk.back()), stk.pop_back();
while (!isroot(x)) {
int y = t[x].fa, z = t[y].fa;
if (!isroot(y)) {
// 如果是折线形,先转x;如果是直线形,先转y
if ((t[z].ch[0] == y) ^ (t[y].ch[0] == x)) rotate(x);
else rotate(y);
}
rotate(x);
}
pushup(x);
}
// 打通 x 到根的路径,变成实链
void access(int x) {
int last = 0; // 上一次操作处理完的节点(即新链的头)
for (; x; x = t[x].fa) {
splay(x);
t[x].ch[1] = last; // 把右儿子改成上次的链(虚变实)
pushup(x);
last = x; // 更新last
}
}
// 让 x 成为原树的根
void makeroot(int x) {
access(x);
splay(x);
pushrev(x); // 翻转整棵Splay树,深度颠倒
}
// 找到 x 所在原树的根
int find_root(int x) {
access(x);
splay(x);
while (t[x].ch[0]) pushdown(x), x = t[x].ch[0]; // 一直往左
splay(x); // 保证势能分析
return x;
}
// 提取 u 到 v 的路径,结果存在 v 的Splay树中
void split(int u, int v) {
makeroot(u);
access(v);
splay(v);
}
// 连接 u 和 v(加边)
void link(int u, int v) {
makeroot(u);
if (find_root(v) != u) t[u].fa = v; // 加一条虚边
}
// 断开 u 和 v 之间的边
void cut(int u, int v) {
makeroot(u);
// 判断是否直接相连
if (find_root(v) == u && t[v].fa == u && !t[v].ch[0]) {
t[u].ch[1] = 0; // 右子树置空
t[v].fa = 0; // 父指针清0
pushup(u);
}
}
int main() {
int n, m; // n:节点数,m:操作数
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> t[i].val; // 输入节点权值
t[i].sum = t[i].val;
}
while (m--) {
int op, u, v;
cin >> op >> u >> v;
if (op == 0) { // 查询u到v路径异或和
split(u, v);
cout << t[v].sum << endl;
} else if (op == 1) { // 连接u和v
link(u, v);
} else if (op == 2) { // 断开u和v
cut(u, v);
} else { // 修改u的权值为v
splay(u);
t[u].val = v;
pushup(u);
}
}
return 0;
}
5.2 Python 代码
import sys
sys.setrecursionlimit(1 << 25) # 防止递归深度不够
class LCT:
def __init__(self, n):
self.n = n
self.ch = [[0, 0] for _ in range(n + 1)] # 左右儿子
self.fa = [0] * (n + 1) # 父指针
self.val = [0] * (n + 1) # 节点权值
self.sum = [0] * (n + 1) # 子树异或和
self.rev = [0] * (n + 1) # 翻转标记
def isroot(self, x):
# 判断 x 是不是Splay树的根
f = self.fa[x]
return f == 0 or (self.ch[f][0] != x and self.ch[f][1] != x)
def pushup(self, x):
self.sum[x] = self.val[x] ^ self.sum[self.ch[x][0]] ^ self.sum[self.ch[x][1]]
def pushrev(self, x):
if x == 0:
return
self.ch[x][0], self.ch[x][1] = self.ch[x][1], self.ch[x][0]
self.rev[x] ^= 1
def pushdown(self, x):
if self.rev[x]:
self.pushrev(self.ch[x][0])
self.pushrev(self.ch[x][1])
self.rev[x] = 0
def rotate(self, x):
y = self.fa[x]
z = self.fa[y]
k = 1 if self.ch[y][1] == x else 0 # 0:左旋,1:右旋
if not self.isroot(y):
self.ch[z][1 if self.ch[z][1] == y else 0] = x
self.fa[x] = z
self.ch[y][k] = self.ch[x][k ^ 1]
if self.ch[x][k ^ 1]:
self.fa[self.ch[x][k ^ 1]] = y
self.ch[x][k ^ 1] = y
self.fa[y] = x
self.pushup(y)
self.pushup(x)
def splay(self, x):
# 先用栈保存路径,依次下传标记
stk = []
y = x
stk.append(y)
while not self.isroot(y):
y = self.fa[y]
stk.append(y)
while stk:
self.pushdown(stk.pop())
while not self.isroot(x):
y = self.fa[x]
z = self.fa[y]
if not self.isroot(y):
if (self.ch[z][0] == y) ^ (self.ch[y][0] == x):
self.rotate(x)
else:
self.rotate(y)
self.rotate(x)
self.pushup(x)
def access(self, x):
last = 0
while x:
self.splay(x)
self.ch[x][1] = last
self.pushup(x)
last = x
x = self.fa[x]
def makeroot(self, x):
self.access(x)
self.splay(x)
self.pushrev(x)
def find_root(self, x):
self.access(x)
self.splay(x)
while self.ch[x][0]:
self.pushdown(x)
x = self.ch[x][0]
self.splay(x)
return x
def split(self, u, v):
self.makeroot(u)
self.access(v)
self.splay(v)
def link(self, u, v):
self.makeroot(u)
if self.find_root(v) != u:
self.fa[u] = v
def cut(self, u, v):
self.makeroot(u)
if self.find_root(v) == u and self.fa[v] == u and self.ch[v][0] == 0:
self.ch[u][1] = 0
self.fa[v] = 0
self.pushup(u)
def main():
n, m = map(int, input().split())
lct = LCT(n)
arr = list(map(int, input().split()))
for i in range(1, n + 1):
lct.val[i] = arr[i - 1]
lct.sum[i] = arr[i - 1]
for _ in range(m):
op, u, v = map(int, input().split())
if op == 0: # 查询路径异或和
lct.split(u, v)
print(lct.sum[v])
elif op == 1: # 连接
lct.link(u, v)
elif op == 2: # 断开
lct.cut(u, v)
else: # 修改单点权值
lct.splay(u)
lct.val[u] = v
lct.pushup(u)
if __name__ == "__main__":
main()
6. 如何测试你的代码?
你可以用下面的简单测试样例来验证正确性:
输入:
5 7
1 2 3 4 5
0 1 3
1 1 2
1 2 3
0 1 3
2 2 3
0 1 3
3 1 10
0 1 3
解释:
- 5个节点,7个操作。
- 初始节点权值:1,2,3,4,5。
- 操作0:查询1到3路径异或和(此时没有边,不连通,
split不会出错但sum可能只是1和3各自异或?注意split会换根,不连通时结果无意义,实际题目会保证操作合法。但这里只是演示,建议在合法时使用。) - 操作1:连边1-2。
- 操作1:连边2-3。
- 操作0:查询1到3路径异或和 → 1^2^3 = 0。
- 操作2:断开2-3。
- 操作0:查询1到3路径 → 1^2 = 3(注意此时1到3不连通,但LCT不会报错,返回的是以1为根到3的路径?实际上不连通时
split仍会提取路径,但路径上只有1和3?严格来说不连通时路径不存在,实际使用应确保连通。这里仅作测试,结果可能不准确。) - 操作3:修改节点1的权值为10。
- 操作0:查询1到3路径 → 10^2 = 8。
注意:LCT的 split 在两点不连通时仍会执行 makeroot(u) 和 access(v),但此时v的根不是u,所以路径上可能只有u或v,或者包含中间的虚边节点?实际上,如果u和v不在同一棵树,access(v) 会把v到它自己树的根的路径打通,但u不在那条路径上。所以结果是没有意义的。在实际题目中,查询和连边/断边操作会保证合法性,或者你需要先判断连通性。
7. 常见考试 / 竞赛题型
- 动态连通性:判断两个点是否连通(用
find_root)。 - 路径信息查询:最大值、最小值、和、异或和等。
- 最小生成树动态维护(如维护边权,用LCT维护最大边权,支持加边和删边)。
- 树上操作:子树信息?LCT不擅长子树,因为树根会变。子树查询通常用树剖或可持久化。
8. 总结与扩展指引
LCT是一把瑞士军刀,能处理各种动态树问题。它的核心就是“Splay + 虚实变换”。记住几个关键口诀:
access打通到根的路。makeroot先access再splay再翻转。link和cut前都先makeroot(u),再判断是否合法。
掌握了LCT,你还可以进一步学习:
- 可持久化LCT(更深奥)
- LCT维护边权(将边权转化为点权)
- Euler Tour Tree(ETT),另一种动态树方案
- 树链剖分(静态版)对比理解
现在,你已经学会了让树木自由生长的方法!快去试试吧。
例题精讲
在动态树(Link-Cut Tree)中,access(x) 操作的主要目的是什么?
在 Link-Cut Tree 中,makeroot(x) 操作可以通过先 access(x) 再 splay(x),最后翻转 x 所在的 Splay 树来实现。
对于一棵有 n 个节点的动态树,使用 Link-Cut Tree 进行 m 次操作(包含 link、cut、路径查询等),在均摊意义下,每次操作的时间复杂度是?
以下是 Link-Cut Tree 中 splay 操作的部分实现,请补全代码。假设已有函数 isroot(x) 判断 x 是否为所在 Splay 树的根,pushdown(x) 下传标记,rotate(x) 进行单次旋转。
void splay(int x) {
stack<int> st;
for (int y = x; !isroot(y); y = fa[y]) st.push(y);
st.push(x);
while (!st.empty()) { pushdown(st.top()); st.pop(); }
while (___①___) {
int y = fa[x], z = fa[y];
if (!isroot(y)) {
if ((___②___) == (___③___)) rotate(y);
else rotate(x);
}
rotate(x);
}
}
请将 ①、②、③ 处填写正确,使函数完整。下面是 Link-Cut Tree 中 link(x, y) 操作的代码,其功能是将节点 x 和 y 之间连一条边(假设 x 和 y 不在同一棵树中)。请补全缺失的两处(每处一个函数调用)。
void link(int x, int y) {
makeroot(x);
if (___①___ != y) {
___②___;
}
}
已知 findroot(x) 返回 x 所在树的根节点,且已有 makeroot 和 access 等函数。请填写 ① 和 ② 处的完整表达式。