当前位置: 首页 > news >正文

树上背包学习笔记

树上背包,顾名思义,就是在树上跑背包。每日顾名思义

Q:那么到底为什么要树上跑背包 dp 呢?

A:因为我们到现在学的背包 dp 还是属于较浅的一类,什么 01 背包、完全背包还是多重背包,但是如果这个东西变得较为复杂一些,例如如果存在了依赖关系(即选某个东西才可以选另一个东西),前面的背包就束手无策了。

实际上,树上背包就是把背包 dp 用到了树的上面。

不如先看一个引入题。

P2014 [CTSC1997] 选课

在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有 N N N 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程 a,才能学习课程 b)。一个学生要从这些课程里选择 M M M 门课程学习,问他能获得的最大学分是多少?

N , M ≤ 300 N,M \le300 N,M300

发现每一个东西都可能存在一个依赖关系,果断使用树上背包。

注意到题面中有这样的一句话:

每门课有一门或没有直接先修课

联想到树上面去,发现每门课如果有一门先修课,就类似于每一个儿子都恰好有一个父亲。每门课如果没有先修课,就类似于每一个根结点。

于是考虑根据先修课的关系来建图,最终会形成一个森林的形式,每一棵树之间互不干扰。

但是这样的话,又会产生问题:最终是所有的点中选择 M M M 个,而不是单棵树中选择 M M M 个。

所以要将森林重新变成一颗更大的树。考虑 0 0 0 号虚点,作为新的树的根结点,并连接原本每一棵树的根结点作为儿子。

但是这时候有一个小细节:因为 0 0 0 是必选的(否则其他东西都选不了),所以直接将 m + 1 → m m+1 \to m m+1m 即可。


于是我们成功地将问题转化成了:

给定一棵树,点带权,需要你在里面选 M M M 个点,注意如果要选儿子就必须要选父亲。求最终权值。特别地, 0 0 0 的权值为 0 0 0

考虑 d p dp dp。设 d p u , i dp_{u,i} dpu,i 表示在 u u u 为根的子树内,选 i i i 个可以得到的最大权值是多少。

考虑转移,但是好像遇到了亿点点麻烦。。。显然可以枚举每一个子结点的子树选了多少个,最终合并起来得到答案,但是太慢了。

所以,这样的状态并不能行得通。因此,考虑改变状态。


注意我们讲的是树上背包,但是我们只是在树上跑了 dp,并没有跑背包。所以考虑背包。

显然可以把点权变成价值,而把 1 1 1 作为每一个点的代价,而每一个点可以选择也可以不选择。这样就转换为了一个背包问题。

考虑使用 d p u , i , j dp_{u,i,j} dpu,i,j 表示 u u u 为根的子树内,考虑了前 i i i 个子结点,选了 j j j 个可以得到的最大权值是多少。

转移显然。为了优化转移方程,可以使用滚动数组。因为这是 01 背包所以需要倒序处理。

#include <bits/stdc++.h>
using namespace std;
#define int long long
#define N 310
int n, m, x, y, dp[N][N], val[N];
vector<int> edge[N];void dfs(int u) {dp[u][1] = val[u];for (auto v : edge[u]) {dfs(v);for (int i = m; i >= 1; i--)//转移 u 选了 i 节课的答案for (int j = i - 1; j >= 1; j--)//u 的这个儿子 v 选了 i - j 节课的答案dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j]);//合并两个背包}
}signed main() {ios::sync_with_stdio(0);cin >> n >> m;m++;for (int x = 1; x <= n; ++x) {cin >> y >> val[x];edge[y].push_back(x);}dfs(0);cout << dp[0][m] << endl;return 0;
}

这里的复杂度是 O ( n × m 2 ) O(n \times m^2) O(n×m2) 的,有一些慢,后面我们会讲更加快速的做法。

观察 dfs 里面的转移步骤,可以发现这个东西本身其实上可以说是把子结点的背包合并了。这个观点很好理解。所以,这种求解树上背包的方法也叫做dfs 合并

所以这个有依赖的背包就这么做完了,但是以后的树上背包大差不差的都是一个样子,所以就可以按照固定的东西想即可。


P2014 更好的写法

实际上这里还有一种实现方法,那就是使用 s u m sum sum 来把转移次数优化一下,尤其是 n , m n,m n,m 同阶的时候。

到底是怎么思考的呢?

很容易发现,上面的代码有一行是 for (int i = m; i >= 1; i--),但是实际上我们并不需要枚举那么多次,实际上只需要枚举 s u m sum sum 次就可以了。(当然如果 min ⁡ ( m , s u m ) \min(m,sum) min(m,sum) 还可以更快)

所以把 for (int i = m; i >= 1; i--) 更改成 for(int i = min(m, sum[u]);i >= 0;i--) 即可。

考虑同样把这个东西用到 for (int j = i - 1; j >= 1; j--) 上面去。但是这样子是做差的,所以考虑枚举 v 选了多少课,以代替以前的枚举。

于是:

for (int j = i - 1; j >= 1; j--)dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j]);

