[CSP-S 2019] 树的重心

对每条边断开后,利用最大子树方向唯一的性质沿 heavy 链倍增定位两侧重心,配合换根在 O(log n) 内枚举每条边。

OJ: luogu

题目 ID: P5666

难度:省选/NOI-

标签:重心换根倍增树形结构

日期: 2026-07-16 23:59

形式化题目

给定一棵 nn 个节点的树。对每条边 (u,v)(u,v),删掉它后树分成两个连通块,分别求出两个连通块的所有重心的编号之和(一个大小为 mm 的树最多有两个重心,此时两个都要计入)。对 n1n-1 条边的贡献求和,输出总和。

这张图是样例第一组数据的树:

text
     1
     |
     2
    / \
   3   4
   |
   5

以切边 (1,2)(1,2) 为例:删掉它后得到 {1}\{1\}{2,3,4,5}\{2,3,4,5\} 两个连通块,重心分别是 {1}\{1\}{2,3}\{2,3\}(两个重心相邻、各侧恰好一半)。观察这个例子:重心判定只看"删掉该点后最大连通部分 m/2\leqslant \lfloor m/2 \rfloor",而样例解释也印证了"重心可能有两个"时编号都要累加。

思路

先看一个可以直接验证想法的朴素解:

cpp
/**
 * 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-08-12 22:48
 */
// brute.cpp:小数据暴力解。对每条边断开,两侧连通块分别以断边端点为根,
// 用“沿最大子树方向下降”在 O(size) 内找重心并累加编号和。
#include <bits/stdc++.h>
using namespace std;

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 (int i = 0; i < (int)g[u].size(); i++) {
            int v = g[u][i];
            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 (int i = 0; i < (int)g[root].size(); i++) {
        int v = g[root][i];
        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 (int i = 0; i < (int)g[cur].size(); i++) {
            int v = g[cur][i];
            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);
        }

        long long 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;
}

brute.cpp 对每条边断开后,在两侧连通块内分别以断边端点为根做 DFS 求子树大小,然后沿"最大子树方向"一步步下降找重心:只要最大儿子的子树大小 >m/2> m/2 就进入它,停下的点就是重心;若最大儿子恰好 m/2m/2,它和停点都是重心。正确,但每条边都要重新 DFS 且下降 O(高度)O(\text{高度}) 步,总 O(n2)O(n^2)

关键观察有三条:

  1. 方向唯一:以连通块内任一点为根,若某儿子子树大小 >m/2> m/2,这样的儿子至多一个(两个儿子大小之和 m1\leqslant m-1),且重心一定在该方向内,所以"沿最大子树方向下降"是一条确定的路线。
  2. 下降可以倍增:以整棵树 1 号点为根预处理每个点的最大子节点(heavy 方向),沿 heavy 链的大小严格递减,"子树大小 m/2\geqslant m/2"在链上单调,因此可以用跳表 jump[u][k]jump[u][k](沿 heavy 链跳 2k2^k 步)大步到小步贪心下降,O(logn)O(\log n) 找到重心;若终点恰好 sz[u]2=msz[u] \cdot 2 = m,则 fa[u]fa[u] 是第二个重心。
  3. 换根只改一个点:断开边 (u,v)(u,v) 并把 vv 临时当根,vv 一侧就是子树 vv(内部完全不变);uu 一侧是"补树",只有 uu 的儿子集合少了 vv,所以只需把 uu 的 heavy 方向换成"除 vv 外最大的邻居"、改 sz[u]sz[u]sz[v]sz[v]fafa 即可,其余节点零修改。

以下面的小图看第 3 条:切边 (1,2)(1,2) 后把 22 临时当根,22 侧是子树 {2,3,4,5}\{2,3,4,5\} 直接求重心;11 侧补树只有 {1}\{1\}

text
切边 (1,2),把 2 临时当根:
  2 侧 = 子树 {2,3,4,5}   m=4,sz[3]=2, sz[4]=1
  下降:2 --(2*sz[3]=4 ≥ 4)--> 3 --(2*sz[5]=2 < 4 停)
  重心 3,且 2*sz[3]==4,父节点 2 也是重心 -> {2,3}(与样例解释一致)

  1 侧 = 补树 {1}        m=1,重心 1

