[NOI2011] 道路修建

任选根 DFS 求每棵子树大小,边费用 = 边权 × |2·子树大小 − n|,一次遍历累加总费用。

OJ: luogu

题目 ID: P2052

难度:普及

标签:树形 DP子树大小前向星

日期: 2026-07-17 02:00

形式化题目

给定一棵 nn 个节点的带权无根树。把每条边断开后,树分成两个连通块,这条边的修建费用定义为边权乘以两侧节点数之差的绝对值。求所有边的修建费用之和。

思路

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

cpp
#include <bits/stdc++.h>
using namespace std;

struct Edge {
    int u;
    int v;
    int w;
};

int count_component(int start, int ban_u, int ban_v,
                    const vector<vector<int>> &g) {
    int n = (int)g.size() - 1;
    vector<int> vis(n + 1, 0);
    stack<int> st;
    st.push(start);
    vis[start] = 1;

    int cnt = 0;
    while (!st.empty()) {
        int u = st.top();
        st.pop();
        ++cnt;

        for (int v : g[u]) {
            if ((u == ban_u && v == ban_v) || (u == ban_v && v == ban_u)) {
                continue;
            }
            if (!vis[v]) {
                vis[v] = 1;
                st.push(v);
            }
        }
    }
    return cnt;
}

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

    int n;
    cin >> n;
    vector<Edge> edges;
    vector<vector<int>> g(n + 1);

    for (int i = 1; i <= n - 1; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        edges.push_back({u, v, w});
        g[u].push_back(v);
        g[v].push_back(u);
    }

    long long ans = 0;
    for (const auto &e : edges) {
        // 直接删掉这条边,暴力数出一侧有多少点。
        int left_cnt = count_component(e.u, e.u, e.v, g);
        int right_cnt = n - left_cnt;
        ans += 1LL * e.w * llabs(1LL * left_cnt - right_cnt);
    }

    cout << ans << '\n';
    return 0;
}

brute.cpp 对每条边断开后分别 DFS 数出两侧节点数再累加费用,单条边 O(n)O(n),总复杂度 O(n2)O(n^2),只适合小数据。

关键观察:任选 11 号点为根,对于父子边 (fa,u)(fa, u),只需知道 uu 的子树大小 size[u]——断掉这条边后一侧有 size[u] 个节点,另一侧就是 nsize[u]n - \text{size}[u],费用为

w×2size[u]nw \times |2 \cdot \text{size}[u] - n|

于是一次迭代 DFS(前向星存边,避免深递归爆栈)得到遍历顺序,逆序回推每个节点的子树大小,同时累加每条父边的费用,整棵树只扫一遍。

代码

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-20 11:28
 * update_at: 2026-08-20 11:28
 */
#include <bits/stdc++.h>
using namespace std;

using ll = long long;
using Edge = struct { int to; ll w; }; // 树边:to 是另一端点,w 是边权
using Graph = std::vector<Edge>;

const int MAXN = 1000000 + 5; // 节点数上限

int n; // 国家数
Graph tree[MAXN]; // 全局邻接表数组:直接向 tree[u] 加带权边

int parent_arr[MAXN];  // parent_arr[u] 表示 u 在根化后的父亲节点
int parent_w[MAXN];    // parent_w[u] 表示 u 与其父亲的连边权值
int subtree_size[MAXN]; // subtree_size[u] 表示以 u 为根的子树大小
int order_arr[MAXN];    // BFS 得到的节点访问顺序
int order_cnt;          // 访问过的节点数

// 从任意点 s 出发 BFS 建父子关系与访问顺序。
// BFS 是队列迭代遍历,n 达到 1e6 时也不会深递归爆栈。
void bfs_build(int s) {
    queue<int> q;
    q.push(s);
    parent_arr[s] = 0; // 根没有父亲
    order_cnt = 0;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        order_arr[++order_cnt] = u;

        for (Edge e : tree[u]) {
            int v = e.to;
            if (v == parent_arr[u]) {
                continue; // 跳过父亲方向,避免走回上一步
            }
            parent_arr[v] = u;
            parent_w[v] = e.w;
            q.push(v);
        }
    }
}

// 逆序遍历顺序回推子树大小,同时累加每条父子边的修建费用。
// 断开父子边 (fa, u) 后,u 一侧有 subtree_size[u] 个节点,
// 另一侧为 n - subtree_size[u],费用 = 边权 * |2*子树大小 - n|。
ll calc_cost() {
    ll ans = 0;

    for (int i = 1; i <= n; i++) {
        subtree_size[i] = 1;
    }

    // BFS 顺序中子节点一定在父节点之后,
    // 从后往前即可保证算完每个 u 时子树大小已完整。
    for (int i = order_cnt; i >= 2; i--) {
        int u = order_arr[i];
        ll diff = llabs(1LL * n - 2LL * subtree_size[u]);
        ans += 1LL * parent_w[u] * diff;
        subtree_size[parent_arr[u]] += subtree_size[u];
    }

    return ans;
}

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

    cin >> n;
    for (int i = 1; i <= n - 1; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        tree[u].push_back({v, w});
        tree[v].push_back({u, w});
    }

    bfs_build(1);
    cout << calc_cost() << '\n';

    return 0;
}

复杂度

  • 时间:一次遍历 + 逆序汇总,O(n)O(n)
  • 空间:前向星与子树大小数组,O(n)O(n)

总结

"树边分割"类问题通常只需要知道一侧的子树大小,另一侧由总数减去它得到。用根化把无根树变成有向的父子关系,再自底向上回推子树大小,是最直接的 O(n)O(n) 解法。注意费用和要使用 64 位整数。