变成了:

for(int j = min(m, sum[v]);j >= 0;--j)dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);//把减变成了加

最终得出来这样的代码:

void dfs(int u,int pre)
{/* DP初始化 */ sum[u] = 1;for(auto v : edge[u]) if(v != pre) {dfs(v,u);for(int i = min(m, sum[u]);i >= 0;--i)//枚举 u 选了 i 节课for(int j = min(m, sum[v]);j >= 0;--j) //枚举 v 选了 j 节课dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);sum[u] += sum[v];}
}

略加数学分析可得这样的代码复杂度是 O ( n 2 ) O(n^2) O(n2)

是这样分析的:对于每一个 u u u 都最多要转移 s u m u × n sum_u \times n sumu×n 次,而把所有结点加起来就是 ∑ ( s u m u × n ) = ∑ s u m u × n = n × n \sum (sum_u \times n) = \sum sum_u \times n = n \times n (sumu×n)=sumu×n=n×n,是不是很奇妙!

当然,如果 m m m 远小于 n n n,则使用 O ( n × m 2 ) O(n \times m^2) O(n×m2) 会优秀一点,否则使用 O ( n 2 ) O(n^2) O(n2) 优秀一点,这两个东西都不可以抛弃掉。

U53204 【数据加强版】选课

这道题是 P2014 [CTSC1997] 选课 的一个超级数据加强版,原本 O ( n 2 ) O(n^2) O(n2) 地算法只能拿到 50 分。

这道题,如果使用上面给出的代码并略加修改,只能得到 50pts 的好成绩。所以上面的方法在这道题是行不通的。

所以,在这道题里面我将介绍一种新的树形背包方法,也就是在 dfs 序上面进行的奇妙 dp

这样复杂度是 O ( n m ) O(nm) O(nm) 的,也是非常优秀的了。而且题目保证了 n m ≤ 1 0 8 nm \le 10^8 nm108,使用这种方法恰好可以卡过。


接下来开始讲解思路。

考虑先看一棵树,作为例子:

显然我们可以发现,如果最上面的点被选了,那么就可以考虑它最左边的一个儿子。

如果这个儿子被选了,那么就可以继续考虑它最左边的一个儿子。

如果它的左边的儿子已经考虑完了,那么就可以开始考虑右边的儿子。

如果这个儿子没有被选,则显然不能考虑任何一个儿子。

……

最终,考虑的顺序是这样子的:

那么,看到这个东西,你的脑子里面还没有一点点灵感吗?

这不就是 dfs 序嘛!

考虑转移。

如果一个点被选了,它就会转移到下一个 dfs 序代表的位置。

而且,如果一个点没有被选,则其子树所有的点都不能被选!!!完美符合 dfs 序的优秀传统:只需要跳到子树区间右端点 + 1 +1 +1 的位置即可。

所以,这个时候我们就已经把它转换为了一个序列式的 dp,且转移也已经明确,直接跑背包即可。复杂度显然就是 O ( n m ) O(nm) O(nm) 的。

**但是因为这不是相邻进行转移,而是会带有一些跳跃性地转移,所以不能使用滚动数组。空间复杂度也是 O ( n m ) O(nm) O(nm) 的。**但是空间有 500 500 500 MB,所以直接开 int 数组也可以开的下。

个人感觉这种方法非常的优美。


关于实现的一些注意事项:

  • 因为数据范围只给了 n m nm nm 的取值,并没有特别地约束 n , m n,m n,m 分别的值,如果开数组的话就会开不下,应该在主函数里面开 vector 二维数组。

  • 一开始要让 v a l 0 = 1 val_0 = 1 val0=1,不然后面就无法转移,最后直接把答案减一即可。

其他的看代码就可以了。

#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, m, x, y;
int val[N], l[N], r[N];
int id[N], dfn;
vector<int> v[N];void dfs(int u) {l[u] = ++dfn, id[dfn] = u;for (auto i : v[u])dfs(i);r[u] = dfn;
}int main() {cin >> n >> m;m++;for (int x = 1; x <= n; x++) {cin >> y >> val[x];v[y].push_back(x);}dfs(0);val[0] = 1;vector<vector<int> > dp(n + 10, vector<int>(m + 10));//二维数组for (int i = 1; i <= n + 1; i++) {int x = id[i];for (int j = 0; j <= m; j++)if (dp[i][j] > 0 || i == 1) {//要判断这是否是合法状态dp[i + 1][j + 1] = max(dp[i + 1][j + 1], dp[i][j] + val[x]);dp[r[x] + 1][j] = max(dp[r[x] + 1][j], dp[i][j]);}}cout << dp[n + 2][m] - 1;return 0;
}

CF815C Karen and Supermarket

仿照日常的树上背包,不难设计出 d p u , n u m , 0 / 1 dp_{u,num,0/1} dpu,num,0/1 表示 u u u 的子树里面选了 n u m num num 个物品, u u u 不使用 / 使用优惠券的最小花费。

考虑如何写出转移。

首先,我们可以不用 u u u,所以子树内也就没有点会被使用:

d p u , i + j , 0 = min ⁡ ( d p u , i + j , 0 , d p u , i , 0 + d p v , j , 0 ) dp_{u,i+j,0} = \min(dp_{u,i+j,0},dp_{u,i,0}+dp_{v,j,0}) dpu,i+j,0=min(dpu,i+j,0,dpu,i,0+dpv,j,0)

其次,我们可以使用 u u u,且对于 v v v,这个 u u u 的子结点也使用:

d p u , i + j , 1 = min ⁡ ( d p u , i + j , 1 , d p u , i , 1 + d p v , j , 1 ) dp_{u,i+j,1} = \min(dp_{u,i+j,1},dp_{u,i,1}+dp_{v,j,1}) dpu,i+j,1=min(dpu,i+j,1,dpu,i,1+dpv,j,1)

最后,我们可以使用 u u u,但是对于 v v v 不使用优惠券:

d p u , i + j , 1 = min ⁡ ( d p u , i + j , 1 , d p u , i , 1 + d p v , j , 0 ) dp_{u,i+j,1} = \min(dp_{u,i+j,1},dp_{u,i,1}+dp_{v,j,0}) dpu,i+j,1=min(dpu,i+j,1,dpu,i,1+dpv,j,0)


考虑初始状态,显然 n u m = 0 num=0 num=0 n u m = 1 num=1 num=1 的情况我们会选择特殊考虑。

{ d p u , 0 , 0 = 0 d p u , 1 , 0 = a u d p u , 1 , 1 = a u − b u \begin{cases} dp_{u,0,0} = 0\\ dp_{u,1,0} = a_u \\ dp_{u,1,1} = a_u - b_u \end{cases} dpu,0,0=0dpu,1,0=audpu,1,1=aubu

其他的东西都可以根据这几个初始状态算出来。


答案显然就是找最大的 i i i 来满足 min ⁡ ( d p 1 , i , 0 , d p 1 , i , 1 ) ≤ m o n e y \min(dp_{1,i,0},dp_{1,i,1}) \le money min(dp1,i,0,dp1,i,1)money,其中 m o n e y money money 是她的预算。

#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5010;
int n, m, a[N], b[N];
int dp[N][N][2], siz[N];
vector<int> v[N];void dfs(int u) {dp[u][0][0] = 0;dp[u][1][0] = a[u];dp[u][1][1] = a[u] - b[u];siz[u] = 1;for (int x : v[u]) {dfs(x);for (int i = siz[u]; i >= 0; i--)for (int j = 0; j <= siz[x]; j++)dp[u][i + j][0] = min(dp[u][i + j][0], dp[u][i][0] + dp[x][j][0]),dp[u][i + j][1] = min(dp[u][i + j][1], dp[u][i][1] + min(dp[x][j][0], dp[x][j][1]));siz[u] += siz[x];}
}signed main() {cin >> n >> m >> a[1] >> b[1];memset(dp, 0x3f, sizeof dp);for (int i = 2, x; i <= n; i++)cin >> a[i] >> b[i] >> x, v[x].push_back(i);dfs(1);for (int i = n; i >= 0; i--)if (min(dp[1][i][0], dp[1][i][1]) <= m) {cout << i;return 0;}return 0;
}

相关文章:

  • 【mysql】常用命令
  • vue源代码采用的设计模式分解
  • accept() reject() hide()
  • Select Rows组件研究
  • 使用Java和LangChain4j实现人工智能:从分类到生成式AI
  • stm32之输出比较OC和输入捕获IC
  • SQLite数据类型
  • Class AB OPA corner 仿真,有些corenr相位从0开始
  • 使用ZYNQ芯片和LVGL框架实现用户高刷新UI设计系列教程(第十一讲)
  • 人工智能100问☞第15问:人工智能的常见分类方式有哪些?
  • 2025年软件工程与数据挖掘国际会议(SEDM 2025)
  • Three.js和WebGL区别、应用建议
  • 大模型在宫颈癌诊疗全流程预测与应用研究报告
  • 【免费试用】LattePanda Mu x86 计算模块套件,专为嵌入式开发、边缘计算与 AI 模型部署设计
  • [论文阅读]MCP Guardian: A Security-First Layer for Safeguarding MCP-Based AI System
  • VMware搭建ubuntu保姆级教程
  • NGINX `ngx_http_browser_module` 深度解析与实战
  • 数据中台架构设计
  • 分布式开发:数字时代的高性能架构革命-为什么要用分布式?优雅草卓伊凡
  • IP-Adapter
  • 印对巴军事打击后,巴外交部召见印度驻巴临时代办
  • 动物只有在被认为对人类有用时,它们的建筑才会被特别设计
  • 印媒证实:至少3架印军战机7日在印控克什米尔地区坠毁
  • A股三大股指集体高开大涨超1%,券商、房地产涨幅居前
  • 经济日报:落实落细更加积极的财政政策
  • 抗战回望18︱《广西学生军》:“广西的政治基础是青年”