每日算法 — 使用java实现动态树:Link-Cut Tree路径查询与动态森林维护

一、算法概述

动态树(Dynamic Tree)问题是指:维护一个会随时变化的森林,每个结点有权值,需要支持任意两个结点之间的连通性查询、边连接、边删除,以及对路径上的结点进行求和、求最大/最小值、修改等操作。1982 年 Sleator 和 Tarjan 提出的 Link-Cut Tree(以下简称 LCT)是第一个能够在均摊 O(log n) 时间内完成所有这些操作的经典数据结构,它的核心思想是把树形结构拆分成”实边”和”虚边”,并通过一系列精巧的旋转、暴露(expose)操作把任意结点到根的路径转换成”实路径”以便直接访问。

LCT 的难度远高于线段树、平衡树等普通数据结构。它需要熟练掌握 splay 树 作为底层容器,并且需要理解”路径偏好(path parent)”与”虚实边(preferred child / virtual child)”的区别。但一旦掌握,LCT 就能轻松处理一类”动态连通 + 路径维护”问题,比如动态最小生成树、动态最大流(部分实现)、网络割点处理等。

本文将系统讲解其设计原理,给出一个生产可用的 Java 实现,并展示 main 方法中的常见操作。

二、算法原理

2.1 数据结构概览

LCT 由若干棵 splay 树 组成。每一棵 splay 树表示一个”实路径”(preferred path)。整个森林被划分为若干条实路径,每条实路径用一棵 splay 树维护;不同实路径之间通过 虚边 连接,但不进入同一棵 splay 树。

对于结点 x:
– left[x], right[x]:splay 树中的左右子结点;
– parent[x]:父结点(在 splay 树中是父结点,在森林中可能是虚边的”路径父亲”);
– reverse[x]:懒翻转标记,表示以 x 为根的子树需要翻转(用于路径反向);
– value[x]:结点权值;
– sum[x], max[x]:以 x 为根的 splay 子树对应的聚合信息。

关键的概念是”expose(x)“,也常写作 access(x),它把结点 x 到根结点(所在实路径树的根)之间的路径变成一条实路径,并把 x 设为该 splay 树的根。这是 LCT 一切高级操作的基础。

2.2 核心操作

2.2.1 isRoot(x)

判断 x 是否是其所在 splay 树的根。在 LCT 中,splay 树的根与 splay 树的 splay 操作后的根不同:只有在父结点的左右子都不指向 x 时,x 才是该 splay 树的根。这是 LCT 实现的关键判别条件。

2.2.2 pushUp(x)

维护 x 为根的 splay 子树信息(求和、最大值等)。具体取决于需要支持的查询类型。

2.2.3 pushDown(x)

下推 reverse 标记。reverse 会让左右子树交换,sum 不变但顺序需要翻;如果维护最大最小值则可能变化。

2.2.4 rotate(x)

标准的 splay 树旋转。需要先 pushDown 父结点链上的所有标记,再旋转。

2.2.5 splay(x)

把 x 通过一系列旋转提升到所在 splay 树的根。在 LCT 中,splay(x) 只会 把 x 提升到所在实路径对应的 splay 树的根,而不会跨越虚边。

2.2.6 access(x)

access(x):
    last = null
    while x != null:
        splay(x)
        right[x] = last
        pushUp(x)
        last = x
        x = parent[x]    // 跨过虚边

这一步把从 x 到根的路径变成了实路径,且 x 成为该 splay 树的根。

2.2.7 makeRoot(x)

makeRoot(x):
    access(x)
    splay(x)
    toggle reverse[x]

把 x 设为所在树的根。access 之后 x 已经是 splay 树的根,再做懒翻转即可。

2.2.8 findRoot(x)

findRoot(x):
    access(x)
    splay(x)
    pushDown(x)
    while left[x] != null:
        pushDown(x)
        x = left[x]
    splay(x)
    return x

返回 x 所在树的根(最左侧结点)。

2.2.9 split(x, y)

把 x -> y 这条路径变成单独的实路径,方法是先 makeRoot(x),再 access(y),再 splay(y)。此时 y 所在 splay 树恰好对应 x -> y 的路径。

2.2.10 link(x, y)

先 makeRoot(x),如果 findRoot(y) != x,则把 parent[x] = y,即连一条虚边。

2.2.11 cut(x, y)

makeRoot(x); access(y); splay(y)。如果 left[y] == x && right[x] == null,说明它们在同一条实路径上且中间没有其他点,此时 parent[x] = null; left[y] = null; pushUp(y)。

2.3 复杂度分析

每个 access 都会沿着路径把所有子路径变成实路径;总次数均摊后每个虚边只会被切换有限次,因此所有操作均摊为 O(log n)。整个 LCT 的时间复杂度是 均摊 O(log n),空间复杂度是 O(n)。

三、Java 实现

下面给出一个支持”边权求和 + 边权修改 + 连通性判断 + 动态加边/删边”的完整 LCT 实现。我们将结点权值(这里直接用结点权值,便于后续扩展为边权:通过把边权下放到子结点即可)。

