点分治 (Centroid Decomposition)

点分治 (Centroid Decomposition)

点分治是一种把树递归拆成"规模至少减半"的若干子树的分治方法。它构建出一棵新的树,通常称为重心分解树。

在 Codeforces 321C 里,我们要做的事情非常纯粹:构建重心分解树,并把每个点在分解树中的深度映射成字母。

更多例题与进阶方向放在文末。


一、重心是什么

对当前连通块(只考虑尚未被删除的点)选择一个点 c,满足删掉 c 之后,所有剩余连通块的大小都不超过当前块大小的一半。

这个 c 称为重心。

  • 重心一定存在:令当前连通块大小为 S。对块内任意点 u,定义 f(u) 为删掉 u 后所有连通块大小的最大值。若 f(u) > S/2,则存在某个邻居 v 使得删掉 u 后包含 v 的那块大小等于 f(u);向 v 移动。对这个 vf(v) < f(u)
    • v 回到 u 的那一侧大小是 S - f(u),严格小于 f(u)
    • 其它方向的连通块都包含在那块大小为 f(u) 的子块里,因此也严格小于 f(u)
      因为 f(u) 是正整数且严格下降,过程必然停止在某个点 c。停止时 f(c) <= S/2,即 c 是重心。
  • 如果当前块大小为 S,删掉重心后每个子块大小都 <= S/2,因此任意点在分解树中的深度是 O(log n)

二、点分治的过程(How)

点分治每一层处理的是一个"当前连通块",它由某个入口点 entry 标识,并且只包含 blocked[u] = false 的点。

entry 所在的连通块做分解:

  1. 取出连通块点集:从 entry 出发 DFS/BFS,只走未被 blocked 的点,得到本块所有点,以及本块大小 S
  2. 计算子树大小 sub[u]:在这棵"被限制后的树"上任选一个根(常用 entry),按父子关系把本块看成一棵根树,再自底向上累加得到每个点的 sub[u]
  3. 找重心 c:对本块内每个点 u,它被删掉后最大的那块大小是 max( S - sub[u], max(sub[v]) ),其中 v 枚举 u 的子节点。取使这个最大值最小的点(等价条件是 maxPart * 2 <= S)。
  4. 标记并赋值:blocked[c] = true,并把 c 的答案设为当前层字母。
  5. 递归:对 c 的每个未被 blocked 的邻居 v,它们分别对应删掉 c 后的一个连通块,从 v 作为入口递归进入下一层。

这里的 blocked 就是"把重心从树上删掉"的实现方式:后续遍历直接跳过 blocked 的点。

2.1 分解把树变成什么形状

点分治最终会得到一棵新的树:重心分解树(centroid decomposition tree)。

  • 节点集合不变:重心分解树的节点仍然是原树的 1..n
  • 边的含义改变:如果某次分解选到重心 c,删掉 c 后得到若干连通块;每个连通块继续递归会选出一个重心 c_child,在重心分解树里连一条边 c - c_child,并把 c 当作父亲。
  • 根是谁:整棵树第一次选出的重心就是重心分解树的根。

每个节点都会在某一层递归中成为某个连通块的重心一次。

  • 因为一旦某个点被选为重心,就会被 blocked 标记,此后不会再出现在任何子问题里。
  • 递归最终会把每个连通块分解到大小为 1 的情况,单点块的唯一点必然是它自己的重心。
  • 但这不意味着每个点都是"整棵树"的重心;它只是它所处那个子问题的重心。

三、复杂度怎么推

如果对大小为 S 的连通块,我们做一次 O(S) 的工作(收集点集、算 sub、找重心、做常数级标记),然后递归到若干个子块 s1, s2, ...,则满足:

  • s1 + s2 + ... = S - 1
  • 每个 si <= S/2

一种直观推导是按层计费:

  • 在点分治的同一层,所有连通块两两不交,块大小之和不超过 n,因此该层总工作量是 O(n)
  • 任意一个点,每下沉一层,它所在块的大小至少减半,因此它最多参与 O(log n) 层。

所以总复杂度是 O(n log n)。在 321C 里,除了构建本身没有额外统计,最终就是 O(n log n) 时间与 O(n) 额外空间(不含邻接表)。


