枚举每条断边,两侧各求重心:沿“最大子树”方向下降的路线唯一,用重儿子链上的倍增跳表 O(log n) 定位;换根时只有当前端点需要重算重儿子与跳表。
OJ: luogu
题目 ID: P5666
难度:省选/NOI-
标签:树树形结构树的重心倍增换根
创建: 2026-07-16 23:59
更新: 2026-10-30 13:06
形式化题目
给定一棵
对树
对每条边
其中
先看样例第一组:
图上标了
暴力解法
思路
最直接的做法是枚举每条边,把两侧连通块各自求一遍重心。
求单个连通块的重心也不必逐点验证定义,可以沿「最大子树」方向走:把连通块内任一点当根,算出各子树的点数;只要存在一个子树点数超过
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-08-12 22:34
* update_at: 2026-10-01 21:04
*/
// brute.cpp:小数据暴力解。对每条边断开,两侧连通块分别以断边端点为根,
// 用“沿最大子树方向下降”在 O(size) 内找重心并累加编号和。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 35;
int n;
vector<int> g[MAXN]; // 邻接表存树
int edge_u[MAXN], edge_v[MAXN]; // 按输入顺序存每条边
bool in_comp[MAXN]; // 标记当前连通块内的节点
int fa[MAXN], sz[MAXN]; // 连通块内以根做 DFS 得到的父节点与子树大小
// 收集从 start 出发、不经过断边 (ban_u, ban_v) 的连通块。
void collect_component(int start, int ban_u, int ban_v) {
memset(in_comp, 0, sizeof(in_comp));
queue<int> q;
q.push(start);
in_comp[start] = true;
while (!q.empty()) {
int u = q.front();
q.pop();
for (auto v : g[u]) {
if ((u == ban_u && v == ban_v) || (u == ban_v && v == ban_u)) {
continue;
}
if (!in_comp[v]) {
in_comp[v] = true;
q.push(v);
}
}
}
}
// 在当前连通块内以 root 为根做 DFS,求出 sz[] 与 fa[]。
void dfs_size(int root) {
sz[root] = 1;
for (auto v : g[root]) {
if (v == fa[root] || !in_comp[v]) {
continue;
}
fa[v] = root;
dfs_size(v);
sz[root] += sz[v];
}
}
// 求以 root 为根的连通块的重心编号和(沿最大子树方向下降,O(size))。
int centroid_sum(int root) {
int total = sz[root];
int cur = root;
int max_child = 0;
// 不断进入“大小超过一半”的唯一子树方向;超过一半的子树至多一个,
// 所以方向唯一,走到停就是重心。
while (true) {
max_child = 0;
for (auto v : g[cur]) {
if (in_comp[v] && fa[v] == cur && sz[v] > sz[max_child]) {
max_child = v;
}
}
if (max_child != 0 && sz[max_child] * 2 > total) {
cur = max_child;
} else {
break;
}
}
// cur 是重心;若最大子树恰好为一半,其根与 cur 是两个相邻重心,编号都要计入。
int result = cur;
if (max_child != 0 && sz[max_child] * 2 == total) {
result += max_child;
}
return result;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
cin >> n;
for (int i = 1; i <= n; i++) {
g[i].clear();
}
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
edge_u[i] = u;
edge_v[i] = v;
g[u].push_back(v);
g[v].push_back(u);
}
ll answer = 0;
for (int i = 1; i < n; i++) {
int u = edge_u[i], v = edge_v[i];
// u 一侧:以 u 为根的连通块。
collect_component(u, u, v);
fa[u] = 0;
dfs_size(u);
answer += centroid_sum(u);
// v 一侧:以 v 为根的连通块。
collect_component(v, u, v);
fa[v] = 0;
dfs_size(v);
answer += centroid_sum(v);
}
cout << answer << '\n';
}
return 0;
}复杂度
每条边断一次,两侧各做一次
瓶颈
两个地方重复劳动:
- 统计重复。 每切一条边都要把两侧的子树大小从头算一遍,而这些信息在相邻两次切边之间几乎没变。
- 下降重复。 找重心要一步一步走,链状树上单次要潜到
深,所有边加起来就更慢。
正解:换根 + 重链倍增
先把上一节「沿最大子树方向下降」这条直觉钉死,再看它为什么能吃下倍增和换根两种加速。
观察一:下降方向唯一
以大小为
反证:假设子树
于是「往最大的那个子树走」是唯一的方向,不会分支。从任意起点出发反复这样做,走的是一条确定的链。
观察二:沿链倍增,落点就是重心
沿这条链往下走,子树大小严格递减,所以「
为什么停在那里就是重心?设落点为
的每个子树都不超过 (否则还能继续往下走,与「最后一个」矛盾); 上方的部分是 (因为 )。
两部分都不超过一半,所以
再看什么时候有两个重心。若
链上既然单调,就可以跳着走。预处理
图里
观察三:换根只需要补一个点
断边
这一侧就是原来以 为根的子树,内部拓扑、 、重儿子、跳表全都没变,直接按观察二查。 这一侧是去掉那棵子树后的补树,大小为 。此时 变成了 的子节点,它的「子节点」集合从「原来的儿子」变成「除 以外的所有邻居」。
关键在于改动的范围。
侧的点:子树没动,重儿子与跳表不用改; 下方的点:它们的下行路径不经过 ,不用改; 的祖先:它们的重儿子与 都没变,不用改。
真正变的只有当前这个端点
选「除
算法流程
第一遍 DFS 以
为什么要「保存现场」和「回溯恢复」:第 ③ 步改的是全局数组,必须等两侧重心都查完才还原,否则下一条边会读到错的重儿子。换根 DFS 把这套「进入时改、退出时还原」的配对自然写在递归的进/出两个位置:进函数时把 fa、sz、jump[u][0] 存进局部变量,递归回来后再逐项写回。
有一处容易看错:代码在推进到 fa[u]=v、fa[v]=father、sz[v] += sz[u]),所以回溯时要恢复的是 fa 与 sz,再加 jump[u][0] 与重建的 jump[u]。正文说「只改一个点」,指的是重儿子方向只有一个点要重选;根指针本身是另一回事。
代码
变量与正文的对应:sz[u] 是 jump_table[u][k] 是 son[u] 是重儿子,largest_two 求最大与次大邻居。
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-08-12 22:34
* update_at: 2026-10-30 14:12
*/
// main.cpp:对每条边断开,用“沿最大子树方向倍增”求两侧连通块的重心编号和。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 300005;
const int LOGN = 20; // 2^19 > 3e5,跳表需要 0..19 共 20 层
int n;
vector<int> g[MAXN]; // 邻接表存树
int sz[MAXN]; // 当前定向下以 i 为根的子树大小(定向根的大小为 n)
int fa[MAXN]; // 当前定向下 i 的父节点
int son[MAXN]; // 初始定向下 i 的最大子节点(重儿子)
int jump_table[MAXN][LOGN]; // jump_table[i][k]:沿 heavy 链向下跳 2^k 步到达的点
ll answer; // 所有边断开后两侧重心编号和的总和
// 重新构建节点 u 的倍增表。换根只会改变 u 一层的 heavy 方向,其余节点不变。
void rebuild_jump(int u) {
for (int k = 1; k < LOGN; k++) {
jump_table[u][k] = jump_table[jump_table[u][k - 1]][k - 1];
}
}
// 求“以 root 为根、大小为 sz[root] 的连通块”的重心编号和(重心可能有 1 或 2 个)。
// 从 root 出发沿最大子树方向下降,走到最后一个满足“子树不小于一半”的节点;
// 若最大子树恰好为一半,说明它和父节点是两个重心,两个编号都要计入。
void add_centroid_sum(int root) {
int total = sz[root];
int u = root;
// 倍增贪心:大步到小步,能跳就跳(子树大小不小于 half)。
for (int k = LOGN - 1; k >= 0; k--) {
int v = jump_table[u][k];
if (v != 0 && sz[v] * 2 >= total) {
u = v;
}
}
answer += u;
if (sz[u] * 2 == total) {
answer += fa[u];
}
}
// 求节点 u 在当前定向下最大、次大的相邻组件大小对应的邻居。
// first/second 用于换根时把 v 排除后重选 u 的最大子树方向。
void largest_two(int u, int& first, int& second) {
first = second = 0;
for (auto v : g[u]) {
if (sz[v] >= sz[first]) {
second = first;
first = v;
} else if (sz[v] >= sz[second]) {
second = v;
}
}
}
// 第一遍 DFS:以 1 为根填 fa,回溯时汇总 sz 并选出重儿子。
void dfs_init(int u, int p) {
fa[u] = p;
sz[u] = 1;
son[u] = 0;
for (auto v : g[u]) {
if (v == p) {
continue;
}
dfs_init(v, u);
sz[u] += sz[v];
if (sz[v] > sz[son[u]]) {
son[u] = v;
}
}
}
// 第二遍:换根 DFS 枚举每条边,统计两侧重心。
//
// 进入 v 的子树前把 v 临时当根:u 的 heavy 方向换成“除 v 外最大的邻居”,
// sz[u]、sz[v]、fa 同步改;递归回来后再逆操作恢复。
// 因为切边只影响 u 一个点,其余节点零修改,所以每条边只需一次换根 + 一次恢复。
void dfs_reroot(int u, int p) {
// 当前定向下 u 的最大、次大相邻组件,用于把某个儿子排除后重选 heavy 方向。
int first, second;
largest_two(u, first, second);
for (auto v : g[u]) {
if (v == p) {
continue;
}
// 边 (u, v) 断开后,v 一侧就是当前定向下 v 的子树,直接求重心。
add_centroid_sum(v);
// 保存现场,准备把 v 临时变成定向根。
int save_fa_u = fa[u], save_fa_v = fa[v];
int save_sz_u = sz[u], save_sz_v = sz[v];
int save_jump0_u = jump_table[u][0];
// 换根:u 的 heavy 方向变成“除 v 外最大的相邻组件”。
jump_table[u][0] = (v == first) ? second : first;
rebuild_jump(u);
sz[u] -= sz[v];
sz[v] += sz[u];
// u 一侧是“补树”(整棵树去掉 v 的子树),求重心。
add_centroid_sum(u);
// 更新父指针,进入 v 的子树继续枚举。
fa[u] = v;
fa[v] = p;
dfs_reroot(v, u);
// 恢复现场,回到“u 是当前定向根”的状态,继续处理 u 的下一个儿子。
fa[u] = save_fa_u;
fa[v] = save_fa_v;
sz[u] = save_sz_u;
sz[v] = save_sz_v;
jump_table[u][0] = save_jump0_u;
rebuild_jump(u);
}
}
// 处理单组测试数据:读入、预处理、换根枚举每条边并累加重心编号和。
void solve_one() {
cin >> n;
for (int i = 1; i <= n; i++) {
g[i].clear();
sz[i] = 1;
fa[i] = 0;
son[i] = 0;
for (int k = 0; k < LOGN; k++) {
jump_table[i][k] = 0;
}
}
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
// 第一遍:以 1 为根求 fa、sz、son。
dfs_init(1, 0);
// 构建初始倍增表:jump_table[u][0] 就是 heavy 方向。
// 必须按层构建:第 k 层只依赖第 k-1 层,保证任意节点的下层都已完成。
for (int u = 1; u <= n; u++) {
jump_table[u][0] = son[u];
}
for (int k = 1; k < LOGN; k++) {
for (int u = 1; u <= n; u++) {
jump_table[u][k] = jump_table[jump_table[u][k - 1]][k - 1];
}
}
// 第二遍:换根 DFS 枚举每条边。
answer = 0;
dfs_reroot(1, 0);
cout << answer << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
solve_one();
}
return 0;
}三个值得对照代码看的细节:
add_centroid_sum里sz[v] * 2 >= total就是观察二的判定,最后一行sz[u] * 2 == total则补上双重心时的父节点。rebuild_jump只重算的层,第 层由调用方直接赋值。 largest_two在进入时算一次并存进局部变量 first/second,之后的每个儿子都 取用。
复杂度
- 时间:预处理
与跳表 ;枚举 条边,每边两次查询、一次单点重建、一次回溯恢复,各 。总 。 - 空间:跳表
,邻接表 。取 时约 40 MB。
总结
这题把「求重心」拆成了两件可以独立加速的事:
一条判定条件,配上「超过一半的方向至多一个」,就把逐点验证变成了沿唯一链下降;链上
两个容易踩的地方:
- 别在断边时扫邻接表找次大邻居。 菊花图上单次就退化到
,必须预处理最大与次大。 - 跳表只朝下,所以改一个点不会波及祖先。 这条是换根能成立的全部理由;如果哪天把跳表改成朝上或双向,这个结论立刻失效。
参考与致谢
| 来源 | 借鉴的思路 | 本文自行补充 |
|---|---|---|
| xht(uid 100544) | 对比「按点统计贡献」与「按边求两侧重心」两类路线 | 采用换根 + 重链倍增路线;补写唯一方向、双重心论证 |
| rainygame(uid 804607) | 以最大子树方向唯一为依据,把找重心转为沿重儿子链下降 | 补充倍增判定与边界说明,对齐本地实现 |
| TEoS(uid 91534) | 以重心为根、重链与次重儿子切换的线性路线 | 作为复杂度与实现取舍的比较材料 |
| 1gnat2(uid 2508) | 沿子树重心祖先路径推进、批量处理补树重心 | 作为渐进更优的比较路线,未采纳为主解 |