import java.util.Scanner;

/**
 * Link-Cut Tree(LCT)—— 动态森林维护 + 路径聚合
 *
 * 支持:
 *  1. 连通性判断:findRoot(x) == findRoot(y)
 *  2. 路径求和 / 最大值
 *  3. 路径翻转:makeRoot
 *  4. 动态加边 link(x, y)
 *  5. 动态删边 cut(x, y)
 *
 * 实现要点:
 *  - 底层使用 splay 树
 *  - 虚实边分离:实路径用一棵 splay 树维护,不同实路径之间靠虚边连接
 *  - access(x) 是核心操作,把 x 到根的路径变成实路径
 *
 * 复杂度:均摊 O(log n) 每操作
 */
public class LinkCutTree {

    /** 结点数量(1-indexed) */
    private final int n;

    /** 左子结点 */
    private final int[] ch = new int[4 * 100005 * 2];
    /** 右子结点 */
    private final int[] right = new int[ch.length];
    /** 父结点 */
    private final int[] parent = new int[ch.length];
    /** 翻转懒标记 */
    private final boolean[] reverse = new boolean[ch.length];

    /** 结点权值 */
    private final int[] value = new int[ch.length];
    /** 子树权值和 */
    private final long[] sum = new long[ch.length];
    /** 子树最大权值 */
    private final int[] max = new int[ch.length];

    public LinkCutTree(int n) {
        this.n = n;
        for (int i = 1; i <= n; i++) {
            value[i] = 0;
            sum[i] = 0;
            max[i] = Integer.MIN_VALUE;
        }
    }

    /** 判断 x 是否是所在 splay 树的根 */
    private boolean isRoot(int x) {
        int p = parent[x];
        return (ch[p] != x) && (right[p] != x);
    }

    /** 维护 x 的聚合信息 */
    private void pushUp(int x) {
        sum[x] = sum[ch[x]] + sum[right[x]] + value[x];
        max[x] = value[x];
        if (ch[x] != 0) max[x] = Math.max(max[x], max[ch[x]]);
        if (right[x] != 0) max[x] = Math.max(max[x], max[right[x]]);
    }

    /** 下推翻转懒标记 */
    private void pushDown(int x) {
        if (reverse[x]) {
            int l = ch[x], r = right[x];
            // 交换左右子树
            int tmp = ch[x]; ch[x] = right[x]; right[x] = tmp;
            if (l != 0) reverse[l] = !reverse[l];
            if (r != 0) reverse[r] = !reverse[r];
            reverse[x] = false;
        }
    }

    /** 准备旋转:将 x 到根路径上的懒标记全部下推 */
    private void pushDownPath(int x) {
        if (!isRoot(x)) pushDownPath(parent[x]);
        pushDown(x);
    }

    /** splay 旋转 */
    private void rotate(int x) {
        int p = parent[x];
        int g = parent[p];
        // 子结点身份
        if (!isRoot(p)) {
            if (ch[g] == p) ch[g] = x;
            else if (right[g] == p) right[g] = x;
        }
        parent[x] = g;

        // 旋转方向:左旋还是右旋
        if (ch[p] == x) {
            // 右旋
            int b = right[x];
            right[x] = p;
            parent[p] = x;
            ch[p] = b;
            if (b != 0) parent[b] = p;
        } else {
            // 左旋
            int b = ch[x];
            ch[x] = p;
            parent[p] = x;
            right[p] = b;
            if (b != 0) parent[b] = p;
        }
        pushUp(p);
        pushUp(x);
    }

    /** 把 x 提升到所在 splay 树的根 */
    private void splay(int x) {
        pushDownPath(x);
        while (!isRoot(x)) {
            int p = parent[x];
            if (!isRoot(p)) {
                int g = parent[p];
                // zig-zig 或 zig-zag
                if ((ch[g] == p) ^ (ch[p] == x)) {
                    rotate(x);
                } else {
                    rotate(p);
                }
            }
            rotate(x);
        }
    }

    /** access(x):把 x 到根的路径变成实路径 */
    public void access(int x) {
        int last = 0;
        for (int y = x; y != 0; y = parent[y]) {
            splay(y);
            right[y] = last;
            pushUp(y);
            last = y;
        }
    }

    /** makeRoot(x):把 x 设为所在树的根 */
    public void makeRoot(int x) {
        access(x);
        splay(x);
        reverse[x] = !reverse[x];
    }

    /** 找到 x 所在树的根(最左结点) */
    public int findRoot(int x) {
        access(x);
        splay(x);
        pushDown(x);
        while (ch[x] != 0) {
            x = ch[x];
            pushDown(x);
        }
        splay(x);
        return x;
    }

    /** split(x, y):把 x 到 y 的路径变成实路径 */
    public void split(int x, int y) {
        makeRoot(x);
        access(y);
        splay(y);
    }

    /** 路径 x -> y 的元素之和 */
    public long pathSum(int x, int y) {
        split(x, y);
        return sum[y];
    }

    /** 路径 x -> y 的最大值 */
    public int pathMax(int x, int y) {
        split(x, y);
        return max[y];
    }