四、321C 的对应关系

题目让你给每个点输出一个字母。

  • 第 0 层重心标 A
  • 删掉这些 A 后,对每个剩余连通块求重心并标 B
  • 继续递归,层数加一,字母加一

等价地说:

  • 建立重心分解树
  • ans[u] = 'A' + depth(u),其中 depth(u)u 在重心分解树中的深度

五、手推一个例子

取 7 个点的树:

  • 1-2
  • 2-3
  • 3-4
  • 4-5
  • 3-6
  • 6-7

4.1 第 0 层

当前块大小 7。

  • 选重心 3
  • ans[3] = A

删掉 3 后变成三个块:{1,2}{4,5}{6,7}

4.2 第 1 层

对每个大小为 2 的块选重心并标 B

  • {1,2}:重心可以是 12
  • {4,5}:重心可以是 45
  • {6,7}:重心可以是 67

选到哪个都合法。

4.3 第 2 层

每个剩下的单点块标 C


六、Java 实现(理解版,力扣格式)

点分治递归深度是 O(log n),这部分一般不会很深;下面的实现用递归写法更贴近算法描述,适合对照理解。

java 复制代码
import java.util.*;

class Solution {
    private List<Integer>[] g;
    private boolean[] blocked;
    private int[] sub;
    private char[] ans;

    public char[] centroidLabels(int n, int[][] edges) {
        g = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] e : edges) {
            int u = e[0], v = e[1];
            g[u].add(v);
            g[v].add(u);
        }

        blocked = new boolean[n + 1];
        sub = new int[n + 1];
        ans = new char[n + 1];

        decompose(1, 0);
        return Arrays.copyOfRange(ans, 1, n + 1);
    }

    private void dfsSize(int u, int p) {
        sub[u] = 1;
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            dfsSize(v, u);
            sub[u] += sub[v];
        }
    }

    private int dfsCentroid(int u, int p, int total) {
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            if (sub[v] > total / 2) {
                return dfsCentroid(v, u, total);
            }
        }
        return u;
    }

    private void decompose(int entry, int depth) {
        dfsSize(entry, 0);
        int c = dfsCentroid(entry, 0, sub[entry]);

        ans[c] = (char) ('A' + depth);
        blocked[c] = true;

        for (int v : g[c]) {
            if (!blocked[v]) {
                decompose(v, depth + 1);
            }
        }
    }
}

七、例题:Codeforces 342E 动态最近红点

7.1 题目描述

给一棵树,初始只有点 1 是红点。两类操作:

  • 1 v:把点 v 染红(重复染红无影响)
  • 2 v:查询点 v 到最近红点的距离

7.2 关键思路

把"最近红点"拆到重心分解祖先链上做最小值。

  • 对每个重心 c 维护 best[c],表示当前所有红点到 c 的最小距离
  • 对任意点 x,它到最近红点的答案可以写成:

min over centroid ancestors c of x: best[c] + dist(x, c)

其中 "centroid ancestors" 指 x 在重心分解树上一路往上的重心。

为什么只需要枚举这些重心:对任意红点 r 和查询点 x,在点分治过程中存在一个"最浅层的重心" c,使得删掉 crx 落在不同连通块里,因此原树上从 xr 的路径必经过 c,并且满足 dist(x, r) = dist(x, c) + dist(r, c)。于是

  • 更新 paint(r) 只需要沿 r 的重心祖先链,把 dist(r, c) 合并进 best[c]
  • 查询 query(x) 只需要沿 x 的重心祖先链,枚举所有可能的"路径分割点" c,取 best[c] + dist(x, c) 的最小值

7.3 预处理与操作

预处理时,构建点分治,并为每个点 u 保存一条链 path[u]

  • path[u] 是若干对 (c, d),表示 u 到某个分解祖先重心 c 的距离 d

操作:

  • 染红 paint(u):对 path[u] 里的每个 (c, d),做 best[c] = min(best[c], d)
  • 查询 query(u):对 path[u] 里的每个 (c, d),取 min(best[c] + d)

7.4 Java 实现(理解版,力扣格式)

java 复制代码
import java.util.*;

