建立虚拟根将森林统一为树,使用树上分组背包求解有依赖选课的最大总学分。
OJ: luogu
题目 ID: P2014
难度:普及+/提高-
标签:树上背包树形DP动态规划
创建: 2025-12-30 17:17
更新: 2026-10-26 14:17
形式化题目
给定
每门课程
- 若
,表示该课程无直接先修课; - 若
,则必须先选修课程 才能选修课程 。
保证依赖关系无环(依赖图为森林,至少存在一门课无先修课)。要求选择恰好
暴力解法
思路
通过 DFS 递归枚举全部
- 检查选中的课程总数是否恰好为
; - 检查选中的每门课程
的直接先修课 是否同样被选中(先修约束); - 若合法,则累加所选课程的总学分并更新全局最大值。
代码
#include <bits/stdc++.h>
using namespace std;
// brute.cpp:小数据暴力解,使用 01 序列枚举每门课程选或不选。
const int MAXN = 25;
int n, m;
int parent_course[MAXN];
int credit[MAXN];
int choose_flag[MAXN]; // choose_flag[i] = 0/1,表示第 i 门课程不选/选
int answer;
int calc_chosen_count() {
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (choose_flag[i] == 1) cnt++;
}
return cnt;
}
bool check() {
if (calc_chosen_count() != m) {
return false;
}
for (int i = 1; i <= n; i++) {
if (choose_flag[i] == 1 && parent_course[i] != 0 && choose_flag[parent_course[i]] == 0) {
return false;
}
}
return true;
}
int calc_answer() {
int sum = 0;
for (int i = 1; i <= n; i++) {
if (choose_flag[i] == 1) sum += credit[i];
}
return sum;
}
void dfs_choose(int dep) {
if (dep == n + 1) {
if (check()) {
int value = calc_answer();
if (answer < value) answer = value;
}
return;
}
// 第 dep 门课程的 01 选择:0 不选,1 选。
for (int i = 0; i <= 1; i++) {
choose_flag[dep] = i;
dfs_choose(dep + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> parent_course[i] >> credit[i];
}
answer = 0;
dfs_choose(1);
cout << answer << '\n';
return 0;
}复杂度
- 时间复杂度:
。每次生成一个状态需 校验依赖和统计答案。 - 空间复杂度:
。递归栈深度及选择标记数组占用线性空间。
瓶颈
状态空间随着课程数指数级暴涨。对于
正解
思路
1. 建立虚拟根统一森林
由于原问题的依赖关系可能包含多棵互不连通的树(森林),引入一个编号为
- 虚拟根学分设为
( ); - 所有没有直接先修课(
)的课程均以 为父节点。
这样所有节点连接成一棵以
2. 状态定义
定义
- 若不选
,则根据先修规则, 的任何子树节点都不可选,学分为 或不参与子树贡献;强制选 可使状态转移具有清晰的连通性递推关系。 - 基础边界:对任意节点
,选 个节点(即仅选 本身)时, ;其余不可达状态初始化为极小值 。
3. 树上分组背包转移
遍历节点
- 子节点
的整棵子树相当于一个“物品组”。 - 我们需要将分配给以
为根的剩余总名额 中,分出 个名额给子树 。 - 为了保证每组物品只转移一次(标准的 0/1 背包 / 分组背包思想),名额
必须从大到小倒序枚举:
其中:
从 倒序枚举至 ; 从 枚举至 (因为节点 本身至少占用 个名额,分给子树 的名额至多为 ); - 转移前需保证
与 均为有效可达状态。
4. 目标答案
最终答案即为在以虚拟根
转移演算: 样例上的虚拟根与背包合并
样例 7 4 的先修关系与学分是
| 课程 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 先修 |
2 | 0 | 0 | 2 | 7 | 7 | 2 |
| 学分 |
2 | 1 | 4 | 1 | 1 | 6 | 2 |
加上虚拟根
flowchart TB Z["0 (虚拟根, s=0)"] --> C2["2 (s=1)"] Z --> C3["3 (s=4)"] C2 --> D1["1 (s=2)"] C2 --> D4["4 (s=1)"] C2 --> D7["7 (s=2)"] D7 --> E5["5 (s=1)"] D7 --> E6["6 (s=6)"]
下表列出各结点最终的
| 结点 |
|||||
|---|---|---|---|---|---|
| — | — | ||||
| — | — | — | — | ||
| — | — | — | — | ||
| — | — | — | — | ||
| — | — | — | — | ||
| — | — | — | — |
读表注意三处. 第一, 叶子结点(
这说明
为什么名额要倒序枚举
正序枚举
| 阶段 | |||
|---|---|---|---|
| 初始化(只有 |
|||
| 合并 |
|||
| 合并 |
合并
代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 305;
const int NEG = -1000000000;
int n, m;
int credit[MAXN];
vector<int> children[MAXN];
int dp[MAXN][MAXN]; // dp[u][j]:在 u 的子树中选 j 门课,并且必须选 u,能得到的最大学分。
void read_input() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int parent;
cin >> parent >> credit[i];
children[parent].push_back(i);
}
}
void dfs(int u) {
for (int j = 0; j <= m + 1; j++) {
dp[u][j] = NEG;
}
// 若选择 u,至少占用 1 个名额。虚拟根 0 的学分为 0。
dp[u][1] = credit[u];
for (int idx = 0; idx < (int)children[u].size(); idx++) {
int v = children[u][idx];
dfs(v);
// 树上背包:把总名额 j 分一部分 k 给孩子 v 的子树。
for (int j = m + 1; j >= 1; j--) {
for (int k = 1; k < j; k++) {
if (dp[u][j - k] == NEG || dp[v][k] == NEG) {
continue;
}
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
}
}
void solve() {
dfs(0);
cout << dp[0][m + 1] << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
- 时间复杂度:
(使用子树大小上下界精细剪枝可达 ,但当前代码枚举上限为 ,在 下常数极小,可通过所有测试点)。 - 空间复杂度:
,即 DP 状态数组占用的内存空间。
总结
- 虚拟根建树技巧:面对多根树或森林结构上的依赖决策时,添加学分/代价为 0 的虚拟超根统一入口,将全局选
转化为新树强制选根且容量为 的标准树形背包。 - 强制选根化简状态:在树形背包中,“选子节点必选父节点”的依赖使得定义状态包含根
变得非常自然且避免了冗余分支分类讨论。 - 分组背包倒序枚举:将每个子树视为一组互斥容量选择,倒序枚举总名额
,确保每个子树组只对当前阶段贡献一次。