点分治 (Centroid Decomposition)
点分治是一种把树递归拆成"规模至少减半"的若干子树的分治方法。它构建出一棵新的树,通常称为重心分解树。
在 Codeforces 321C 里,我们要做的事情非常纯粹:构建重心分解树,并把每个点在分解树中的深度映射成字母。
更多例题与进阶方向放在文末。
一、重心是什么
对当前连通块(只考虑尚未被删除的点)选择一个点 c,满足删掉 c 之后,所有剩余连通块的大小都不超过当前块大小的一半。
这个 c 称为重心。
- 重心一定存在:令当前连通块大小为
S。对块内任意点u,定义f(u)为删掉u后所有连通块大小的最大值。若f(u) > S/2,则存在某个邻居v使得删掉u后包含v的那块大小等于f(u);向v移动。对这个v有f(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 所在的连通块做分解:
- 取出连通块点集:从
entry出发 DFS/BFS,只走未被blocked的点,得到本块所有点,以及本块大小S。 - 计算子树大小
sub[u]:在这棵"被限制后的树"上任选一个根(常用entry),按父子关系把本块看成一棵根树,再自底向上累加得到每个点的sub[u]。 - 找重心
c:对本块内每个点u,它被删掉后最大的那块大小是max( S - sub[u], max(sub[v]) ),其中v枚举u的子节点。取使这个最大值最小的点(等价条件是maxPart * 2 <= S)。 - 标记并赋值:
blocked[c] = true,并把c的答案设为当前层字母。 - 递归:对
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-22-33-44-53-66-7
4.1 第 0 层
当前块大小 7。
- 选重心
3 ans[3] = A
删掉 3 后变成三个块:{1,2}、{4,5}、{6,7}。
4.2 第 1 层
对每个大小为 2 的块选重心并标 B。
{1,2}:重心可以是1或2{4,5}:重心可以是4或5{6,7}:重心可以是6或7
选到哪个都合法。
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,使得删掉 c 后 r 与 x 落在不同连通块里,因此原树上从 x 到 r 的路径必经过 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" 的点对贡献。
- 若
u与v在删掉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]的数量
- I:新增配对数为
- 再把
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:距离阈值统计点对,点分治做"经过重心"的计数合并。
练习顺序通常是:342E → CSES Fixed Length Paths → IOI 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(把重心当作一侧端点)。
按子树依次处理:
- 收集当前子树内所有
(dw, de)(只保留dw <= k)。 - 先用这些点查询答案:对每个
(dw, de),令need = k - dw,若best[need]存在,则候选答案为de + best[need]。 - 再把当前子树的点合并进
best:best[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);
}
}
}
}