class NearestRedTree {
    private static final int INF = 1_000_000_000;

    private final List<Integer>[] g;
    private final boolean[] blocked;
    private final int[] sub;

    private final List<int[]>[] path;
    private final int[] best;

    NearestRedTree(int n, int[][] edges) {
        g = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] e : edges) {
            int u = e[0], v = e[1];
            g[u].add(v);
            g[v].add(u);
        }

        blocked = new boolean[n + 1];
        sub = new int[n + 1];

        path = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            path[i] = new ArrayList<>();
        }

        best = new int[n + 1];
        Arrays.fill(best, INF);

        decompose(1);
        paint(1);
    }

    void paint(int u) {
        for (int[] cd : path[u]) {
            int c = cd[0];
            int d = cd[1];
            if (d < best[c]) {
                best[c] = d;
            }
        }
    }

    int query(int u) {
        int ans = INF;
        for (int[] cd : path[u]) {
            int c = cd[0];
            int d = cd[1];
            int cand = best[c] + d;
            if (cand < ans) {
                ans = cand;
            }
        }
        return ans;
    }

    private void dfsSize(int u, int p) {
        sub[u] = 1;
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            dfsSize(v, u);
            sub[u] += sub[v];
        }
    }

    private int dfsCentroid(int u, int p, int total) {
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            if (sub[v] > total / 2) {
                return dfsCentroid(v, u, total);
            }
        }
        return u;
    }

    private void dfsAddPath(int u, int p, int dist, int centroid) {
        path[u].add(new int[]{centroid, dist});
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            dfsAddPath(v, u, dist + 1, centroid);
        }
    }

    private void decompose(int entry) {
        dfsSize(entry, 0);
        int c = dfsCentroid(entry, 0, sub[entry]);

        dfsAddPath(c, 0, 0, c);
        blocked[c] = true;

        for (int v : g[c]) {
            if (!blocked[v]) {
                decompose(v);
            }
        }
    }
}

八、例题:CSES Fixed-Length Paths I/II(抽象模板)

这两题本质相同:统计树上点对 (u, v),使得路径长度 dist(u, v) 落在某个条件里。

  • I:dist(u, v) = k
  • II:a <= dist(u, v) <= b

8.1 重心处的"合并计数"视角

固定重心 c,只统计"路径经过 c" 的点对贡献。

  • uv 在删掉 c 后落在不同子树(或其中一个就是 c),则 dist(u, v) = dist(u, c) + dist(v, c)
  • 因此在处理 c 时,只需要知道每个点到 c 的距离。

做法:依次处理 c 的每棵子树 T

  • 收集该子树内所有点到 c 的距离列表 D(T)
  • 维护一个"已处理过的子树"距离频次结构 freq
  • 对于 D(T) 里的每个距离 d
    • I:新增配对数为 freq[k - d]
    • II:新增配对数为 freq 中距离落在 [a - d, b - d] 的数量
  • 再把 D(T) 的所有距离加入 freq,继续处理下一棵子树。

初始化 freq[0] = 1 表示把重心 c 自己纳入"已处理集合"。

8.2 为什么不会重复计数

  • 在某个重心 c 处,只会统计跨不同子树的点对,因此不会把同一子树内部的路径算进来。
  • 对于任意点对 (u, v),存在唯一的"最浅分割重心"使得它们第一次落在不同子块里,点对只会在那个重心处被统计一次。

8.3 统一的实现方式

  • I 只需要数组 freq[len] 直接访问。
  • II 需要区间计数:可以用 Fenwick(BIT)对 freq 做前缀和,支持 sum(r) - sum(l-1)

下面只给出 I(dist(u, v) = k)的实现,保持最短路径计数的核心逻辑清晰。

java 复制代码
import java.util.*;

class FixedLengthPathsI {
    private List<Integer>[] g;
    private boolean[] blocked;
    private int[] sub;

    private int k;
    private long answer;

    private int[] distBuf;
    private int distBufSize;

    private int[] freq;
    private int[] used;
    private int usedSize;

