[CHCI 2002 Final Exam #2] 有线电视网

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

将用户数作为背包容量维度,利用树形背包 DP 求解在净收益非负条件下的最大转播用户数。

OJ: luogu

题目 ID: P1273

难度:普及+/提高-

标签:树形 DP背包 DP动态规划

创建: 2026-10-23 23:05

更新: 2026-10-26 14:13

形式化题目

给定一棵包含 NN 个节点的有向树,树根为节点 11。树中的叶子节点共有 MM 个,编号为 N−M+1N-M+1 到 NN,分别代表 MM 个用户终端;其余节点为转播中转站。

树上的每条有向边 (u,v)(u, v) 具有非负权值 c(u,v)c(u, v),表示传输信号的成本;每个叶子节点 ii 具有非负收益 PiP_i,表示用户愿意支付的费用。

若要向一个叶子节点子集 SS 提供转播信号,必须在树上激活包含根节点 11 和子集 SS 中所有叶子的连通子树。总成本等于该连通子树中所有边的权值之和(重复复用的边仅计费一次),总收入等于 ∑i∈SPi\sum_{i \in S} P_i。

求在满足总收入不小于总成本(即净收益 ≥0\ge 0)的前提下,子集 SS 的最大元素个数 ∣S∣|S|。

暴力解法

思路

树上的叶子节点总数为 MM。最直接的方法是枚举全部 2M2^M 种可能的叶子用户子集:

  1. 枚举一个二进制掩码 mask∈[0,2M−1]\text{mask} \in [0, 2^M - 1],其中第 ii 位为 11 表示选取第 ii 个叶子节点。
  2. 对于每种选法,从每个被选中的叶子节点沿着树边向上攀爬到根节点 11,标记所有被激活的树边。
  3. 统计被激活边的边权总和作为总成本,累加选中叶子的付费作为总收入。
  4. 若总收入 ≥\ge 总成本,则用当前选中的叶子个数更新答案的最大值。

代码

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-10-23 23:32
 * update_at: 2026-10-23 23:33
 */
/*
 * 暴力解法说明:
 * 枚举所有用户终端(共有 M 个)的子集,共 2^M 种状态。
 * 对于每个被选中的用户子集:
 * 1. 信号必须从根节点 1 传输到所有被选中的用户,因此需要覆盖连接这些用户到根节点的连通子树。
 * 2. 统计树上被激活的边的权值总和(成本),并计算被选中用户的付费总和(收入)。
 * 3. 若 收入 - 成本 >= 0,则更新最大合法用户数。
 * 时间复杂度为 O(2^M * N),只适合 M <= 20 的小规模测试数据。
 */

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

typedef long long ll;

const int MAXN = 105;

struct Edge {
    int to;
    ll w;
};

ll n, m;
vector<Edge> g[MAXN];
ll val[MAXN];
int parent_node[MAXN];
ll parent_edge_cost[MAXN];

void get_parents(int u, int p) {
    for (const auto &e : g[u]) {
        int v = e.to;
        if (v == p) continue;
        parent_node[v] = u;
        parent_edge_cost[v] = e.w;
        get_parents(v, u);
    }
}

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

    if (!(cin >> n >> m)) return 0;

    for (int i = 1; i <= n - m; ++i) {
        int k;
        cin >> k;
        for (int j = 0; j < k; ++j) {
            int to;
            ll cost;
            cin >> to >> cost;
            g[i].push_back({to, cost});
        }
    }

    for (int i = n - m + 1; i <= n; ++i) {
        cin >> val[i];
    }

    get_parents(1, 0);

    ll ans = 0;
    // 枚举 2^m 种叶子选择方案
    int total_masks = 1 << m;
    for (int mask = 0; mask < total_masks; ++mask) {
        ll cnt = 0;
        ll income = 0;
        vector<bool> edge_used(n + 1, false);

        for (int i = 0; i < m; ++i) {
            if ((mask >> i) & 1) {
                cnt++;
                int leaf = (n - m + 1) + i;
                income += val[leaf];
                // 向上点亮通往根节点 1 的路径
                int curr = leaf;
                while (curr != 1) {
                    if (edge_used[curr]) break;
                    edge_used[curr] = true;
                    curr = parent_node[curr];
                }
            }
        }

        ll cost = 0;
        for (int i = 2; i <= n; ++i) {
            if (edge_used[i]) {
                cost += parent_edge_cost[i];
            }
        }

        if (income >= cost) {
            ans = max(ans, cnt);
        }
    }

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

复杂度

  • 时间复杂度:共有 2M2^M 种叶子子集,每次检验需沿树向上标记边,耗时 O(N)O(N)。整体时间复杂度为 O(2M⋅N)O(2^M \cdot N)。
  • 空间复杂度:存储整棵树及父节点指针需 O(N)O(N) 空间。

瓶颈

暴力的时间复杂度随着用户数量 MM 呈指数级增长。当 M≤20M \le 20 时尚能运行,而本题中 M≤3000M \le 3000,2M2^M 是一个不可逾越的天文数字。

瓶颈在于暴力枚举完全忽视了树形结构的重叠子问题和子树最优子结构: 在以任意节点 uu 为根的子树中,要给子树内的 kk 个叶子提供信号,其内部如何选取使得“子树内叶子收益 - 子树内边权”最大化是一个相对独立的决策问题,与子树外的其他兄弟分支互不干扰。这提示我们应当使用树形动态规划来合并子树状态。

正解

思路

1. 状态维度对调与背包设计

若以“花费或收益”作为 DP 状态的维度,由于边权与费用的累加值域较大且不规则,状态规模难以控制。反之,由于用户数量 M≤3000M \le 3000,且最终目标正是最大化用户数,我们可以进行“状态与值对调”:

  • 容量维度:以“选中的叶子用户数量”作为状态维度(范围从 00 到 MM)。
  • DP 状态值:维护在当前子树内选定该数量用户时所能取得的最大净收益(总收入 - 内部总成本)。

定义 dp[u][j]dp[u][j] 表示:在以节点 uu 为根的子树中,恰好为 jj 个用户终端提供转播信号时,子树内部能获得的最大净收益。

2. 状态初始化

  • 默认所有状态初始化为负无穷 −∞-\infty,表示该方案不可行(注意净收益本身可能为负数,但必须是合法连通的状态才能参与转移)。
  • 对于任何节点 uu,选 00 个用户时不需要激活子树内任何边,净收益为 00,即 dp[u][0]=0dp[u][0] = 0。
  • 对于叶子用户节点 uu(N−M+1≤u≤NN-M+1 \le u \le N):它只有自身 11 个用户,若选 11 个用户则获得收益 PuP_u,即 dp[u][1]=Pudp[u][1] = P_u;子树叶子数 sz[u]=1sz[u] = 1。
  • 对于中转站节点 uu(1≤u≤N−M1 \le u \le N-M):初始时已合并的叶子数 sz[u]=0sz[u] = 0。

3. 状态转移方程

对于非叶子节点 uu,依次遍历其每条出边 (u,v)(u, v),边权为 ww。 先递归求解子节点 vv 的所有状态与叶子总数 sz[v]sz[v]。

将子树 vv 视为一个分组背包物品并入 uu 的已有状态中: 设 uu 在合并 vv 之前已累计的叶子数为 sz[u]sz[u],合并后可选的最大叶子数为 sz[u]+sz[v]sz[u] + sz[v]。 为了避免同一子树产生后效性累加,我们仿照 0/1 背包,从大到小倒序枚举合并后的总用户数 j∈[sz[u]+sz[v],0]j \in [sz[u] + sz[v], 0]: 再枚举分配给子树 vv 的用户数 k∈[1,min⁡(sz[v],j)]k \in [1, \min(sz[v], j)]:

dp[u][j]=max⁡(dp[u][j],  dp[u][j−k]+dp[v][k]−w)dp[u][j] = \max\Big(dp[u][j],\; dp[u][j - k] + dp[v][k] - w\Big)

说明:

  • 当 k≥1k \ge 1 时,子树 vv 贡献了 dp[v][k]dp[v][k] 的收益,但由于信号需要从 uu 传送到 vv,必须支付连接边 (u,v)(u, v) 的成本 ww。
  • 当 k=0k = 0 时,无需激活边 (u,v)(u, v),贡献为 00,相当于保留原状态 dp[u][j]dp[u][j],无需显式减去边权。

更新完所有 jj 后,令 sz[u]←sz[u]+sz[v]sz[u] \leftarrow sz[u] + sz[v]。

为了看清这个转移到底在做什么, 用一个 44 个节点的小例子把某一次合并逐格算一遍:

  • 节点 11 是根, 它唯一的孩子是节点 22, 边权 c(1,2)=2c(1, 2) = 2;
  • 节点 22 有两个孩子, 都是用户终端: 节点 33 (P3=9P_3 = 9, c(2,3)=1c(2, 3) = 1) 与节点 44 (P4=5P_4 = 5, c(2,4)=3c(2, 4) = 3).

先在节点 22 上做完全部合并: 合并叶子 33 得到 dp[2][1]=9−1=8dp[2][1] = 9 - 1 = 8, 再合并叶子 44 得到 dp[2][2]=8+5−3=10dp[2][2] = 8 + 5 - 3 = 10, 于是儿子状态为 dp[2]=[ 0, 8, 10 ]dp[2] = [\,0,\ 8,\ 10\,] (对应 j=0,1,2j = 0, 1, 2). 下表把根节点 11 合并这唯一一个儿子 (边权 w=2w = 2) 的一轮转移逐格列出: 行是合并后的总用户数 jj, 中间三列是“分给儿子 kk 个用户”这一来源各自算出的候选值, 最后两列是这一格在合并前与合并后的取值.

总用户数 jj k=0k = 0 候选 (不动) k=1k = 1 候选 k=2k = 2 候选 合并前 dp[1][j]dp[1][j] 合并后 dp[1][j]dp[1][j]
00 00 — — 00 00
11 −∞-\infty dp[1][0]+dp[2][1]−2=6dp[1][0] + dp[2][1] - 2 = 6 — −∞-\infty 66
22 −∞-\infty dp[1][1]+dp[2][1]−2=−∞dp[1][1] + dp[2][1] - 2 = -\infty dp[1][0]+dp[2][2]−2=8dp[1][0] + dp[2][2] - 2 = 8 −∞-\infty 88

读表时注意三件事: 第一, k=0k = 0 那一列表示“不往这个儿子的子树里放任何用户”, 状态原样保留, 所以 j=0j = 0 一行的右侧仍等于左侧; 第二, 只要 k≥1k \ge 1, 候选值都必须再扣一次边权 w=2w = 2, 因为儿子子树里只要有用户, 边 (u,v)(u, v) 就被激活, 而这条边无论承载几个用户都只计费一次, 这就是 −w-w 的来源; 第三, j=2j = 2 行的 k=1k = 1 候选是 −∞-\infty, 原因是倒序枚举让 j=2j = 2 先于 j=1j = 1 被处理, 此刻 dp[1][1]dp[1][1] 还停在合并前的 −∞-\infty. 若改成从 j=0j = 0 正序枚举, dp[1][1]dp[1][1] 会先被更新成 66, 随后 j=2j = 2 的 k=1k = 1 候选就变成 6+8−2=126 + 8 - 2 = 12, 等于把同一个儿子 22 用了两次, 答案虚高. 核对终点也很直接: dp[1]=[ 0, 6, 8 ]dp[1] = [\,0,\ 6,\ 8\,] 中 dp[1][2]=8≥0dp[1][2] = 8 \ge 0, 所以这个小例子最多服务 22 个用户.

下面这张图把 dp[u][j]dp[u][j] 的候选值拆成来源, 说明一次合并中每一格的值由哪几部分组成:

flowchart LR
  A["dp[u][j-k]: 父节点合并前的状态"] --> C["相加"]
  B["dp[v][k]: 儿子子树恰好服务 k 个用户"] --> C
  C --> D["减去边权 w: 激活边 u 到 v, 且只计一次"]
  D --> E["dp[u][j] 的一个候选"]
  F["k = 0: 直接保留原来的 dp[u][j]"] --> G["在全部候选中取 max"]
  E --> G

图中上方是 k≥1k \ge 1 的一条来源路径: 父节点的旧状态配上儿子的 kk 个用户, 再统一扣掉一次边权; 下方是 k=0k = 0 的分支, 它完全不经过减法, 等价于“这个儿子整体不选”. 所有分支最后都汇合到取最大值这一步, 这正是分组背包里“每组只能挑一个 kk”的体现.

4. 提取答案

整棵树的 DFS 遍历结束后,根节点的状态 dp[1][j]dp[1][j] 即代表在全局选 jj 个用户时的最大净收益。 我们从 j=Mj = M 递减枚举到 00: 找到第一个满足 dp[1][j]≥0dp[1][j] \ge 0 的 jj 并输出,即为在不亏本的前提下能服务的最大用户数。由于 dp[1][0]=0≥0dp[1][0] = 0 \ge 0,一定存在合法解。

代码

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-10-23 23:32
 * update_at: 2026-10-23 23:32
 */
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>

using namespace std;

typedef long long ll;

const ll INF = 0x3f3f3f3f3f3f3f3fLL;
const int MAXN = 3005;

struct Edge {
    int to;
    ll w;
};

ll n, m;
vector<Edge> g[MAXN];
ll val[MAXN];
ll dp[MAXN][MAXN];
ll sz[MAXN]; // 子树中用户终端(叶子)的数量

void dfs(int u) {
    if (u > n - m) {
        // 用户终端
        sz[u] = 1;
        dp[u][0] = 0;
        dp[u][1] = val[u];
        return;
    }

    dp[u][0] = 0;
    sz[u] = 0;

    for (const auto &e : g[u]) {
        int v = e.to;
        ll w = e.w;
        dfs(v);

        // 树上背包合并:从大到小枚举以防止重复计算
        for (ll j = sz[u] + sz[v]; j >= 0; --j) {
            for (ll k = 1; k <= sz[v] && k <= j; ++k) {
                if (dp[u][j - k] != -INF && dp[v][k] != -INF) {
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k] - w);
                }
            }
        }
        sz[u] += sz[v];
    }
}

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

    if (!(cin >> n >> m)) return 0;

    for (int i = 1; i <= n - m; ++i) {
        int k;
        cin >> k;
        for (int j = 0; j < k; ++j) {
            int to;
            ll cost;
            cin >> to >> cost;
            g[i].push_back({to, cost});
        }
    }

    for (int i = n - m + 1; i <= n; ++i) {
        cin >> val[i];
    }

    // 初始化 dp 数组为极小值
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j <= m; ++j) {
            dp[i][j] = -INF;
        }
    }

    dfs(1);

    // 寻找在根节点净收益 >= 0 的最大用户数
    for (ll j = m; j >= 0; --j) {
        if (dp[1][j] >= 0) {
            cout << j << "\n";
            break;
        }
    }

    return 0;
}

