动态树(Link-Cut Tree):会变形的树
极难6动态树(Link-Cut Tree):会变形的树——从拼图到森林的魔法
这是什么?用来干什么?
想象你正在玩一个电子游戏,地图上有许多岛屿(节点),岛屿之间原本没有桥。你可以随时在两个岛屿之间建造一座桥(Link),也可以拆掉一座桥(Cut)。更神奇的是,你还能快速知道从任意一个岛屿到另一个岛屿,沿途经过的所有岛屿上的宝藏数之和(或者异或和)。而且,这些操作都是在不断变化的森林中瞬间完成的。
Link-Cut Tree(简称 LCT)就是这样一个“会变形”的数据结构。它专门用于处理动态森林——也就是节点和边可以随时增加或删除的树形结构。LCT 可以在接近 O(log n) 的时间里完成以下操作:
- 连接两棵树(Link)
- 断开一条边(Cut)
- 查询两个节点之间的路径信息(和、最大值、最小值等)
- 更改某个节点的权值
和静态的树不同,LCT 不需要事先知道整棵树的形状,它通过一种叫做“偏爱路径”的机制,把复杂的动态问题拆成许多“小段”,每一段用平衡树(Splay)来维护。这样,无论森林怎么变,LCT 都能轻松应对。
核心思想:把大树拆成“偏爱路径”
什么是“偏爱路径”?
LCT 把一棵树分解成若干条从根到叶子的路径,这些路径被称为偏爱路径。每个节点最多属于一条偏爱路径。偏爱路径的划分并不是固定的,而是根据最近访问过的节点动态调整——谁被“访问”了,谁所在的路径就可能被“提拔”为偏爱路径。
生活例子:想象一个班级,同学们(节点)按座位排成一棵“树”(班长是根)。班长每次叫某位同学回答问题时,那条从班长到这位同学经过的所有同学,就会在班长的“偏爱本”上被记下来,成为一条“特权路径”。下次如果叫另一个同学,那条路径就会被重新调整。
Splay 树的作用
LCT 使用Splay树(一种平衡二叉搜索树)来维护每一条偏爱路径。Splay树的每个节点对应原树中的一个节点,并且整条偏爱路径上的节点按照在原树中的深度(从根往下)作为关键字,存放到一棵Splay树中(左子树深度小,右子树深度大)。这样,我们就能用Splay的旋转、分裂、合并等操作,快速调整路径。
虚实链切换
LCT 中的边分为两种:
- 实边(Preferred Edge):连接两个属于同一条偏爱路径的节点,它们被放在同一棵Splay树里。
- 虚边(Edge):连接不同偏爱路径的节点,用父指针(fa)表示,但只记录从子节点指向父节点(Splay的根的父亲),而且这个父节点不一定在同一个Splay树里。
当我们执行 access(x) 操作时,会把从根到 x 的路径上所有边变成实边,其他边变成虚边。这个“虚实切换”是LCT的核心,保证了每次操作只影响 O(log n) 个节点。
基本操作详解(配例子和代码)
1. access(x):打通根到 x 的路径
作用:把从树根(当前根)到节点 x 的路径变成一条实边路径,并且让 x 成为所在Splay树的根。
生活例子:你(x)想从班长(根)那里拿到一份批准,班长需要在你经过的每个同学那里盖章。access(x) 就是让班长一路通知这些同学,让你能快速拿到批准。
代码实现:
void access(int x) {
for (int y = 0; x; y = x, x = t[x].fa) { // y 是上一步的 x,用于连接
splay(x); // 先把 x 旋转到它所在 Splay 的根
t[x].ch[1] = y; // 把原来的右儿子换成 y(新的虚边变实)
pushup(x); // 更新 x 的 sum 信息
}
}
注意:t[x].fa 在这个循环中指向的是 x 所在Splay树的根的父亲(可能是虚边指向的节点)。循环每次向上跳,直到根(x=0)。
2. makeroot(x):让 x 成为整棵树的根
作用:把 x 变成整棵树的根。这样,我们就能以 x 为基准进行各种操作。
原理:先 access(x) 打通路径,再 splay(x) 将 x 旋转到所在Splay的根,然后对 x 所在Splay树进行翻转(rev),使得整条路径的方向反过来。翻转后,原本深度最小的节点(根)变成深度最大的节点(x),所以 x 变成了新的根。
生活例子:你想当班长?先 makeroot(你),同学们都听你的了。
代码实现:
void makeroot(int x) {
access(x);
splay(x);
pushrev(x); // 翻转 x 所在 Splay 树
}
3. findroot(x):找 x 所在树的根
作用:找到 x 所在树的真正的根(在原树中的根节点)。
原理:先 access(x),再 splay(x),然后一直往左子树走(因为根在路径的最左端,深度最小),并沿途 pushdown(因为可能有反转标记),最后到达的节点就是根。
代码实现:
int findroot(int x) {
access(x);
splay(x);
while (t[x].ch[0]) { // 一直向左走,直到没有左孩子
pushdown(x); // 下传翻转标记,保证方向正确
x = t[x].ch[0];
}
splay(x); // 把根旋转到 Splay 的根,加速后续操作
return x;
}
常见错误:忘记 pushdown,导致在翻转后的树上找错根。
4. link(x, y):在 x 和 y 之间连一条边
作用:把 x 和 y 两个节点连接起来(假设之前它们不在同一棵树中)。实际上是把 x 所在的树作为子树,接到 y 的下面。
做法:先让 x 变成它所在树的根(makeroot(x)),然后检查 y 的根是不是 x(如果是就不连,避免环),最后把 x 的父亲设为 y。
代码实现:
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) { // 确保不在同一棵树
t[x].fa = y; // 虚边连接:x 挂在 y 下面
}
}
生活例子:两个班级合并,一班班长(x)和二班班长(y)握手,让一班所有同学归属二班。先让一班班长“成为自己班的根”,然后判断二班班长不是自己人,就把一班的根的父亲指向二班班长。
5. cut(x, y):切断 x 和 y 之间的边
作用:删除 x 和 y 之间的边(假设这条边一定存在)。
做法:先让 x 成为根(makeroot(x)),然后检查 y 的根是否为 x,并且 y 的父亲是否为 x,且 y 没有左孩子(即 y 是 x 的直接右孩子)。如果条件满足,就把 x 的右孩子和 y 的父亲置为 0,并更新信息。
代码实现:
void cut(int x, int y) {
makeroot(x); // 先让 x 成为根
if (findroot(y) == x && // y 和 x 在同一棵树
t[y].fa == x && // y 的父亲就是 x
!t[y].ch[0]) { // y 没有左孩子(说明 y 在 Splay 中是 x 的直接右孩子)
t[x].ch[1] = 0; // 断开 x 的右孩子
t[y].fa = 0; // 清空 y 的父亲
pushup(x); // 更新 x 的 sum
}
}
注意:必须严格检查条件,否则可能导致错误断开。新手容易忽略 !t[y].ch[0] 的判断,导致切断了不该切断的边。
6. split(x, y):提取 x 到 y 的路径
作用:把 x 到 y 的路径单独提取到一棵Splay树中,并且让 y 成为这棵Splay树的根。之后我们就可以直接查询 y(作为根)的 sum 信息来得到整条路径的信息。
做法:makeroot(x);然后 access(y);最后 splay(y)。这时,路径上的所有节点都在以 y 为根的Splay树中。
代码实现:
void split(int x, int y) {
makeroot(x);
access(y);
splay(y);
}
例子:查询从学校大门(x)到操场出口(y)的路径上所有同学的积分和。先让大门变成根,然后打通到出口的路径,最后看出口节点的 t[y].sum 即可。
完整可运行的代码示例(含 splay 细节)
下面是一个完整的 C++ 代码,可以读入 n 个节点的初始权值,然后处理 m 个操作。操作类型用数字表示:
- 1 x y:Link(x, y)
- 2 x y:Cut(x, y)
- 3 x y:查询路径 x 到 y 上的异或和(也可以用加法,这里沿用原代码的异或)
为了简洁,我们把 splay 的 rotate 和 splay 函数补全,并加入 pushdown 和 pushup 的完整实现。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int ch[2]; // 左右孩子,0:左,1:右
int fa; // 父节点(在Splay树中),如果是虚边,fa指向路径外节点
int rev; // 翻转标记(0或1)
int val; // 节点权值
int sum; // 子树异或和(包括自身)
} t[100005]; // 节点数组,节点编号从1到n,0表示空
int n, m;
// 判断 x 是不是所在Splay树的根(即没有父节点或父节点的左右孩子都不是它)
bool isroot(int x) {
return t[t[x].fa].ch[0] != x && t[t[x].fa].ch[1] != x;
}
// 更新 x 的 sum:左子树异或和 ^ 右子树异或和 ^ 自身权值
void pushup(int x) {
t[x].sum = t[t[x].ch[0]].sum ^ t[t[x].ch[1]].sum ^ t[x].val;
}
// 翻转 x 所在子树(交换左右孩子,并打上标记)
void pushrev(int x) {
swap(t[x].ch[0], t[x].ch[1]);
t[x].rev ^= 1;
}
// 下传翻转标记(如果有,则向下传递)
void pushdown(int x) {
if (t[x].rev) {
if (t[x].ch[0]) pushrev(t[x].ch[0]); // 左子树翻转
if (t[x].ch[1]) pushrev(t[x].ch[1]); // 右子树翻转
t[x].rev = 0; // 清除自身标记
}
}
// 旋转:将 x 向上旋转一层(左旋或右旋)
void rotate(int x) {
int y = t[x].fa, z = t[y].fa; // y 是 x 的父,z 是爷爷
int k = (t[y].ch[1] == x); // k=0表示x是左孩子,k=1表示右孩子
if (!isroot(y)) t[z].ch[t[z].ch[1] == y] = x; // 如果y不是根,更新爷爷的孩子
t[x].fa = z; // 更新x的父亲为z
t[y].ch[k] = t[x].ch[k ^ 1]; // 把x的另一个孩子给y
if (t[x].ch[k ^ 1]) t[t[x].ch[k ^ 1]].fa = y;
t[x].ch[k ^ 1] = y; // y成为x的对应孩子
t[y].fa = x;
pushup(y); pushup(x); // 更新信息
}
// 把 x 旋转到所在Splay树的根(一路向上splay)
void splay(int x) {
// 因为旋转时会改变父节点关系,需要先把从根到x路径上的所有翻转标记下传
stack<int> stk;
int y = x;
stk.push(y);
while (!isroot(y)) { // 把路径上的节点存入栈
y = t[y].fa;
stk.push(y);
}
while (!stk.empty()) { // 从根到x依次下传
pushdown(stk.top());
stk.pop();
}
while (!isroot(x)) {
int y = t[x].fa, z = t[y].fa;
if (!isroot(y)) { // 如果y不是根,需要判断旋转类型
if ((t[z].ch[0] == y) ^ (t[y].ch[0] == x)) rotate(x); // 之字形
else rotate(y); // 一字型
}
rotate(x);
}
// 此时x已经是Splay树的根
}
// 以下五个操作前面已经详细解释
void access(int x) {
for (int y = 0; x; y = x, x = t[x].fa) {
splay(x);
t[x].ch[1] = y;
pushup(x);
}
}
void makeroot(int x) {
access(x);
splay(x);
pushrev(x);
}
int findroot(int x) {
access(x);
splay(x);
while (t[x].ch[0]) {
pushdown(x);
x = t[x].ch[0];
}
splay(x);
return x;
}
void split(int x, int y) {
makeroot(x);
access(y);
splay(y);
}
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) {
t[x].fa = y;
}
}
void cut(int x, int y) {
makeroot(x);
if (findroot(y) == x && t[y].fa == x && !t[y].ch[0]) {
t[x].ch[1] = 0;
t[y].fa = 0;
pushup(x);
}
}
int main() {
scanf("%d%d", &n, &m); // 读入节点数n,操作数m
for (int i = 1; i <= n; i++) {
scanf("%d", &t[i].val); // 读入每个节点的初始权值
t[i].sum = t[i].val; // 初始sum等于自身权值
}
while (m--) {
int op, x, y;
scanf("%d%d%d", &op, &x, &y);
if (op == 1) {
link(x, y);
} else if (op == 2) {
cut(x, y);
} else if (op == 3) {
split(x, y);
printf("%d\n", t[y].sum); // 路径异或和
}
}
return 0;
}
新手容易犯的错误
-
忘记 pushdown
在findroot、splay中必须下传翻转标记,否则在反转后的树中找左子树会得到错误结果。 -
在 link 和 cut 之前忘记 makeroot 或条件判断不完整
link时必须先makeroot(x),否则可能连接方向错误。cut时必须严格检查t[y].fa == x && !t[y].ch[0],否则可能误切。
-
混淆父指针的含义
t[x].fa在 Splay 树中代表父节点,但在虚边中代表路径外节点。不要直接修改fa而不经过 Splay 操作。 -
没有正确维护 pushup
每次改变孩子(旋转、access、切割等)后都要pushup,否则路径信息(sum)会错。 -
数组大小不够
动态树一般需要 2~4 倍节点数(因为有 splay 的旋转,但这里直接用原节点,所以数组开够最大 n+5 即可)。如果 n 很大(如 10^5),数组至少开 n+5。
生活中的应用类比
游戏中的地图:比如“我的世界”中,玩家可以随意搭桥(Link)和拆桥(Cut),需要快速计算从家(节点A)到矿洞(节点B)的铁轨长度。用 LCT 可以实时维护。
班级积分系统:同学们按位置形成一棵树(班主任是根)。每当有人换座位(Cut+Link),老师需要快速知道从小红到小明的路径上所有人的总积分。LCT 可以支持这种动态调整。
网络路由:互联网中的路由器可以动态连接和断开,需要快速检测两个路由器是否连通,或者计算路径上的总带宽。LCT 也能胜任。
相关知识点指引
- Splay 树:LCT 的基础,掌握 Splay 的旋转、双旋、标记下传是学习 LCT 的前提。
- 树链剖分(Heavy Light Decomposition):静态树路径查询,比 LCT 简单,但无法处理动态变化。
- 线段树:处理一维区间问题,LCT 把树路径转化为 Splay 上的区间,思想类似。
- 并查集:只支持连通行,不支持 Cut 和路径信息查询,但实现简单。
- 动态树(Euler Tour Tree, ETT):另一种动态树,支持 Link/Cut 和子树查询,但路径查询不如 LCT 方便。
如果你已经熟悉了 Splay 树和树链剖分,那么 LCT 会是一个强大的工具,让你在竞赛中轻松处理动态森林问题。多练习模板题,比如“P2147 [SDOI2008] 洞穴勘测”、“P4219 [BJOI2014] 大融合”,很快就能上手哦!
例题精讲
在Link-Cut Tree中,执行access(x)操作后,关于节点x在辅助树(splay)中的状态,以下哪个说法是正确的?
在Link-Cut Tree中,makeroot(x)操作的实现步骤为:先access(x)使x到根路径成实边,再splay(x)将x旋转到辅助树根,最后对x执行子树翻转操作(打翻转标记)。判断该描述是否正确。
在Link-Cut Tree中,findroot(x)操作用于查找x所在原树的根节点。标准实现为:access(x); splay(x); 然后从x沿左孩子一直走到最左节点;最后对该节点执行splay操作。请问,最后一步splay该节点的主要目的什么?
假设Link-Cut Tree已经定义了makeroot(x)和findroot(x)函数,且节点数组为fa[](记录父指针,0表示无),ch[][2]表示左右儿子,rev[]为翻转标记。现需要实现link(x,y)操作,表示在x和y之间连一条边(保证连接前不在同一棵树)。请补全下面的函数:
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) {
___;
}
}在Link-Cut Tree中,cut(x,y)操作删除边(x,y),假设该边确实存在且x是y的父节点(原树关系)。标准实现为:makeroot(x); access(y); splay(y); 此时y的左儿子应为x,且x没有右儿子(因为x是父节点)。随后将y的左儿子指针置空,同时将x的父指针置空,即可完成切断。判断该描述是否正确。