    public long countPairsExactK(int n, int[][] edges, int k) {
        this.k = k;

        g = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] e : edges) {
            int u = e[0], v = e[1];
            g[u].add(v);
            g[v].add(u);
        }

        blocked = new boolean[n + 1];
        sub = new int[n + 1];

        distBuf = new int[n + 1];

        freq = new int[k + 1];
        used = new int[k + 1];

        answer = 0;
        decompose(1);
        return answer;
    }

    private void dfsSize(int u, int p) {
        sub[u] = 1;
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            dfsSize(v, u);
            sub[u] += sub[v];
        }
    }

    private int dfsCentroid(int u, int p, int total) {
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            if (sub[v] > total / 2) {
                return dfsCentroid(v, u, total);
            }
        }
        return u;
    }

    private void dfsCollect(int u, int p, int d) {
        if (d > k) {
            return;
        }
        distBuf[distBufSize++] = d;
        for (int v : g[u]) {
            if (v == p || blocked[v]) {
                continue;
            }
            dfsCollect(v, u, d + 1);
        }
    }

    private void addFreq(int d) {
        if (freq[d] == 0) {
            used[usedSize++] = d;
        }
        freq[d]++;
    }

    private void clearFreq() {
        for (int i = 0; i < usedSize; i++) {
            freq[used[i]] = 0;
        }
        usedSize = 0;
    }

    private void processCentroid(int c) {
        clearFreq();
        addFreq(0);

        for (int v : g[c]) {
            if (blocked[v]) {
                continue;
            }

            distBufSize = 0;
            dfsCollect(v, c, 1);

            for (int i = 0; i < distBufSize; i++) {
                int d = distBuf[i];
                int need = k - d;
                if (need >= 0) {
                    answer += freq[need];
                }
            }

            for (int i = 0; i < distBufSize; i++) {
                addFreq(distBuf[i]);
            }
        }

        clearFreq();
    }

    private void decompose(int entry) {
        dfsSize(entry, 0);
        int c = dfsCentroid(entry, 0, sub[entry]);

        blocked[c] = true;
        processCentroid(c);

        for (int v : g[c]) {
            if (!blocked[v]) {
                decompose(v);
            }
        }
    }
}

九、更多经典例题

  • Codeforces 342E Xenia and Tree:动态染色 + 最近点查询,点分治最经典模板。
  • SPOJ QTREE5:与 342E 同类,但操作更像模板综合练习。
  • IOI 2011 Race:边带权,求权值和为 k 的最短边数路径,点分治做最优值合并。
  • Codeforces 293E Close Vertices:距离阈值统计点对,点分治做"经过重心"的计数合并。

练习顺序通常是:342ECSES Fixed Length PathsIOI 2011 Race


十、例题:IOI 2011 Race

10.1 题目描述

给一棵 n 个点的树,每条边有正权。给定 k,求一条简单路径,使得路径权值和恰好等于 k,并让路径边数最小;若不存在输出 -1

10.2 重心处的合并思路

在重心 c 处,只统计"路径经过 c" 的答案。

对任意点 u,记:

  • dw(u) = distWeight(u, c)
  • de(u) = distEdges(u, c)

若路径 (u, v) 经过 c,则满足:

  • distWeight(u, v) = dw(u) + dw(v)
  • distEdges(u, v) = de(u) + de(v)

因此处理 c 时,维护一个数组 best[sum]

  • best[s] 表示在已处理过的子树里,存在某个点到 c 的权值距离为 s,并且其 de 的最小值

初始化 best[0] = 0(把重心当作一侧端点)。

按子树依次处理:

  1. 收集当前子树内所有 (dw, de)(只保留 dw <= k)。
  2. 先用这些点查询答案:对每个 (dw, de),令 need = k - dw,若 best[need] 存在,则候选答案为 de + best[need]
  3. 再把当前子树的点合并进 bestbest[dw] = min(best[dw], de)

"先查后并"是为了避免把同一子树内部的两点错误地在重心处配对。

10.3 Java 实现(理解版,力扣格式)

java 复制代码
import java.util.*;

class Race2011Solver {
    private static final int INF = 1_000_000_000;

    private List<int[]>[] g;
    private boolean[] blocked;
    private int[] sub;

    private int k;
    private int answer;

    private int[] best;
    private int[] touched;
    private int touchedSize;