复杂度

  • 时间复杂度: 在子树合并时,两重循环的枚举上限分别为已合并叶子数 sz[u]sz[u] 与新子树叶子数 sz[v]sz[v]。根据树形背包经典的点对计数分析,树上任意两片叶子 xx 和 yy 只有在其最近公共祖先(LCA)处的合并循环中被配对遍历一次。因此,整棵树上所有合并循环的内层执行次数之和不超过叶子点对的总数 (M2)=M(M−1)2\binom{M}{2} = \frac{M(M-1)}{2}。 加上树遍历的边开销 O(N)O(N),总时间复杂度为 O(N+M2)O(N + M^2)。当 N,M≤3000N, M \le 3000 时,总操作步数在数百万量级以内,可轻松在毫秒级内跑完。
  • 空间复杂度: DP 数组大小为 (N+1)×(M+1)(N + 1) \times (M + 1),在 N,M≤3000N, M \le 3000 时占用约 3000×3000×8 Bytes≈72 MB3000 \times 3000 \times 8 \text{ Bytes} \approx 72\text{ MB} 内存,低于标准 128MB 限制;邻接表与辅助数组占用 O(N)O(N) 空间。整体空间复杂度为 O(N⋅M)O(N \cdot M)。

总结

本题是经典的树形分组背包动态规划。解题的关键在于:

  1. 反向刻画状态:面对边权费用累加难以作为下标的难题,利用目标“最大化用户数”的性质,将用户数量反客为主作为背包容量维度,维护净收益的最大值。
  2. 严格子树上下界优化:通过严格按子树内实际叶子数 sz[u]sz[u] 限制内层循环上界,将朴素 O(N⋅M2)O(N \cdot M^2) 的复杂度成功降阶为点对计数的 O(N+M2)O(N + M^2),保证了算法的高效性。