[福建省队集训2019] 最大权独立集问题

GitHub跳转原题关系图返回列表

把删点顺序转成树边定向,再做树形 DP,维护子树向根汇总权值与根向下可达点数的 Pareto 状态。

OJ: luogu

题目 ID: P9111

难度:提高+/省选-

标签:动态规划树形DP建模

日期: 2026-06-21 11:27

题意

n 个 AI,通信关系是一棵树。

每个 AI 一开始存有一道难度为 d_i 的题。

每次选择一个当前还有题的 AI,把它里面的题出掉。出题前,它会把这道题发给所有相邻的、当前还没被清空的 AI:

  • 如果对方还有题,就把两题难度相加
  • 如果对方已经没题,就这次发送作废

要求选择一个出题顺序,使得最终一共出的 n 道题的难度和最大。

思路

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

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

typedef long long ll;

// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。

const ll NEG_INF = -(1LL << 60);

int n;
ll d[15];
vector<int> g[15];
int permu[15];
int posi[15];
vector<int> out[15];

// 统计固定删点顺序下的总答案。
ll calc_one_order() {
    for (int i = 1; i <= n; i++) {
        posi[permu[i]] = i;
        out[i].clear();
    }

    // 一条边一定从“更早被删的点”指向“更晚被删的点”。
    for (int u = 1; u <= n; u++) {
        for (int v : g[u]) {
            if (u < v) {
                if (posi[u] < posi[v]) {
                    out[u].push_back(v);
                }
                else {
                    out[v].push_back(u);
                }
            }
        }
    }

    ll total = 0;
    for (int s = 1; s <= n; s++) {
        queue<int> q;
        vector<int> vis(n + 1, 0);
        q.push(s);
        vis[s] = 1;

        while (!q.empty()) {
            int u = q.front();
            q.pop();
            total += d[s];
            for (int v : out[u]) {
                if (!vis[v]) {
                    vis[v] = 1;
                    q.push(v);
                }
            }
        }
    }
    return total;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> d[i];
        permu[i] = i;
    }
    for (int i = 2; i <= n; i++) {
        int c;
        cin >> c;
        g[i].push_back(c);
        g[c].push_back(i);
    }

    ll ans = NEG_INF;
    sort(permu + 1, permu + n + 1);
    do {
        ans = max(ans, calc_one_order());
    } while (next_permutation(permu + 1, permu + n + 1));

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

暴力枚举删点顺序以后,整个过程其实就被完全确定了。

关键观察是:一条边两端谁先被删,谁后被删,会决定这条边上的题是往哪个方向传。

所以我们可以把每条树边看成一条有向边:

  • uv 先删,就连 u -> v

这样整棵树就变成了一棵 DAG,而且答案只和这组边的定向有关,不再需要显式模拟“每一步删谁”。

对于一个点 s,它最终出的题难度,等于所有能沿着有向边从 s 走到的点的初始权值之和。

于是总答案变成:

  • 对每个点 s,统计它能到达多少个点
  • 把这些可达点的权值加总

接下来做树形 DP。

设我们只看 u 的子树,需要维护两类信息:

  1. W:子树里有多少总权值,最终能汇总到 u
  2. C:从 u 出发,沿着当前定向能到达多少个点

再设 F 为这棵子树内部已经确定的总答案。

为什么这三个量够用?

因为合并一个儿子 v 时,只有两种选择:

  1. v -> u

    • 那么 v 子树能汇到 u 的权值 W_v,会对 u 当前能到达的 C_u 个点各贡献一次
    • 新贡献是 W_v * C_u
  2. u -> v

    • 那么 u 当前能汇到的权值 W_u,会对 v 子树里 u 能继续走到的 C_v 个点各贡献一次
    • 新贡献是 W_u * C_v

所以转移非常直接:

DP 转移方程:树上定向合并

合并儿子 v 时,当前状态为 (W_u,C_u,F_u),儿子状态为 (W_v,C_v,F_v)。 只有两种边方向:

  • v -> u(W_u + W_v, C_u, F_u + F_v + W_v * C_u)
  • u -> v(W_u, C_u + C_v, F_u + F_v + W_u * C_v)

这里会出现很多状态,但对于固定的 C,如果两个状态满足:

  • 一个状态的 W 更大
  • 同时 F 也更大

那较差的那个以后不可能翻盘,可以直接删掉。

因此代码里对每个 C 只保留 (W, F) 的 Pareto 前沿。

这样就能在 n = 400 的范围内通过。

代码

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

typedef long long ll;

