CC++ & Algorithm

动态树(Link-Cut Tree):会变形的树

极难6
语言版本:C++
概述:动态树(Link-Cut Tree)是一种可以快速连接、切断和查询路径信息的树形数据结构,特别擅长处理动态变化的森林。

动态树(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 的 rotatesplay 函数补全,并加入 pushdownpushup 的完整实现。

#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;
}

新手容易犯的错误

  1. 忘记 pushdown
    findrootsplay 中必须下传翻转标记,否则在反转后的树中找左子树会得到错误结果。

  2. 在 link 和 cut 之前忘记 makeroot 或条件判断不完整

    • link 时必须先 makeroot(x),否则可能连接方向错误。
    • cut 时必须严格检查 t[y].fa == x && !t[y].ch[0],否则可能误切。
  3. 混淆父指针的含义
    t[x].fa 在 Splay 树中代表父节点,但在虚边中代表路径外节点。不要直接修改 fa 而不经过 Splay 操作。

  4. 没有正确维护 pushup
    每次改变孩子(旋转、access、切割等)后都要 pushup,否则路径信息(sum)会错。

  5. 数组大小不够
    动态树一般需要 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] 大融合”,很快就能上手哦!

例题精讲

1单选题

在Link-Cut Tree中,执行access(x)操作后,关于节点x在辅助树(splay)中的状态,以下哪个说法是正确的?

A节点x没有右子树(右子为空)
B节点x没有左子树(左子为空)
C节点x同时具有左右子树
D节点x的右子树为原树中其父节点
2判断题

在Link-Cut Tree中,makeroot(x)操作的实现步骤为:先access(x)使x到根路径成实边,再splay(x)将x旋转到辅助树根,最后对x执行子树翻转操作(打翻转标记)。判断该描述是否正确。

3单选题

在Link-Cut Tree中,findroot(x)操作用于查找x所在原树的根节点。标准实现为:access(x); splay(x); 然后从x沿左孩子一直走到最左节点;最后对该节点执行splay操作。请问,最后一步splay该节点的主要目的什么?

A保持辅助树的平衡性,防止退化
B将该节点提升为splay的根,降低后续操作的均摊复杂度
C强制更新该节点上的懒标记
D获取该节点的节点值以返回
4填空题
假设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) {
        ___;
    }
}
5判断题

在Link-Cut Tree中,cut(x,y)操作删除边(x,y),假设该边确实存在且x是y的父节点(原树关系)。标准实现为:makeroot(x); access(y); splay(y); 此时y的左儿子应为x,且x没有右儿子(因为x是父节点)。随后将y的左儿子指针置空,同时将x的父指针置空,即可完成切断。判断该描述是否正确。