    public int minEdgesWithSumK(int n, int[][] edges, int k) {
        this.k = k;

        g = new ArrayList[n + 1];
        for (int i = 1; i <= n; i++) {
            g[i] = new ArrayList<>();
        }
        for (int[] e : edges) {
            int u = e[0], v = e[1], w = e[2];
            g[u].add(new int[]{v, w});
            g[v].add(new int[]{u, w});
        }

        blocked = new boolean[n + 1];
        sub = new int[n + 1];

        best = new int[Math.max(0, k) + 1];
        Arrays.fill(best, INF);
        touched = new int[best.length];

        answer = INF;
        decompose(1);
        return answer == INF ? -1 : answer;
    }

    private void dfsSize(int u, int p) {
        sub[u] = 1;
        for (int[] e : g[u]) {
            int v = e[0];
            if (v == p || blocked[v]) {
                continue;
            }
            dfsSize(v, u);
            sub[u] += sub[v];
        }
    }

    private int dfsCentroid(int u, int p, int total) {
        for (int[] e : g[u]) {
            int v = e[0];
            if (v == p || blocked[v]) {
                continue;
            }
            if (sub[v] > total / 2) {
                return dfsCentroid(v, u, total);
            }
        }
        return u;
    }

    private void collect(int u, int p, int distW, int distE, List<int[]> out) {
        if (distW > k) {
            return;
        }
        out.add(new int[]{distW, distE});
        for (int[] e : g[u]) {
            int v = e[0];
            int w = e[1];
            if (v == p || blocked[v]) {
                continue;
            }
            collect(v, u, distW + w, distE + 1, out);
        }
    }

    private void touchMin(int dist, int edges) {
        if (dist < 0 || dist > k) {
            return;
        }
        if (best[dist] == INF) {
            touched[touchedSize++] = dist;
        }
        if (edges < best[dist]) {
            best[dist] = edges;
        }
    }

    private void clearTouched() {
        for (int i = 0; i < touchedSize; i++) {
            best[touched[i]] = INF;
        }
        touchedSize = 0;
    }

    private void processCentroid(int c) {
        clearTouched();
        touchMin(0, 0);

        for (int[] e0 : g[c]) {
            int v = e0[0];
            int w0 = e0[1];
            if (blocked[v]) {
                continue;
            }

            List<int[]> list = new ArrayList<>();
            collect(v, c, w0, 1, list);

            for (int[] p : list) {
                int dw = p[0];
                int de = p[1];
                int need = k - dw;
                if (need >= 0 && best[need] != INF) {
                    answer = Math.min(answer, de + best[need]);
                }
            }

            for (int[] p : list) {
                touchMin(p[0], p[1]);
            }
        }

        clearTouched();
    }

    private void decompose(int entry) {
        dfsSize(entry, 0);
        int c = dfsCentroid(entry, 0, sub[entry]);

        blocked[c] = true;
        processCentroid(c);

        for (int[] e : g[c]) {
            int v = e[0];
            if (!blocked[v]) {
                decompose(v);
            }
        }
    }
}
相关推荐
追随者永远是胜利者1 小时前
(LeetCode-Hot100)42. 接雨水
java·算法·leetcode·职场和发展·go
Hx_Ma163 小时前
测试题(三)
java·开发语言·后端
田里的水稻3 小时前
FA_规划和控制(PC)-瑞德斯.谢普路径规划(RSPP))
人工智能·算法·数学建模·机器人·自动驾驶
罗湖老棍子3 小时前
【例 1】二叉苹果树(信息学奥赛一本通- P1575)
算法·树上背包·树型动态规划
元亓亓亓4 小时前
LeetCode热题100--76. 最小覆盖子串--困难
算法·leetcode·职场和发展
CHANG_THE_WORLD4 小时前
C++数组地址传递与数据影响:深入理解指针与内存
算法
json{shen:"jing"}4 小时前
力扣-单词拆分
数据结构·算法
星火开发设计4 小时前
序列式容器:deque 双端队列的适用场景
java·开发语言·jvm·c++·知识
aaa7874 小时前
Codeforces Round 1080 (Div. 3) 题解
数据结构·算法