    /** 修改结点 x 的权值为 v */
    public void update(int x, int v) {
        access(x);
        splay(x);
        value[x] = v;
        pushUp(x);
    }

    /** 连接边 (x, y):先 makeRoot(x),再判断是否连通,最后连虚边 */
    public void link(int x, int y) {
        makeRoot(x);
        if (findRoot(y) != x) {
            parent[x] = y;
        }
    }

    /** 删除边 (x, y) */
    public void cut(int x, int y) {
        makeRoot(x);
        access(y);
        splay(y);
        // 检查是否直接相邻:x 是 y 的最左孩子,且 x 没有右孩子
        if (ch[y] == x && right[x] == 0) {
            ch[y] = 0;
            parent[x] = 0;
            pushUp(y);
        }
    }

    /** 判断 x 与 y 是否在同一棵树中 */
    public boolean connected(int x, int y) {
        return findRoot(x) == findRoot(y);
    }

    // ================== 演示入口 ==================
    public static void main(String[] args) {
        LinkCutTree lct = new LinkCutTree(10);

        // 初始化结点权值
        for (int i = 1; i <= 10; i++) {
            lct.update(i, i);
        }

        // 连成一棵树
        lct.link(1, 2);
        lct.link(2, 3);
        lct.link(3, 4);
        lct.link(2, 5);
        lct.link(5, 6);

        // 查询 1 -> 6 路径和
        System.out.println("路径 1->6 的和:" + lct.pathSum(1, 6)); // 1+2+5+6=14
        // 修改结点 5 的值为 100
        lct.update(5, 100);
        System.out.println("修改后路径 1->6 的和:" + lct.pathSum(1, 6)); // 1+2+100+6=109

        // 删除边 (2,5)
        lct.cut(2, 5);
        System.out.println("删除边 2-5 后 1 与 6 是否连通:" + lct.connected(1, 6)); // false
        System.out.println("删除边 2-5 后 5 与 6 是否连通:" + lct.connected(5, 6)); // true
    }
}

四、复杂度分析

4.1 时间复杂度

  • access(x):每次会把一条实路径切换为虚路径;切换次数均摊后总代价为 O(log n)。
  • makeRoot / findRoot / split:每次只调用常数次 access,因此也是 O(log n)。
  • link / cut:调用 makeRoot + 判连通 + 修改虚边,因此 O(log n)。
  • pathSum / pathMax / pathMin:先 split 再读根信息,O(log n)。
  • update:access + splay 后修改值,O(log n)。

整体所有操作都是 均摊 O(log n)。

4.2 空间复杂度

每个结点固定大小的数组(约 6 个 int + 1 个 boolean + 1 个 long),所以 O(n)。对于 n = 1e5 的规模,内存占用约为 1-2 MB,是非常经济的。

4.3 注意点

  • LCT 是 均摊 O(log n),不是严格 O(log n);在最坏情况下可能退化为 O(n);
  • 必须保证父结点引用在 splay 时被正确维护,否则会破坏森林结构;
  • 路径聚合一定要先 split,否则会读到错误的子树。

五、应用场景

5.1 动态最小生成树

经典的”动态 MST”问题:维护一个带权的森林,支持加边(更新 MST)、删边(重新求解)。可以用 LCT 直接维护 MST 上的最大边权 + 替换,复杂度 O(log n) 每操作。

5.2 网络连通性

动态网络(路由器增删)中查询”两个终端是否仍然连通”,或”网络最大瓶颈”等。LCT 的 findRoot 与 pathMax 组合直接给出答案。

5.3 动态树剖

在某些题目中,我们需要对 子树 进行操作(如子树求和)。LCT 本身不支持”以父为根的子树聚合”,但可以通过 Euler Tour Tree(用 LCT 维护欧拉序)来实现。这个套路在处理”动态树 + 子树修改”问题时非常常见。

5.4 替代品与权衡

  • Euler Tour Tree(ETT):可以处理子树操作,但不支持路径反转;
  • Top Tree:比 LCT 更强大,支持子树 + 路径双重操作,但实现复杂;
  • Heavy-Light Decomposition + Segment Tree:仅支持静态树,但代码简单常数小。

在实际竞赛中,能用 HLF 就不用 LCT,因为 LCT 的常数非常大;但只要遇到动态操作,LCT 是首选。

六、总结

Link-Cut Tree 是处理动态森林连通性与路径维护问题的强大工具。它建立在 splay 树之上,借助”实路径/虚边”的精巧设计将森林中的任意路径转换为可操作的 splay 树。理解 LCT 的关键是掌握 access + makeRoot + splay 这三个核心操作的几何含义。在竞赛中,凡是遇到”动态加边/删边 + 路径聚合”的问题,都应该首先想到 LCT。

掌握 LCT 需要的不仅是”代码背过”,更是对虚实边切换的”画面感”——这需要大量的题目训练。本文给的代码是一个最小可运行版本,建议读者在此基础上扩展出区间修改、点权下放等更复杂的功能,以应对更高难度的题目。