树的重心
树的重心把删点后的最大连通块压到最小,可以用一次 DFS 统计子树大小在线性时间内求出。
一句话算法
删掉一个点后,看剩下的最大连通块有多大;让这个最大块尽量小的点就是树的重心。
问题模型
给定一棵有
记这些连通块中的最大点数为
树的重心
树的重心就是使
等价地,如果删除点
从子树大小得到判定式
重心的目标是点数平衡:删除这个点后,任何一边都不能超过一半。
任选点 root 作为根。设 sz[u] 表示以 u 为根的子树大小。
删除 u 后会产生两类连通块:
- 每个儿子
v对应一块,大小是sz[v]; u的父亲方向对应一块,大小是n - sz[u]。
因此:
判断 u 是不是重心,只需检查
只要一次 DFS 求出所有子树大小,就能顺便计算每个点的
不要漏掉父亲方向
sz[v] 只覆盖 u 的儿子方向。删除 u 后,父亲方向还有 n - sz[u] 个点,这是求重心时最常漏掉的一块。
算法步骤
- 任选一个点作为根,例如
1。 - DFS 进入点
u,令sz[u] = 1。 - 递归处理每个儿子
v,把sz[v]加到sz[u]。 - 用所有儿子子树大小和
n - sz[u]计算mx。 - 维护最小的
mx,记录所有取得最小值的点。
算法证明
sz[u]的正确性:叶子显然成立;递归返回后,u的子树恰好由u自己与所有儿子的子树组成,归纳可得sz[u]就是u的子树大小。- 判定没有遗漏:删除
u后,连通块恰好是各儿子的子树和父亲方向,这些部分已经全部进入的公式。因此算法计算出的 mx就是,扫描所有点取最小值,必然得到全部重心。 - 两个定义等价:
最小当且仅当 。 -
先证"最小
":反设最小点 有 ,即删除 后有一块 的大小为 。取 中与 相邻的点 : 朝 一侧有 个点, 的其他块都在 内、每块都小于 ,所以 ,与 是最小点矛盾。 -
再证"
最小":反设存在 使 ,则 。 取路径
。对路径上的每条边 ,删掉它后树分成两块,记含 的那块的大小为 ( )。观察 的三个性质: :删掉 后含 的块,恰好也是删掉 后含 的块,它是删 后的一块,大小 。 :删掉 后含 的块 整棵树 含 的那块。含 的那块是删 后的一块,大小 ,故 (含 的块) 。 严格递减: 对应的块 对应的块 自己 ( 挂在路径外的分支),所以 。
但性质 3 给出
,与性质 1、2 的 矛盾。
-
复杂度分析
- 时间复杂度:
。每个点和每条边只处理常数次。 - 空间复杂度:
。邻接表、子树大小和递归栈都占线性空间。
代码模板
模板返回全部重心,并按编号升序排列。best 是删除重心后最大连通块的大小。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <algorithm>
#include <vector>
using Graph = std::vector<std::vector<int>>;
Graph tree; // 全局邻接表:使用前先 resize(n+1) 并加边
// 如何使用:
// 1. tree.resize(n + 1),读入边:tree[u].push_back(v); tree[v].push_back(u);
// 2. TreeCentroid tc(n);
// 3. vector<int> cs = tc.find_centroids(); // 返回所有重心,编号升序
// 求树的所有重心。
// 重心:删除该点后,剩下的每个连通块大小都不超过 n/2。
struct TreeCentroid {
int n;
std::vector<int> sz; // sz[u] = u 的子树大小
std::vector<int> ans; // 答案:所有重心,按编号升序
int best; // 最小的 B(u):删除 u 后最大的连通块大小
explicit TreeCentroid(int n) : n(n), best(n), sz(n + 1) {}
// 返回所有重心(编号升序)
std::vector<int> find_centroids(int root = 1) {
best = n;
ans.clear();
dfs(root, 0);
std::sort(ans.begin(), ans.end());
return ans;
}
// 统计子树大小,同时计算每个点的 B(u) = 删除 u 后最大的连通块大小
void dfs(int u, int parent) {
// 如果邻接表不叫 tree,把循环里的 tree 替换成你的变量名
sz[u] = 1;
int mx = 0; // B(u):先看各儿子子树
for (int v : tree[u]) {
if (v == parent) continue;
dfs(v, u);
sz[u] += sz[v];
mx = std::max(mx, sz[v]);
}
// 父亲方向也是一块:整棵树减去 u 的子树
mx = std::max(mx, n - sz[u]);
// 记录 B(u) 最小的点(可能不止一个)
if (mx < best) {
best = mx;
ans = {u};
} else if (mx == best) {
ans.push_back(u);
}
}
};
另一种写法:不维护全局最小值,直接用等价判定 B(u) ≤ n/2 收集重心,代码更短。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <algorithm>
#include <vector>
using Graph = std::vector<std::vector<int>>;
Graph tree; // 全局邻接表:使用前先 resize(n+1) 并加边
// 如何使用:
// 1. tree.resize(n + 1),读入边:tree[u].push_back(v); tree[v].push_back(u);
// 2. TreeCentroid2 tc(n);
// 3. vector<int> cs = tc.find_centroids(); // 返回所有重心,编号升序
// 求树的所有重心。
// 重心:删除该点后,剩下的每个连通块大小都不超过 n/2。
// 与 tree_centroid.cpp 不同:不维护全局最小值,直接按"最大连通块 ≤ n/2"判定重心。
struct TreeCentroid2 {
int n;
std::vector<int> sz; // sz[u] = u 的子树大小
std::vector<int> ans; // 答案:所有重心,按编号升序
explicit TreeCentroid2(int n) : n(n), sz(n + 1) {}
// 返回所有重心(编号升序)
std::vector<int> find_centroids(int root = 1) {
ans.clear();
dfs(root, 0);
std::sort(ans.begin(), ans.end());
return ans;
}
// 统计子树大小;若 B(u) = 删除 u 后最大的连通块大小 ≤ n/2,u 就是重心
void dfs(int u, int parent) {
// 如果邻接表不叫 tree,把循环里的 tree 替换成你的变量名
sz[u] = 1;
int mx = 0; // B(u):先看各儿子子树
for (int v : tree[u]) {
if (v == parent) continue;
dfs(v, u);
sz[u] += sz[v];
mx = std::max(mx, sz[v]);
}
// 父亲方向也是一块:整棵树减去 u 的子树
mx = std::max(mx, n - sz[u]);
// 等价判定:B(u) ≤ n/2 ⟺ u 是重心
if (mx <= n / 2) ans.push_back(u);
}
};
代码实现
输入一棵无权树,第一行输出重心个数,第二行按编号升序输出所有重心。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
using Graph = vector<vector<int>>;
Graph tree; // 全局邻接表:使用前先 resize(n+1) 并加边
// 求树的所有重心。
// 重心:删除该点后,剩下的每个连通块大小都不超过 n/2。
struct TreeCentroid {
int n;
vector<int> sz; // sz[u] = u 的子树大小
vector<int> ans; // 答案:所有重心,按编号升序
int best; // 最小的 B(u):删除 u 后最大的连通块大小
explicit TreeCentroid(int n) : n(n), best(n), sz(n + 1) {}
// 返回所有重心(编号升序)
vector<int> find_centroids(int root = 1) {
best = n;
ans.clear();
dfs(root, 0);
sort(ans.begin(), ans.end());
return ans;
}
// 统计子树大小,同时计算每个点的 B(u) = 删除 u 后最大的连通块大小
void dfs(int u, int parent) {
sz[u] = 1;
int mx = 0; // B(u):先看各儿子子树
for (int v : tree[u]) {
if (v == parent) continue;
dfs(v, u);
sz[u] += sz[v];
mx = max(mx, sz[v]);
}
// 父亲方向也是一块:整棵树减去 u 的子树
mx = max(mx, n - sz[u]);
// 记录 B(u) 最小的点(可能不止一个)
if (mx < best) {
best = mx;
ans = {u};
} else if (mx == best) {
ans.push_back(u);
}
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
tree.resize(n + 1);
for (int i = 1; i < n; ++i) {
int u, v;
cin >> u >> v;
tree[u].push_back(v);
tree[v].push_back(u);
}
TreeCentroid tc(n);
vector<int> answer = tc.find_centroids();
cout << answer.size() << '\n';
for (size_t i = 0; i < answer.size(); ++i) {
if (i > 0) cout << ' ';
cout << answer[i];
}
cout << '\n';
return 0;
}
测试用例
两个重心
输入:
8
1 2
1 3
2 4
2 5
3 6
6 7
6 8
输出:
2
1 3
删除 1 后,最大的部分是 {3,6,7,8};删除 3 后,最大的部分是 {1,2,4,5}。两块大小都是 1 和 3 都是重心。
1
/ \
2 3
/ \ \
4 5 6
/ \
7 8
单点树
输入:
1
输出:
1
1
重要性质
1. 树的重心不是树的中心
这两个名字接近,但优化目标不同。
| 概念 | 优化目标 | 常用求法 |
|---|---|---|
| 树的重心 | 删除该点后的最大连通块最小 | 子树大小 |
| 树的中心 | 到其他点的最大距离最小 | 直径中点 |
例如,一条长链的一端挂着许多叶子时,中心更在意最远距离,重心更在意两边各有多少个点,它们可能不是同一个点。
2. 重心一定存在
站在任意点 u 上,假设删除 u 后有一块包含了超过一半的点,那么树明显向这一块“偏重”,重心一定在这个方向。
删除 u 后:
右侧超过
于是得到一种寻找重心的过程:
- 如果删除
u后没有连通块超过,那么 u已经是重心。 - 否则,超过
的连通块至多有一个,向这一块的相邻点 v移动。 - 移到
v后,回到u的那一块大小小于,所以不会立刻走回去。
树没有环,因此这个移动过程不可能绕圈,最终一定停下。停下时所有连通块都不超过
3. 至多有两个重心
设 c 是一个重心。
- 如果删除
c后每一块都严格小于,那么其他点所在方向的总点数不足一半;移动到任何其他点,包含 c的反方向都会超过一半,所以重心唯一。 - 如果某一块恰好有
个点,设这块中与 c相邻的点为v。删除v后,c方向也恰好有个点,其他块更小,所以 v也是重心。
第二种情况只会产生相邻的 c、v 两个重心。因此树至少有一个重心,至多有两个重心;如果有两个,它们一定相邻。
4. 重心使距离和最小
在无权树上,重心也使到所有点的距离和最小。
设边 (u, v) 切开后,v 一侧有 u 移到 v:
- 这
个点的距离各减少 ; - 其余
个点的距离各增加 。
所以距离和的变化为:
当 v 移动会让距离和变小;没有任何方向超过一半时,距离和已经最小。这正是重心的判定条件。
5. 重心分治中的作用
重心分治每次选当前连通块的重心作为分治点。删除它后,每个子问题的规模至多是原来的一半,因此递归层数是
“求树的重心”是重心分治的基础步骤,但它本身还不是完整的重心分治算法。
应用分类详解
树的重心本质上是在树上找一个最平衡的切分点。
一、直接寻找平衡根
典型模式: 选择一个点作为根或枢纽,希望删掉它后最大的剩余部分尽量小。
识别信号: 题面出现“删除一个点”“最大连通块最小”“每部分不超过一半”。
核心建模: 用子树大小表示儿子方向,用 n - sz[u] 表示父亲方向。
二、最小化全树距离和
典型模式: 在无权树上选会场,使所有点到会场的距离和最小。
识别信号: 出现“所有点到选址点的距离总和最小”。
核心建模: 跨过一条边时,距离和变化由两侧点数之差决定;最优点满足每侧都不超过一半。
三、递归拆分树上问题
典型模式: 需要反复统计经过某个分治点的路径,再递归处理剩余连通块。
识别信号: 树上点对、路径长度统计,且普通 DFS 会重复处理大量跨子树路径。
核心建模: 每次取重心,保证子问题规模至少减半,从而把递归深度控制在
四、删边后的重心变化
典型模式: 删除每条边后,分别询问两侧连通块的重心。
识别信号: 对很多棵只差一条边或一个根的树重复求重心。
核心建模: 不能每次重新 DFS,需要利用最大子树方向、换根或倍增复用原树信息。
| 应用场景 | 维护的信息 | 典型复杂度 |
|---|---|---|
| 单次求重心 | 子树大小、最大剩余块 | |
| 距离和选址 | 子树大小、距离和 | |
| 重心分治 | 当前连通块大小、删除标记 | 常见为 |
| 多次删边求重心 | 最大子树方向、换根或倍增信息 | 依题目设计 |
经典例题
1. 树的重心模板
给出一棵树,输出所有重心。重点是同时检查儿子方向和父亲方向,并正确处理双重心。
2. luogu-P1395 题解
要求选择会议地点,使所有点到会场的距离和最小。重心性质可以确定最优位置;如果还要得到最小距离和,可以配合换根 DP 在线性时间内计算。
3. luogu-P5666 题解
枚举删除每条边后两侧连通块的全部重心。每次重新 DFS 会达到
实践思考与扩展
- 如果每个点带有权值,把“点数”换成“点权和”,可以定义树的带权重心。
- 如果只要求任意一个重心,可以从任意点不断走向超过一半的连通块。
- 递归 DFS 在退化成链的树上可能达到
层;栈空间严格受限时,可以改用显式栈求父亲和遍历序,再逆序累加子树大小。