寒假集训笔记·树上背包

寒假集训 树上背包

树形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棵子树"维度)。

转移逻辑
  1. 初始状态: d p [ u ] [ 1 ] = s c o r e [ u ] dp[u][1] = score[u] dp[u][1]=score[u](选根节点自身这1门课的学分)。
  2. 对于每个子节点 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),两种优化方式等价。

最终总结

  1. 原始树形背包: O ( N 3 ) O(N^3) O(N3),仅适用于极小数据量;
  2. 常数优化+滚动数组: O ( N 2 ) O(N^2) O(N2),适用于 N ≤ 1000 N \leq 1000 N≤1000 左右的场景;
  3. 限制枚举范围到M: O ( N M ) O(NM) O(NM),适用于 M ≤ 2000 M \leq 2000 M≤2000 且 N ≤ 10000 N \leq 10000 N≤10000 的场景;
  4. 核心思想:利用背包问题的"滚动数组+逆序枚举"压缩维度,结合子树大小/题目限制缩小枚举范围。
相关推荐
庄周迷蝴蝶2 小时前
四、CUDA排序算法实现
算法·排序算法
以卿a2 小时前
C++(继承)
开发语言·c++·算法
I_LPL2 小时前
day22 代码随想录算法训练营 回溯专题1
算法·回溯算法·求职面试·组合问题
金融RPA机器人丨实在智能2 小时前
2026动态规划新风向:实在智能Agent如何以自适应逻辑重构企业效率?
算法·ai·重构·动态规划
czxyvX2 小时前
017-AVL树(C++实现)
开发语言·数据结构·c++
深蓝海拓2 小时前
PySide6从0开始学习的笔记(二十七) 日志管理
笔记·python·学习·pyqt
你真是饿了2 小时前
1.C++入门基础
开发语言·c++
xqqxqxxq2 小时前
Java Thread 类核心技术笔记
java·笔记
elseif1232 小时前
【C++】并查集&家谱树
开发语言·数据结构·c++·算法·图论