于是算法:第一遍以 1 为根 BFS 求 fa/sz/heavyfa/sz/heavy 并按层构建跳表;第二遍用显式栈做换根 DFS 枚举每条边——进入边 (u,v)(u,v) 时先求子树 vv 的重心和,临时把根换成 vv 后求补树(根 uu)的重心和,递归进入 vv,返回时恢复现场。每步 O(logn)O(\log n)

代码

cpp
/**
 * 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-08-12 22:48
 */
// main.cpp:对每条边断开,用“沿最大子树方向倍增”求两侧连通块的重心编号和。
#include <bits/stdc++.h>
using namespace std;

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 heavy_son[MAXN];          // 初始定向下 i 的最大子节点
int jump_table[MAXN][LOGN];   // jump_table[i][k]:沿 heavy 链向下跳 2^k 步到达的点
long long 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 (int i = 0; i < (int)g[u].size(); i++) {
        int v = g[u][i];
        if (sz[v] >= sz[first]) {
            second = first;
            first = v;
        } else if (sz[v] >= sz[second]) {
            second = v;
        }
    }
}

// 换根 DFS 的显式栈帧:node 的邻居已处理到 stack_idx[top],
// stack_first/stack_second 是进入该帧时算好的最大、次大相邻组件。
int stack_node[MAXN], stack_father[MAXN], stack_idx[MAXN];
int stack_first[MAXN], stack_second[MAXN];

// 帧弹出时恢复的现场(由父帧在压入时保存):
// changed_node 与 changed_child 是换根时被修改的边两端点。
int restore_fa_node[MAXN], restore_fa_child[MAXN];
int restore_sz_node[MAXN], restore_sz_child[MAXN];
int restore_jump0[MAXN], restore_changed_node[MAXN], restore_changed_child[MAXN];

// 第一遍 BFS 的顺序与队列(迭代写法避免 3e5 深度递归爆栈)。
int order_arr[MAXN], order_cnt;
int bfs_queue[MAXN];