const ll NEG_INF = -(1LL << 60);
const int MAXN = 405;

int n;
ll d[MAXN];
vector<int> g[MAXN];
vector<int> child[MAXN];
vector<int> order_nodes;

// states[u][c] = 若干个 (W, F)
// c : 在子树 u 中,从 u 出发沿定向边能到达的点数
// W : 能通过反向链路汇总到 u 的总权值
// F : 这棵子树内部的总答案
vector<vector<pair<ll, ll> > > states[MAXN];

void dfs_build(int u, int fa) {
    order_nodes.push_back(u);
    for (int v : g[u]) {
        if (v == fa) {
            continue;
        }
        child[u].push_back(v);
        dfs_build(v, u);
    }
}

// 固定同一个 c 时,保留 (W, F) 的 Pareto 前沿。
// 如果存在另一个状态同时满足 W 更大且 F 更大,那么当前状态以后不可能更优。
vector<pair<ll, ll> > prune_same_c(vector<pair<ll, ll> > vec) {
    sort(vec.begin(), vec.end(), [](const pair<ll, ll> &a, const pair<ll, ll> &b) {
        if (a.first != b.first) {
            return a.first > b.first;
        }
        return a.second > b.second;
    });

    vector<pair<ll, ll> > res;
    ll best_f = NEG_INF;
    int i = 0;
    while (i < (int) vec.size()) {
        int j = i;
        ll cur_w = vec[i].first;
        ll best_this_w = NEG_INF;
        while (j < (int) vec.size() && vec[j].first == cur_w) {
            best_this_w = max(best_this_w, vec[j].second);
            j++;
        }
        if (best_this_w > best_f) {
            res.push_back(make_pair(cur_w, best_this_w));
            best_f = best_this_w;
        }
        i = j;
    }
    return res;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> d[i];
    }
    for (int i = 2; i <= n; i++) {
        int c;
        cin >> c;
        g[i].push_back(c);
        g[c].push_back(i);
    }

    dfs_build(1, 0);

    for (int idx = (int) order_nodes.size() - 1; idx >= 0; idx--) {
        int u = order_nodes[idx];

        // dp[c]:当前已经合并完若干个儿子后,能形成的 (W, F) 前沿。
        vector<vector<pair<ll, ll> > > dp(2);
        dp[1].push_back(make_pair(d[u], d[u]));

        for (int v : child[u]) {
            vector<vector<pair<ll, ll> > > ndp((int) dp.size() + (int) states[v].size());

            for (int cu = 1; cu < (int) dp.size(); cu++) {
                if (dp[cu].empty()) {
                    continue;
                }
                for (pair<ll, ll> now : dp[cu]) {
                    ll wu = now.first;
                    ll fu = now.second;
                    for (int cv = 1; cv < (int) states[v].size(); cv++) {
                        if (states[v][cv].empty()) {
                            continue;
                        }
                        for (pair<ll, ll> son : states[v][cv]) {
                            ll wv = son.first;
                            ll fv = son.second;

                            // 方案 1:v -> u
                            // 子树 v 的权值能汇到 u,u 的可达点数不变。
                            ndp[cu].push_back(make_pair(
                                wu + wv,
                                fu + fv + wv * cu
                            ));

                            // 方案 2:u -> v
                            // u 能继续走进 v 子树,u 的可达点数增加 cv。
                            ndp[cu + cv].push_back(make_pair(
                                wu,
                                fu + fv + wu * cv
                            ));
                        }
                    }
                }
            }

            for (int c = 1; c < (int) ndp.size(); c++) {
                if (!ndp[c].empty()) {
                    ndp[c] = prune_same_c(ndp[c]);
                }
            }
            dp.swap(ndp);
        }

        states[u].swap(dp);
    }

    ll ans = NEG_INF;
    for (int c = 1; c < (int) states[1].size(); c++) {
        for (pair<ll, ll> st : states[1][c]) {
            ans = max(ans, st.second);
        }
    }

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

复杂度

设每个点保留下来的 Pareto 状态数为 S

转移复杂度大致是 O(nS2)O(n * S^2),在这题的数据范围内可以通过。

空间复杂度约为 O(nS)O(n * S)

总结

这题最关键的建模,不是“出题过程怎么模拟”,而是把它改写成:

  • 每条边按照删点先后关系定向

一旦这样看,整题就会变成一棵树上的定向 DP。

状态里真正重要的是:

  • 能往上汇多少权值
  • 能往下覆盖多少点

抓住这两个量以后,合并儿子就是很自然的两种方向分类讨论。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析