1. 从一道经典题看树形依赖背包的本质
1.1 这道题到底在问什么
先把题目场景用大白话翻译一遍。假设你是一名刚入学的研究生,培养方案里列了若干门课,每门课有对应的学分。但选课不是完全自由的——有些课有"先修要求",也就是说你想选某门课,必须先选它指定的前置课。比如"高级算法"要求先修"数据结构","数据结构"又要求先修"程序设计基础"。这些依赖关系层层嵌套,最终形成一棵以"无前置课"的课程为根的树(或者森林,因为可能有多门课都没有前置要求)。
现在你最多只能选 M 门课,问能拿到的最大总学分是多少。
这道题是 CTSC1997 的题目,编号 P2014,在算法竞赛圈里几乎是"树形依赖背包"的代名词。它的核心难点在于:选课之间存在父子依赖,选了子节点就必须选父节点。这种"选了儿子必须选爸爸"的约束,让普通的 0/1 背包直接失效,必须把背包搬到树结构上做。
1.2 为什么它值得反复琢磨
很多人第一次做这道题,会本能地想到"把树拍平成序列然后跑背包",结果发现根本处理不了依赖关系。也有人想到"枚举每个子树选几门课",但不知道状态怎么定义、转移怎么写。这道题真正的价值,在于它逼着你理解一个通用模型:树形依赖背包。
这个模型的应用范围远超一道竞赛题。你在实际工作中会反复遇到它的变体:项目排期里"做任务 B 必须先完成任务 A"、技能树里"点高级技能必须先点满前置技能"、产品功能模块的依赖打包、甚至组织架构里的预算分配。只要约束是"树状依赖 + 总量限制 + 求最优",底层就是这道题。
所以这篇文章我不打算只给你一份能 AC 的代码,而是把这道题从建模、状态定义、转移推导、边界处理到代码实现,一层层拆开讲透。看完之后,你应该能独立地把任何"树形依赖 + 容量限制"的问题套进这个框架里。
1.3 适合谁来读
如果你正在学动态规划,已经会写 0/1 背包和分组背包,但一遇到树结构就懵,那这篇就是为你写的。如果你已经能背出这道题的代码,但说不清楚"为什么第二层循环要倒序""为什么虚拟根节点要加一个",那也建议你读完,把那些"背下来的细节"变成"真正理解的东西"。整篇内容我会尽量用生活化的类比配合严谨的推导,保证你既能看懂,也能自己写出来。
2. 建模:把森林变成一棵树的关键一步
2.1 依赖关系为什么天然是树
先想清楚一个问题:为什么课程依赖关系一定构成树,而不是图?因为如果存在环,比如 A 依赖 B、B 又依赖 A,那这两门课谁都选不了,逻辑上直接矛盾。而如果一门课有多个前置课(比如"机器学习"要求同时修完"概率论"和"线性代数"),那它就不是树而是 DAG(有向无环图),处理起来会复杂得多。本题的设定是每门课至多有一个直接前置课,这就保证了每个节点只有一个父亲,整张图退化成森林。
森林的意思是:可能有好几门课都没有前置要求,它们各自成为一棵树的根。比如"体育""思政""英语"可能都没有前置,它们就是三棵独立的树。这时候如果直接对森林做树形 DP,会非常别扭,因为你要分别处理每一棵树,还要在它们之间分配选课名额。
2.2 虚拟根节点的妙用
解决森林问题的标准套路,是人为添加一个虚拟根节点 0,让它成为所有"无前置课"的课程的父节点。这个虚拟根节点的学分是 0,选它不消耗任何实际意义的名额(或者说它本身不算一门课)。
这样一来,森林就变成了一棵以 0 为根的大树。原本"最多选 M 门课"就变成了"在以 0 为根的树里,选 M+1 个节点(包含虚拟根),其中虚拟根必选"。为什么是 M+1?因为虚拟根占了一个位置,但它不贡献学分,所以实际有效的选课数还是 M。
提示:这个"加虚拟根"的技巧是树形依赖背包的标配。凡是遇到森林,第一反应就是加一个虚拟根把它连成一棵树。这个根节点的权值通常设为 0,代价设为 1(占一个容量),这样转移公式可以统一处理,不用为"根节点是否必选"写特判。
2.3 状态定义:dp[u][j] 到底代表什么
状态定义是整道题的地基,定义错了后面全崩。我们定义:
dp[u][j] 表示在以 u 为根的子树中,选择 j 门课(必须包含 u 本身)所能获得的最大总学分。
注意这里有两个关键约束:第一,范围限定在 u 的子树内;第二,必须包含 u。为什么必须包含 u?因为如果选了子树里的任何节点,根据依赖关系,路径上的所有祖先都必须被选,u 作为子树的根自然必选。这个"必选 u"的约定,让转移变得干净利落。
那么最终答案是什么?是在以虚拟根 0 为根的整棵树里,选 M+1 个节点(包含 0)的最大学分,也就是dp[0][M+1]。因为 0 的学分是 0,所以它不影响结果,但占了 1 个名额。
2.4 为什么不用 dp[u][j] 表示"不包含 u"
有人会问,能不能定义成"子树里选 j 门课,不要求包含 u"?可以,但转移会变得非常麻烦。因为一旦不要求包含 u,你就要在转移时区分"选 u"和"不选 u"两种情况,而"不选 u"意味着整个子树都不能选(因为子节点依赖 u),这又退化成了平凡情况。所以"必选 u"的定义是最省事的,它把"选不选 u"这个决策提前固定下来,转移时只需要考虑"在 u 的各个孩子之间怎么分配名额"。
3. 转移推导:树形背包的核心逻辑
3.1 从分组背包说起
理解树形背包最好的跳板是分组背包。回忆一下分组背包:有若干组物品,每组里最多选一个,求容量限制下的最大价值。它的转移是"逐组考虑,每组枚举选哪个"。
树形背包可以类比成:每个孩子子树就是一个"组",你要决定给这个孩子分配多少名额。给某个孩子分配 k 个名额,意味着在这个孩子的子树里选 k 门课,能获得的最大收益就是dp[child][k]。于是问题变成:在 u 的所有孩子之间分配名额,每个孩子分配的名额数是一个决策,总名额不能超过 j-1(因为 u 自己占了 1 个)。
这就是一个典型的"分组背包"结构,只不过每个"组"(孩子子树)的"物品"是"分配 k 个名额"这个选项,它的价值是dp[child][k],代价是 k。
3.2 转移方程的推导
设 u 的孩子为 v1, v2, ..., vk。我们用一个中间状态f[j]表示"当前已经考虑过的若干孩子中,总共分配 j 个名额(不含 u 自己)的最大收益"。初始时f[0] = 0,其余为负无穷。
每加入一个孩子 v,我们枚举给 v 分配的名额数 t(从 0 到剩余容量),更新:
新 f[j] = max(旧 f[j], 旧 f[j-t] + dp[v][t])处理完所有孩子后,dp[u][j] = f[j-1] + score[u](j 从 1 开始,因为 u 自己占 1 个名额)。
把上面的过程写成更紧凑的形式,就是常见的写法:
for each child v of u: for j from current_max down to 1: for t from 1 to j-1: dp[u][j] = max(dp[u][j], dp[u][j-t] + dp[v][t])这里dp[u][j]在遍历孩子之前初始化为score[u](只选 u 自己),然后逐个孩子合并进来。
3.3 循环顺序为什么这么讲究
这是最容易出错的地方,必须讲透。
第一层:枚举孩子 v。这是分组背包的"组",必须在外层,因为每个孩子只能被合并一次。
第二层:枚举 j,必须倒序。为什么倒序?因为dp[u][j]的更新依赖于dp[u][j-t],而j-t < j。如果正序枚举 j,那么dp[u][j-t]可能已经被当前这个孩子 v 更新过了,相当于同一个孩子被用了两次,这就变成了"完全背包"式的错误。倒序保证dp[u][j-t]还是"考虑之前孩子"的旧值,符合 0/1 的语义。
第三层:枚举 t,正序倒序都行。因为dp[v][t]是孩子 v 的最终状态,不依赖于 u 的更新过程,所以 t 的顺序无所谓。但通常写成从 1 到 j-1。
注意:第二层倒序是这道题最经典的坑。我见过太多人正序写然后 WA 到怀疑人生。记住一句话——只要转移里出现了"用同一维度的更小下标更新更大下标",就要警惕是否需要倒序。
3.4 复杂度分析
朴素写法看起来是三层循环,复杂度似乎是 O(n * M^2)。但仔细分析会发现,如果对每个节点 u,第二层枚举到"当前已合并的子树大小",第三层枚举到"孩子 v 的子树大小",那么总的合并代价是 O(n * M) 级别的(这是树形背包的经典结论,类似"每对节点只在 LCA 处贡献一次")。
不过对于本题的数据范围(n ≤ 300 左右),即使写成 O(n * M^2) 也能过。所以初学者不必纠结优化,先把正确性写对。等你熟练了,再考虑用子树大小限制循环上界来加速。
4. 代码实现:从建树到输出的完整流程
4.1 建树与虚拟根的处理
先看输入格式:第一行两个整数 n 和 m,表示课程数和最多选课数。接下来 n 行,每行两个整数,第一个是该课的前置课编号(0 表示无前置),第二个是该课的学分。
建树时,如果前置课编号是 0,就把它的父亲设为虚拟根 0;否则父亲就是那个编号。用邻接表存孩子。
#include <bits/stdc++.h> using namespace std; const int MAXN = 305; vector<int> children[MAXN]; int score[MAXN]; int dp[MAXN][MAXN]; int n, m; void dfs(int u) { // 初始化:只选 u 自己 dp[u][1] = score[u]; for (int v : children[u]) { dfs(v); // 合并孩子 v for (int j = m + 1; j >= 1; j--) { for (int t = 1; t < j; t++) { dp[u][j] = max(dp[u][j], dp[u][j - t] + dp[v][t]); } } } }注意dp数组要初始化为负无穷(除了合法状态),因为有些状态是不可达的。比如dp[u][j]当 j 超过子树大小时就是非法的。
4.2 初始化与边界处理
初始化有几个细节:
第一,dp全局初始化为一个很小的负数(比如 -1e9),表示不可达。然后在dfs里把dp[u][1]设为score[u],这是唯一确定的初始状态。
第二,虚拟根 0 的score[0] = 0,所以dp[0][1] = 0。最终答案是dp[0][m+1]。
第三,循环上界。第二层 j 从m+1开始倒序,是因为我们最多选 m+1 个节点(含虚拟根)。第三层 t 从 1 到 j-1,保证 u 自己至少占 1 个位置。
int main() { cin >> n >> m; for (int i = 1; i <= n; i++) { int pre; cin >> pre >> score[i]; children[pre].push_back(i); } // 初始化 dp 为负无穷 memset(dp, 0xc0, sizeof(dp)); dfs(0); cout << dp[0][m + 1] << endl; return 0; }4.3 一个容易忽略的细节:m+1 还是 m
很多人会纠结最终答案是dp[0][m]还是dp[0][m+1]。关键在于你怎么定义虚拟根占不占名额。
如果你把虚拟根当成"第 0 门课",它占 1 个名额,那么总共能选 m+1 个节点,答案是dp[0][m+1]。如果你在转移时特殊处理虚拟根不占名额,那答案就是dp[0][m]。两种写法都对,但要前后一致。
我推荐第一种,因为转移公式统一,不用特判。代价就是循环上界和答案下标都要 +1,稍微绕一点,但逻辑更干净。
4.4 用子树大小优化循环上界
朴素写法里,第二层 j 一直枚举到 m+1,第三层 t 枚举到 j-1,会有很多无效枚举。可以用一个size[u]数组记录子树大小,把 j 的上界限制为min(m+1, size[u]),t 的上界限制为min(j-1, size[v])。
int dfs(int u) { int sz = 1; dp[u][1] = score[u]; for (int v : children[u]) { int vsz = dfs(v); for (int j = min(m + 1, sz + vsz); j >= 1; j--) { for (int t = 1; t <= min(j - 1, vsz); t++) { dp[u][j] = max(dp[u][j], dp[u][j - t] + dp[v][t]); } } sz += vsz; } return sz; }这个优化在 n 较大时效果明显,能把复杂度压到接近 O(n*M)。对于本题数据范围,加不加都能过,但养成习惯是好事。
5. 常见问题与排查技巧实录
5.1 答案偏小或为负无穷
如果你输出的答案是负数或者一个很怪的值,八成是dp初始化没做好。memset(dp, 0xc0, sizeof(dp))把每个字节设为 0xc0,得到的整数大约是 -1e9 级别,足够小。但如果你用memset(dp, -1, ...),那得到的是 -1,而 -1 加上学分可能变成正数,导致非法状态被误用。
排查方法:在dfs里打印每个dp[u][j],看哪些状态是可达的。正常情况下,dp[u][j]当 j 不超过子树大小时应该都是可达的(因为可以只选一条链)。
5.2 循环顺序写错导致重复选课
这是最高频的错误。症状是答案比正确值大,因为同一个孩子被合并了多次。检查你的第二层循环是不是倒序。如果你写成了:
for (int j = 1; j <= m + 1; j++) // 错误!正序那dp[u][j-t]可能已经被当前孩子更新过,相当于这个孩子被选了两次。改成倒序即可。
5.3 虚拟根处理不当
如果你忘了加虚拟根,直接对每棵树分别 DP,然后试图在树之间分配名额,代码会变得极其复杂,而且容易漏掉"某棵树一门都不选"的情况。加虚拟根是标准解法,别绕远路。
另一个坑是虚拟根的score没设为 0。如果设成了别的值,最终答案会偏大。记住虚拟根只是占位,不贡献学分。
5.4 常见问题速查表
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 答案为负无穷 | dp 初始化过小或状态不可达 | 检查 dp[u][1] 是否设为 score[u] |
| 答案偏大 | 第二层循环正序,孩子被重复合并 | 改为倒序枚举 j |
| 答案偏小 | 最终答案下标写成 m 而非 m+1 | 确认虚拟根是否占名额 |
| 部分测试点 WA | 虚拟根 score 非 0 | 将 score[0] 设为 0 |
| 运行超时 | 未用子树大小限制上界 | 加 size 数组优化循环范围 |
5.5 几个实操心得
第一,写树形背包时,我习惯先把dp[u][1] = score[u]写好,再进入孩子循环。这样即使 u 没有孩子,dp[u][1]也是正确的,不会漏掉叶子节点。
第二,调试时可以在dfs返回前打印u和它的dp数组,观察状态转移是否符合预期。尤其是合并第一个孩子和第二个孩子时,dp值的变化最能暴露问题。
第三,如果题目改成"每门课有多个学分和多个代价"(比如选一门课要花时间和金钱两种资源),那就是多维树形背包,思路完全一样,只是 dp 数组多一维。理解了本题,扩展起来很轻松。
6. 从这道题延伸出去的通用套路
6.1 树形依赖背包的识别特征
什么样的题该用这套方法?我总结三个特征:第一,存在"选了 A 必须选 B"的依赖关系;第二,依赖关系构成树或森林;第三,有一个总量限制(选几门课、花多少钱、用多少时间),要求最优化某个目标。
只要满足这三条,基本就是树形依赖背包。比如"公司组织团建,每个部门有预算,部门之间有上下级关系,选了子部门的活动必须选父部门的",这就是同一道题换了层皮。
6.2 和其他树形 DP 的区别
树形 DP 是个大范畴,树形背包只是其中一支。区别在于:普通的树形 DP(比如求树的直径、最大独立集)通常不需要"容量"这一维,状态是dp[u][0/1]这种离散的;而树形背包必须有"容量"维,因为要在孩子之间分配有限的资源。
另一个容易混淆的是"树上分组背包",它和树形依赖背包本质是同一个东西,只是叫法不同。分组背包是"每组选一个",树形背包是"每个孩子分配若干名额",后者是前者的推广。
6.3 复杂度优化的通用思路
树形背包的复杂度优化有两个方向。一是用子树大小限制循环上界,把无效枚举砍掉,这是最实用的。二是用"重儿子"或"dsu on tree"等技巧进一步优化,但那是更高级的内容,本题用不上。
对于竞赛选手,我建议先把 O(n*M^2) 的写法练到闭眼能写,再考虑优化。因为很多题的数据范围根本不需要优化,写对了比写快了更重要。
6.4 一个变体:依赖关系是 DAG 怎么办
如果一门课有多个前置课,依赖关系就变成了 DAG,这时候树形背包直接失效。处理 DAG 上的依赖背包,通常要先做拓扑排序,然后用"状压"或"一般图背包"的方法,复杂度会高很多。这也是为什么本题特意限定"每门课至多一个前置课"——就是为了保证树结构,让问题可解。
所以拿到一道依赖背包题,第一件事是判断依赖图是树、森林还是 DAG。树和森林用本文的方法,DAG 就要换思路了。
我在实际写这类题的时候,最大的体会是:树形背包的难点从来不是代码,而是状态定义和循环顺序。代码模板就那么十几行,但如果你不理解"为什么 dp[u][j] 必须包含 u""为什么第二层要倒序",那换个场景你就写不出来了。把这两个问题想透,这道题以及它的一大类变体,你都能拿下。