树上背包学习笔记
树上背包,顾名思义,就是在树上跑背包。每日顾名思义
Q:那么到底为什么要树上跑背包 dp 呢?
A:因为我们到现在学的背包 dp 还是属于较浅的一类,什么 01 背包、完全背包还是多重背包,但是如果这个东西变得较为复杂一些,例如如果存在了依赖关系(即选某个东西才可以选另一个东西),前面的背包就束手无策了。
实际上,树上背包就是把背包 dp 用到了树的上面。
不如先看一个引入题。
P2014 [CTSC1997] 选课
在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有 \(N\) 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程 a,才能学习课程 b)。一个学生要从这些课程里选择 \(M\) 门课程学习,问他能获得的最大学分是多少?
\(N,M \le300\)。
发现每一个东西都可能存在一个依赖关系,果断使用树上背包。
注意到题面中有这样的一句话:
每门课有一门或没有直接先修课
联想到树上面去,发现每门课如果有一门先修课,就类似于每一个儿子都恰好有一个父亲。每门课如果没有先修课,就类似于每一个根结点。
于是考虑根据先修课的关系来建图,最终会形成一个森林的形式,每一棵树之间互不干扰。
但是这样的话,又会产生问题:最终是所有的点中选择 \(M\) 个,而不是单棵树中选择 \(M\) 个。
所以要将森林重新变成一颗更大的树。考虑建 \(0\) 号虚点,作为新的树的根结点,并连接原本每一棵树的根结点作为儿子。
但是这时候有一个小细节:因为 \(0\) 是必选的(否则其他东西都选不了),所以直接将 \(m+1 \to m\) 即可。
于是我们成功地将问题转化成了:
给定一棵树,点带权,需要你在里面选 \(M\) 个点,注意如果要选儿子就必须要选父亲。求最终权值。特别地,\(0\) 的权值为 \(0\)。
考虑 \(dp\)。设 \(dp_{u,i}\) 表示在 \(u\) 为根的子树内,选 \(i\) 个可以得到的最大权值是多少。
考虑转移,但是好像遇到了亿点点麻烦。。。显然可以枚举每一个子结点的子树选了多少个,最终合并起来得到答案,但是太慢了。
所以,这样的状态并不能行得通。因此,考虑改变状态。
注意我们讲的是树上背包,但是我们只是在树上跑了 dp,并没有跑背包。所以考虑背包。
显然可以把点权变成价值,而把 \(1\) 作为每一个点的代价,而每一个点可以选择也可以不选择。这样就转换为了一个背包问题。
考虑使用 \(dp_{u,i,j}\) 表示 \(u\) 为根的子树内,考虑了前 \(i\) 个子结点,选了 \(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 \times m^2)\) 的,有一些慢,后面我们会讲更加快速的做法。
观察 dfs 里面的转移步骤,可以发现这个东西本身其实上可以说是把子结点的背包合并了。这个观点很好理解。所以,这种求解树上背包的方法也叫做dfs 合并。
所以这个有依赖的背包就这么做完了,但是以后的树上背包大差不差的都是一个样子,所以就可以按照固定的东西想即可。
P2014 更好的写法
实际上这里还有一种实现方法,那就是使用 \(sum\) 来把转移次数优化一下,尤其是 \(n,m\) 同阶的时候。
到底是怎么思考的呢?
很容易发现,上面的代码有一行是 for (int i = m; i >= 1; i--)
,但是实际上我们并不需要枚举那么多次,实际上只需要枚举 \(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)\)。
是这样分析的:对于每一个 \(u\) 都最多要转移 \(sum_u \times n\) 次,而把所有结点加起来就是 \(\sum (sum_u \times n) = \sum sum_u \times n = n \times n\),是不是很奇妙!
当然,如果 \(m\) 远小于 \(n\),则使用 \(O(n \times m^2)\) 会优秀一点,否则使用 \(O(n^2)\) 优秀一点,这两个东西都不可以抛弃掉。
U53204 【数据加强版】选课
这道题是 P2014 [CTSC1997] 选课 的一个超级数据加强版,原本 \(O(n^2)\) 地算法只能拿到 50 分。
这道题,如果使用上面给出的代码并略加修改,只能得到 50pts 的好成绩。所以上面的方法在这道题是行不通的。
所以,在这道题里面我将介绍一种新的树形背包方法,也就是在 dfs 序上面进行的奇妙 dp。
这样复杂度是 \(O(nm)\) 的,也是非常优秀的了。而且题目保证了 \(nm \le 10^8\),使用这种方法恰好可以卡过。
接下来开始讲解思路。
考虑先看一棵树,作为例子:
显然我们可以发现,如果最上面的点被选了,那么就可以考虑它最左边的一个儿子。
如果这个儿子被选了,那么就可以继续考虑它最左边的一个儿子。
如果它的左边的儿子已经考虑完了,那么就可以开始考虑右边的儿子。
如果这个儿子没有被选,则显然不能考虑任何一个儿子。
……
最终,考虑的顺序是这样子的:
那么,看到这个东西,你的脑子里面还没有一点点灵感吗?
这不就是 dfs 序嘛!
考虑转移。
如果一个点被选了,它就会转移到下一个 dfs 序代表的位置。
而且,如果一个点没有被选,则其子树所有的点都不能被选!!!完美符合 dfs 序的优秀传统:只需要跳到子树区间右端点 \(+1\) 的位置即可。
所以,这个时候我们就已经把它转换为了一个序列式的 dp,且转移也已经明确,直接跑背包即可。复杂度显然就是 \(O(nm)\) 的。
但是因为这不是相邻进行转移,而是会带有一些跳跃性地转移,所以不能使用滚动数组。空间复杂度也是 \(O(nm)\) 的。但是空间有 \(500\) MB,所以直接开 int 数组也可以开的下。
个人感觉这种方法非常的优美。
关于实现的一些注意事项:
-
因为数据范围只给了 \(nm\) 的取值,并没有特别地约束 \(n,m\) 分别的值,如果开数组的话就会开不下,应该在主函数里面开 vector 二维数组。
-
一开始要让 \(val_0 = 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
仿照日常的树上背包,不难设计出 \(dp_{u,num,0/1}\) 表示 \(u\) 的子树里面选了 \(num\) 个物品,\(u\) 不使用 / 使用优惠券的最小花费。
考虑如何写出转移。
首先,我们可以不用 \(u\),所以子树内也就没有点会被使用:
其次,我们可以使用 \(u\),且对于 \(v\),这个 \(u\) 的子结点也使用:
最后,我们可以使用 \(u\),但是对于 \(v\) 不使用优惠券:
考虑初始状态,显然 \(num=0\) 和 \(num=1\) 的情况我们会选择特殊考虑。
其他的东西都可以根据这几个初始状态算出来。
答案显然就是找最大的 \(i\) 来满足 \(\min(dp_{1,i,0},dp_{1,i,1}) \le 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;
}