树上背包
树上的背包问题,也就是背包问题与树形 DP 的结合。
树上背包往往是设 \(dp_{u,i}\) 表示以 \(u\) 为根的子树放了体积 \(i\)(又或者是选 \(i\) 个节点)时的最优解。
进行状态转移时,依次进入每一个子节点 \(v\),处理完 \(v\) 的子树后将目前的 \(dp_{u,i}\) 和 \(dp_{v,j}\) 合并成 \(dp_{u,i+j}\),注意此时 \(i\) 应当倒序循环,另外建议枚举 \(v\) 中选择的数量的时候也写成倒序,因为有时要用 \(dp_{u,0}\) 和 \(dp_{v,j}\) 更新出 \(dp_{u,j}\),而这必须在最后进行。
时间复杂度貌似是 \(O(n^3)\) 的。
但实际上每个节点的背包容量为子树的大小,如果在合并过程中计算子树大小,每次合并相当于是将 \(size_u\) 和 \(size_v\) 的两部分合并成 \(size_u + size_v\) 的部分,合并花费的时间是 \(O(size_u \times size_v)\)。
这个合并时间可以形象化地转化为在 \(u\) 的子树和 \(v\) 的子树中各选一点进行匹配,那么显然每个点就是和其他点都进行一次匹配,时间复杂度为 \(O(n^2)\)。
如果限制选的数量不能超过 \(k\) 个,那么在枚举 \(u,v\) 选多少个的时候还能把上界限制在 \(k\),总的时间复杂度为 \(O(nk)\)。
证明见 子树合并背包类型的dp的复杂度证明。
例题:P2014 [CTSC1997] 选课
有 \(n\) 门课程,第 \(i\) 门课程的学分为 \(a_i\),每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。一位学生要学习 \(m\) 门课程,求其能获得的最多学分数。
数据范围:\(n,m \le 300\)
分析:由于每门课最多只有一门先修课,与有根树中一个点最多只有一个父亲节点的特点类似。可以利用这个性质来建树,从而所有课程形成了一个森林结构。为了方便起见,可以新增一门 \(0\) 学分的课程(编号为 \(0\)),作为所有无先修课课程的先修课,这样原本的森林就变成了一棵以 \(0\) 号节点为根的树。
设 \(dp_{u,i,j}\) 表示以 \(u\) 为根的子树中,已经遍历了 \(u\) 号点的前 \(i\) 棵子树,选了 \(j\) 门课程的最大学分。
转移的过程结合了树形 DP 和背包问题的特点,枚举点 \(u\) 的每个子节点 \(v\),同时枚举以 \(v\) 为根的子树选了几门课程,将子树的结果合并到 \(u\) 上。
将点 \(x\) 的子节点个数记为 \(s_x\),以 \(x\) 为根的子树大小为 \(sz_x\),则有状态转移方程:\(dp_{u,i,j} = \max \{ dp_{u,i-1,j-k} + dp_{v,s_v,k} \}\),注意有一些状态是无效的,比如 \(k>j\) 或是 \(k>sz_v\) 时。
第二维可以通过滚动数组优化掉,此时需要倒序枚举 \(j\) 的值,同 0-1 背包问题。
先修课这种关系是要求必须选一棵子树的根才能选子树中其他点的,所以枚举 \(u\) 中选择的数量的时候要注意是 \(\ge 1\) 的。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
using std::max;
const int N = 305;
vector<int> tree[N];
int n, m, s[N], sz[N], dp[N][N];
void dfs(int u) {
sz[u] = 1;
dp[u][1] = s[u];
for (int v : tree[u]) {
dfs(v);
for (int i = min(sz[u], m + 1); i >= 1; i--) {
for (int j = min(sz[v], m + 1 - i); j >= 1; j--) {
dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);
}
}
sz[u] += sz[v];
}
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
int k; scanf("%d%d", &k, &s[i]);
tree[k].push_back(i);
}
dfs(0);
printf("%d\n", dp[0][m + 1]);
return 0;
}
例题:P3177 [HAOI2015] 树上染色
分析:显然设 \(dp_{u,i}\) 代表以 \(u\) 为根节点的子树,将其中 \(i\) 个点染成黑色的状态。
但是这个值存什么呢?如果直接表示子树内黑点、白点间的收益,这个状态没有办法转移,因为子树内最大化收益的染色方案被合并上去后未必是最优的方案,也就是有后效性。
考虑每条边对最终答案的贡献,如果一条边的两侧有一对黑点或白点,则这条边对这两个点构成的路径是有贡献的。也就是说,一条边对总答案的贡献次数等于边的两侧同色点个数的乘积。而子树内每条边对总答案的贡献这个状态值在子树合并过程中是可以向上传递的。
因此 \(dp_{u,i}\) 代表以 \(u\) 为根节点的子树中有 \(i\) 个点被染成黑色后子树内每一条边对总答案的贡献,类似树上背包,在合并 \(dp_{u,i}\) 和 \(dp_{v,j}\) 时,要算上新加的 \((u,v)\) 这条边的贡献。而经过一条边的路径数等于边两端的点数的乘积,因此这个贡献就是 \(v\) 子树内和子树外黑点数的乘积加上白点数的乘积,再乘上边权。
如果指定 \(1\) 为根节点进行计算,则最后答案为 \(dp_{1,k}\)。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
#include <utility>
using std::vector;
using std::pair;
using std::min;
using std::max;
using edge = pair<int, int>; // 点,边权
using ll = long long;
const int N = 2005;
vector<edge> tree[N];
int n, k, sz[N];
ll dp[N][N];
void dfs(int u, int fa) {
sz[u] = 1;
for (edge e : tree[u]) {
int v = e.first, w = e.second;
if (v == fa) continue;
dfs(v, u);
for (int i = min(k, sz[u]); i >= 0; i--) {
for (int j = min(k - i, sz[v]); j >= 0; j--) { // 子树内的黑点数量
int black = j * (k - j); // 子树内*子树外
int white = (sz[v] - j) * (n - sz[v] - (k - j)); // 子树内*子树外
ll c = 1ll * w * (black + white);
dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j] + c);
}
}
sz[u] += sz[v];
}
}
int main()
{
scanf("%d%d", &n, &k);
for (int i = 1; i < n; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
tree[u].push_back({v, w}); tree[v].push_back({u, w});
}
dfs(1, 0);
printf("%lld\n", dp[1][k]);
return 0;
}