void solve_one() {
    cin >> n;
    for (int i = 1; i <= n; i++) {
        g[i].clear();
        sz[i] = 1;
        fa[i] = 0;
        heavy_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 为根做 BFS,得到 fa[] 与遍历顺序 order_arr[]。
    order_cnt = 0;
    int head_q = 0, tail_q = 0;
    fa[1] = 0;
    bfs_queue[tail_q++] = 1;
    while (head_q < tail_q) {
        int u = bfs_queue[head_q++];
        order_arr[order_cnt++] = u;
        for (int i = 0; i < (int)g[u].size(); i++) {
            int v = g[u][i];
            if (v == fa[u]) {
                continue;
            }
            fa[v] = u;
            bfs_queue[tail_q++] = v;
        }
    }

    // 逆序遍历求 sz[] 与 heavy_son[](保证子节点的信息先算好)。
    sz[0] = 0;
    for (int i = n - 1; i >= 1; i--) { // order_arr[0] 是根 1,跳过
        int u = order_arr[i];
        int p = fa[u];
        sz[p] += sz[u];
        if (sz[u] > sz[heavy_son[p]]) {
            heavy_son[p] = u;
        }
    }

    // 构建初始倍增表:jump_table[u][0] 就是 heavy 方向。
    // 必须按层构建:第 k 层只依赖第 k-1 层,保证任意节点的下层都已完成。
    for (int u = 1; u <= n; u++) {
        jump_table[u][0] = heavy_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;
    int top = 1;
    stack_node[1] = 1;
    stack_father[1] = 0;
    stack_idx[1] = 0;
    largest_two(1, stack_first[1], stack_second[1]);

    while (top >= 1) {
        int node = stack_node[top];
        int father = stack_father[top];

        if (stack_idx[top] == (int)g[node].size()) {
            // 本帧处理完毕:弹出并恢复父帧换根时保存的现场(根帧无需恢复)。
            if (top >= 2) {
                int cn = restore_changed_node[top];
                int cc = restore_changed_child[top];
                fa[cn] = restore_fa_node[top];
                fa[cc] = restore_fa_child[top];
                sz[cn] = restore_sz_node[top];
                sz[cc] = restore_sz_child[top];
                jump_table[cn][0] = restore_jump0[top];
                rebuild_jump(cn);
            }
            top--;
            continue;
        }

        int v = g[node][stack_idx[top]];
        stack_idx[top]++;
        if (v == father) {
            continue;
        }

        // 边 (node, v) 断开后,v 一侧就是当前定向下 v 的子树,直接求重心。
        add_centroid_sum(v);

        // 保存现场,准备把 v 临时变成定向根。
        restore_fa_node[top + 1] = fa[node];
        restore_fa_child[top + 1] = fa[v];
        restore_sz_node[top + 1] = sz[node];
        restore_sz_child[top + 1] = sz[v];
        restore_jump0[top + 1] = jump_table[node][0];
        restore_changed_node[top + 1] = node;
        restore_changed_child[top + 1] = v;

        // 换根:node 的 heavy 方向变成“除 v 外最大的相邻组件”。
        jump_table[node][0] = (v == stack_first[top]) ? stack_second[top] : stack_first[top];
        rebuild_jump(node);
        sz[node] -= sz[v];
        sz[v] += sz[node];

        // node 一侧是“补树”(整棵树去掉 v 的子树),求重心。
        add_centroid_sum(node);

        // 更新父指针,进入 v 的子树继续枚举。
        fa[node] = v;
        fa[v] = father;

        top++;
        stack_node[top] = v;
        stack_father[top] = node;
        stack_idx[top] = 0;
        largest_two(v, stack_first[top], stack_second[top]);
    }

    cout << answer << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int T;
    cin >> T;
    while (T--) {
        solve_one();
    }

    return 0;
}

复杂度

  • 时间:预处理 O(nlogn)O(n \log n)(跳表按层构建);每条边两次重心查询 + 一次跳表重建 O(logn)O(\log n),总 O(nlogn)O(n \log n)
  • 空间:跳表 O(nlogn)O(n \log n),其余数组 O(n)O(n)

总结

本题的骨架是"重心判定只看最大部分,沿最大部分方向下降"这一性质:它让求重心变成沿一条链走,而链上大小单调又让它能吃下倍增跳跃的加速;换根之所以可行,是因为切边后只有一个端点需要改信息。rbook 的《倍增跳跃》讲了"大步到小步试探、能跳就跳"的跳表原理,正是本题 O(logn)O(\log n) 求重心所依赖的模型;jump[u][k] 沿 heavy 链下降的写法与倍增 LCA 的跳表同理,只是方向相反。

图示解析

这张 ASCII 图展示整道题的解题路线:

text
朴素做法(brute.cpp)
  枚举每条边断开,两侧连通块各自以断边端点为根
  沿“最大子树方向”一步步下降找重心        O(n^2) 每棵树
        |
        | 瓶颈:每边重新 DFS + 下降 O(高度) 步
        v
关键观察
  1) 重心 ⇔ 最大连通部分 <= m/2
  2) 若某子树 > m/2,该方向唯一,重心必在其中
  3) 双重心只在“恰好一半”出现,两个都要加
        |
        v
两个加速(main.cpp)
  A. heavy 链倍增:jump[u][k] = 沿最大子树方向跳 2^k 步
     “子树大小 >= m/2”沿链单调,贪心下降 O(log n) 找到重心
  B. 换根枚举每条边:断开 (u,v) 后把 v 临时当根
     v 侧 = 子树 v,u 侧 = 补树;只有 u 的 heavy 方向
     与 sz[u]、sz[v]、fa 需要改,其余节点零修改
        |
        v
每边 O(log n),总 O(n log n),空间 O(n log n)

图中从上到下对应"暴力慢在哪"“重心下降方向为什么唯一”“倍增 + 换根如何复用信息”。核心是:把"找重心"从逐点判定压缩成"沿唯一方向走",再让"沿唯一方向走"和"切边后的补树"都能在 O(logn)O(\log n) 内完成。