将用户数作为背包容量维度,利用树形背包 DP 求解在净收益非负条件下的最大转播用户数。
OJ: luogu
题目 ID: P1273
难度:普及+/提高-
标签:树形 DP背包 DP动态规划
创建: 2026-10-23 23:05
更新: 2026-10-26 14:13
形式化题目
给定一棵包含
树上的每条有向边
若要向一个叶子节点子集
求在满足总收入不小于总成本(即净收益
暴力解法
思路
树上的叶子节点总数为
- 枚举一个二进制掩码
,其中第 位为 表示选取第 个叶子节点。 - 对于每种选法,从每个被选中的叶子节点沿着树边向上攀爬到根节点
,标记所有被激活的树边。 - 统计被激活边的边权总和作为总成本,累加选中叶子的付费作为总收入。
- 若总收入
总成本,则用当前选中的叶子个数更新答案的最大值。
代码
/**
* 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;
}复杂度
- 时间复杂度:共有
种叶子子集,每次检验需沿树向上标记边,耗时 。整体时间复杂度为 。 - 空间复杂度:存储整棵树及父节点指针需
空间。
瓶颈
暴力的时间复杂度随着用户数量
瓶颈在于暴力枚举完全忽视了树形结构的重叠子问题和子树最优子结构:
在以任意节点
正解
思路
1. 状态维度对调与背包设计
若以“花费或收益”作为 DP 状态的维度,由于边权与费用的累加值域较大且不规则,状态规模难以控制。反之,由于用户数量
- 容量维度:以“选中的叶子用户数量”作为状态维度(范围从
到 )。 - DP 状态值:维护在当前子树内选定该数量用户时所能取得的最大净收益(总收入 - 内部总成本)。
定义
2. 状态初始化
- 默认所有状态初始化为负无穷
,表示该方案不可行(注意净收益本身可能为负数,但必须是合法连通的状态才能参与转移)。 - 对于任何节点
,选 个用户时不需要激活子树内任何边,净收益为 ,即 。 - 对于叶子用户节点
( ):它只有自身 个用户,若选 个用户则获得收益 ,即 ;子树叶子数 。 - 对于中转站节点
( ):初始时已合并的叶子数 。
3. 状态转移方程
对于非叶子节点
将子树
说明:
- 当
时,子树 贡献了 的收益,但由于信号需要从 传送到 ,必须支付连接边 的成本 。 - 当
时,无需激活边 ,贡献为 ,相当于保留原状态 ,无需显式减去边权。
更新完所有
为了看清这个转移到底在做什么, 用一个
- 节点
是根, 它唯一的孩子是节点 , 边权 ; - 节点
有两个孩子, 都是用户终端: 节点 ( , ) 与节点 ( , ).
先在节点
| 总用户数 |
合并前 |
合并后 |
|||
|---|---|---|---|---|---|
| — | — | ||||
| — | |||||
读表时注意三件事: 第一,
下面这张图把
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
图中上方是
4. 提取答案
整棵树的 DFS 遍历结束后,根节点的状态
代码
/**
* 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;
}复杂度
- 时间复杂度:
在子树合并时,两重循环的枚举上限分别为已合并叶子数
与新子树叶子数 。根据树形背包经典的点对计数分析,树上任意两片叶子 和 只有在其最近公共祖先(LCA)处的合并循环中被配对遍历一次。因此,整棵树上所有合并循环的内层执行次数之和不超过叶子点对的总数 。 加上树遍历的边开销 ,总时间复杂度为 。当 时,总操作步数在数百万量级以内,可轻松在毫秒级内跑完。 - 空间复杂度:
DP 数组大小为
,在 时占用约 内存,低于标准 128MB 限制;邻接表与辅助数组占用 空间。整体空间复杂度为 。
总结
本题是经典的树形分组背包动态规划。解题的关键在于:
- 反向刻画状态:面对边权费用累加难以作为下标的难题,利用目标“最大化用户数”的性质,将用户数量反客为主作为背包容量维度,维护净收益的最大值。
- 严格子树上下界优化:通过严格按子树内实际叶子数
限制内层循环上界,将朴素 的复杂度成功降阶为点对计数的 ,保证了算法的高效性。
