寒假集训 树上背包
树形DP中,还有一类问题: 在状态转移时,会涉及到资源的分配,例如需要共计选择10个节点,那么如何分配这些点,在已经合并的子树中分配几个点,新合进来的子树分配几个点就需要再次决策。
这同样是一个动态规划问题-- 背包问题
因此这类问题被称为树形背包。
学习目标
- 树形DP优化常数的方式( N 3 N^3 N3 复杂度优化常数)
- 树形DP优化为 N 2 N^2 N2 的关键点,以及证明方式
- 树形DP优化为 N M NM NM 的关键点
P2014 [CTSC1997] 选课
题目描述:在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。
现在有 N N N 门功课,每门课有若干学分,分别记作 s 1 , s 2 , ⋯ , s N s_1, s_2,⋯,s_N s1,s2,⋯,sN,每门课有一门或没有直接先修课(若课程 a a a 是课程 b b b 的先修课即只有学完了课程 a a a,才能学习课程 b b b)。一个学生要从这些课程里选择 M M M 门课程学习,问他能获得的最大学分是多少?
题目保证课程安排无冲突。(即不会有 a a a 是 b b b 的先修课, b b b 也是 a a a 的先修课这类情况存在。)
题意归纳:可以在有根树中选择一些点,如果选了其中一个点,就必须要选该点及其祖先链。每个点有价值,问最大价值。
分析
与前面的树形DP的区别是什么?
- 不仅仅只关注当前节点自己选了没有,更关注自己所在的子树选择了多少
状态此时也比较显然
d p [ u , i ] dp[u,i] dp[u,i] 表示当前子树 u u u 选了 i i i 门课,能够获得的最大学分。
怎么转移?
思考1
如果参考前面的讲解,树形DP本身是递归的,所以,在思考当前整棵树的答案时,子树的答案已经知道。
那么,如何进行 dp 转移呢?
即此时 u 的子节点 v 1 , v 2 , v 3 , . . . , v k v_1,v_2,v_3,...,v_k v1,v2,v3,...,vk 全部得到,然后开始求 u 的答案。
如果直接枚举,怎么做?
暴力搜索。
思考2
大家是否可以联想,与前面学过的哪个问题很相似?
- 有 n 种物品,每种物品有 k i k_i ki 件,每件第 i i i 种物品的价值是 v a l i val_i vali ,重量是 w i w_i wi 。
求 m m m 的背包能装的最大价值。
如何将这个背包问题应用到这里?
d p [ u , i , j ] dp[u,i,j] dp[u,i,j] 在 u 这里,前 i 棵子树,共计选择 j 门课,共可以获得的最大贡献。
那么,现在有解法:
实现方式1
cpp
void dfs(int u){
for(auto v:graph[u]){
dfs(v); // 先递归求出子树的答案
}
dp[u][0][1] = score[u];
for(int i=1;i<=graph[u].size();i++){
int v=graph[u][i-1];
int siz_v=graph[v].size(); // 标记1
for(int j=1;j<=m+1;j++){ // 至少选自己这门课,才可以考虑子树
dp[u][i][j]=dp[u][i-1][j]; // 不选 v 子树,直接继承。
// 选 v 子树,枚举选多少
for(int k=0;k<=m+1;k++) if(j>k){//至少选自己这门课,才可以考虑子树
dp[u][i][j]=max(dp[u][i][j],dp[u][i-1][j-k]+dp[v][siz_v][k]);
}
}
}
}
Q: 复杂度是多少?怎么分析?
直观方便计算的复杂度:
j,k 的循环显然是 n 2 n^2 n2 。只要到了 标记1 这里,就一定要用到这个复杂度。
标记1的位置能到多少次呢?
对于每个节点,有一个子节点,就会到一次,且只到1次。
多少个子节点,到多少次。
在整棵树的视角来说,就是 n-1 个子节点。
共计 ( n − 1 ) ∗ n 2 = O ( N 3 ) (n-1)*n^2=O(N^3) (n−1)∗n2=O(N3) 的复杂度。
复杂度非常的大,如何优化?
N 3 N^3 N3 常数优化
关注内层的两个循环,跑满是否有意义呢?
联系树形dp的第二种转移方式,u 目前的子树大小是逐渐扩大的,一来根本没有这么大,所以内部的两层循环,没必要到 n 。
实现方式1
cpp
void dfs(int u){
siz[u]=1;
for(auto v:graph[u]){
dfs(v); // 先递归求出子树的答案
siz[u]+=siz[v];
}
dp[u][0][1] = score[u];
for(int i=1;i<=graph[u].size();i++){
int v=graph[u][i-1];
int siz_v=graph[v].size(); // 标记1
for(int j=1;j<=min(m+1,siz[u]);j++){ // 至少选自己这门课,才可以考虑子树
dp[u][i][j]=dp[u][i-1][j]; // 不选 v 子树,直接继承。
// 选 v 子树,枚举选多少
for(int k=0;k<=min(m+1,siz[v]);k++) if(j>k){//至少选自己这门课,才可以考虑子树
dp[u][i][j]=max(dp[u][i][j],dp[u][i-1][j-k]+dp[v][siz_v][k]);
}
}
}
}
现在的复杂度呢?貌似不好计算了。
标记1之后的复杂度怎么计算呢?
我们此时不妨考虑算法复杂度上界。
对于一个节点u来说,复杂度的计算为
c n t 1 ∗ c n t 1 + ( c n t 1 + c n t 2 ) ∗ c n t 2 + ( c n t 1 + c n t 2 + c n t 3 ) ∗ c n t 3 + . . . . cnt_1*cnt_1+(cnt_1+cnt_2)*cnt_2+(cnt_1+cnt_2+cnt_3)*cnt_3+.... cnt1∗cnt1+(cnt1+cnt2)∗cnt2+(cnt1+cnt2+cnt3)∗cnt3+....
= c n t 1 ∗ ( c n t 1 + c n t 2 + c n t 3 + . . . ) + c n t 2 ∗ ( c n t 2 + c n t 3 + c n t 4 + . . . . ) + . . . . . = cnt_1*(cnt_1+cnt_2+cnt_3+...)+ cnt_2*(cnt_2+cnt_3+cnt_4+....) + ..... =cnt1∗(cnt1+cnt2+cnt3+...)+cnt2∗(cnt2+cnt3+cnt4+....)+.....
优化至 O ( N 2 ) O(N^2) O(N2) 复杂度
核心思路
N 3 N^3 N3 优化的核心是去掉维度、滚动数组 + 按子树大小逆序枚举 ,本质是将三维的 d p [ u , i , j ] dp[u,i,j] dp[u,i,j] 压缩为二维的 d p [ u , j ] dp[u,j] dp[u,j],利用背包问题的"01背包逆序枚举"思想避免重复计算。
状态重定义
d p [ u ] [ j ] dp[u][j] dp[u][j]:以 u u u 为根的子树中选择 j j j 门课程能获得的最大学分(直接去掉"前i棵子树"维度)。
转移逻辑
- 初始状态: d p [ u ] [ 1 ] = s c o r e [ u ] dp[u][1] = score[u] dp[u][1]=score[u](选根节点自身这1门课的学分)。
- 对于每个子节点 v v v,逆序枚举当前子树选择的课程数 j j j(从当前 u u u 的子树大小往1枚举),再枚举子树 v v v 选择的课程数 k k k,转移方程:
d p [ u ] [ j ] = m a x ( d p [ u ] [ j ] , d p [ u ] [ j − k ] + d p [ v ] [ k ] ) dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]) dp[u][j]=max(dp[u][j],dp[u][j−k]+dp[v][k])
实现代码
cpp
void dfs(int u) {
// 初始化:选u自己这1门课
siz[u] = 1;
dp[u][1] = score[u];
// 遍历所有子节点
for (auto v : graph[u]) {
dfs(v);
// 逆序枚举,避免重复选择(01背包思想)
for (int j = min(m, siz[u] + siz[v]); j >= 1; j--) {
// 枚举子树v选k门课
for (int k = 0; k <= min(siz[v], j - 1); k++) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
// 更新u的子树大小
siz[u] += siz[v];
}
}
复杂度证明
- 对于每一对父子节点 ( u , v ) (u, v) (u,v),内层 j j j 和 k k k 的循环总次数为 s i z [ u ] ∗ s i z [ v ] siz[u] * siz[v] siz[u]∗siz[v](枚举范围仅到子树大小)。
- 整棵树中所有父子对的 s i z [ u ] ∗ s i z [ v ] siz[u] * siz[v] siz[u]∗siz[v] 之和的上界为 N 2 N^2 N2:
假设树是链状(最坏情况),总次数为 1 ∗ 1 + 2 ∗ 1 + 3 ∗ 1 + . . . + ( n − 1 ) ∗ 1 = O ( N 2 ) 1*1 + 2*1 + 3*1 + ... + (n-1)*1 = O(N^2) 1∗1+2∗1+3∗1+...+(n−1)∗1=O(N2);
若树是完全二叉树,总次数约为 N l o g N NlogN NlogN,远小于 N 2 N^2 N2。 - 因此整体复杂度为 O ( N 2 ) O(N^2) O(N2)。
优化至 O ( N M ) O(NM) O(NM) 复杂度
适用场景
当题目中 M M M(选择的课程总数)远小于 N N N(总课程数)时,可进一步将复杂度优化到 O ( N M ) O(NM) O(NM),核心是限制枚举范围仅到 M M M 而非子树大小。
核心思路
在 O ( N 2 ) O(N^2) O(N2) 优化的基础上,将枚举的上限从"子树大小"改为"题目要求的 M M M",因为超过 M M M 的选择数无意义(题目只要求选 M M M 门课)。
实现代码
cpp
void dfs(int u) {
siz[u] = 1;
dp[u][1] = score[u];
for (auto v : graph[u]) {
dfs(v);
// 仅枚举到M,而非子树大小
for (int j = min(m, siz[u] + siz[v]); j >= 1; j--) {
// k最大为min(siz[v], j-1, m),且k<=M
for (int k = 0; k <= min({siz[v], j - 1, m}); k++) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
siz[u] = min(siz[u] + siz[v], m); // 子树大小超过M无意义,直接截断
}
}
复杂度证明
- 外层遍历所有节点: O ( N ) O(N) O(N);
- 内层每个节点的枚举范围被限制在 M M M 以内, j j j 和 k k k 的循环总次数为 O ( M ) O(M) O(M);
- 整体复杂度为 O ( N ∗ M ) O(N*M) O(N∗M)。
关键结论
- 当 M ≪ N M \ll N M≪N 时(如 M = 1000 , N = 10000 M=1000, N=10000 M=1000,N=10000), O ( N M ) O(NM) O(NM) 远优于 O ( N 2 ) O(N^2) O(N2);
- 当 M = N M=N M=N 时, O ( N M ) O(NM) O(NM) 退化为 O ( N 2 ) O(N^2) O(N2),两种优化方式等价。
最终总结
- 原始树形背包: O ( N 3 ) O(N^3) O(N3),仅适用于极小数据量;
- 常数优化+滚动数组: O ( N 2 ) O(N^2) O(N2),适用于 N ≤ 1000 N \leq 1000 N≤1000 左右的场景;
- 限制枚举范围到M: O ( N M ) O(NM) O(NM),适用于 M ≤ 2000 M \leq 2000 M≤2000 且 N ≤ 10000 N \leq 10000 N≤10000 的场景;
- 核心思想:利用背包问题的"滚动数组+逆序枚举"压缩维度,结合子树大小/题目限制缩小枚举范围。