动态规划的重点就是找出正确的dp顺序/关系
而树形结构中严格的父子关系极其方便
树dp从数据结构角度来讲 可以说是 利用dp避免了做每个子树的答案的时候要遍历整个子树
从状态来讲
"树 dp 常常需要设计几种意义不同的状态,而且状态的含义通常与该点为根的子树有关。" --By Yukimai
树 dp 的转移通常有两种方式,自底向上 ( 即从儿子转移到父亲 ) 和自顶向下 ( 从父亲转移到儿子 ) 。
这两种转移的复杂度都是 O(n)* 单次转移复杂度,因为每条边只会被考虑一次。
不论哪种转移方式都可以一次 dfs 实现。
在掌握了树和dp之后组合进阶还是比较容易的
下面算是几道经典题
T1 最大独立集 luoguP1352 没有上司的舞会 传送门
这个题算是基础 明显的最优化 无后效性 树结构
更新的时候与正常的dp一样 在dfs的时候回溯并用儿子的值做父亲的答案
f[i]表示选i时 以i为根节点的子树产生贡献的大小
g[i]表示不选i时 以i为根节点的子树产生贡献的大小
所以可以自然更新
其实这道题会一点dp就能做出来
Code:
1 #include<cstdio>
2 #include<cstring>
3 #include<algorithm>
4 #include<cmath>
5 #include<queue>
6 #include<vector>
7 #define ms(a,b) memset(a,b,sizeof a)
8 #define rep(i,a,n) for(int i = a;i <= n;i++)
9 #define per(i,n,a) for(int i = n;i >= a;i--)
10 #define inf 2147483647
11 using namespace std;
12 typedef long long ll;
13 ll read() {
14 ll as = 0,fu = 1;
15 char c = getchar();
16 while(c < '0' || c > '9') {
17 if(c == '-') fu = -1;
18 c = getchar();
19 }
20 while(c >= '0' && c <= '9') {
21 as = as * 10 + c - '0';
22 c = getchar();
23 }
24 return as * fu;
25 }
26 const int N = 100005;
27 //head
28 int n,rt;
29 int val[N];
30 int head[N],nxt[N],mo[N],cnt;
31 void add(int x,int y) {
32 mo[++cnt] = y;
33 nxt[cnt] = head[x];
34 head[x] = cnt;
35 }
36
37 bool flag[N];
38 int f[N],g[N];
39 void dfs(int x) {
40 f[x] = 0;
41 g[x] = val[x];
42 for(int i = head[x];i;i = nxt[i]) {
43 int sn = mo[i];
44 dfs(sn);
45 g[x] += f[sn];
46 f[x] += max(g[sn],f[sn]);
47 }
48 }
49
50 int main() {
51 n = read();
52 rep(i,1,n) val[i] = read();
53 while(1) {
54 int x = read();
55 int y = read();
56 if(!x && !y) break;
57 add(y,x);
58 flag[x] = 1;
59 }
60 while(flag[++rt]);
61 dfs(rt);
62 printf("%d
",max(f[rt],g[rt]));
63 return 0;
64 }
T2 最小覆盖
(1)luoguP2016 战略游戏 传送门
起手先分一下状态
f[i]表示这个点有士兵 g[i]表示没有 下的 合法情况 的最优答案
采用自下而上的顺序的话比较好更新
所以根据定义 这个点不放 还合法 只能是它所有的儿子都有士兵(否则x->sn就空了)
所以g[x] = ∑(x->sn∈E)f[sn];
同理 如果i点有士兵 那么儿子放不放都行
f[x] = ∑(x->sn∈E)min(f[sn],g[sn]);
最后的答案依然是max(f[root],g[root])
Code:
1 #include<cstdio>
2 #include<cstring>
3 #include<algorithm>
4 #include<cmath>
5 #include<queue>
6 #include<vector>
7 #define ms(a,b) memset(a,b,sizeof a)
8 #define rep(i,a,n) for(int i = a;i <= n;i++)
9 #define per(i,n,a) for(int i = n;i >= a;i--)
10 #define inf 2147483647
11 using namespace std;
12 typedef long long ll;
13 ll read() {
14 ll as = 0,fu = 1;
15 char c = getchar();
16 while(c < '0' || c > '9') {
17 if(c == '-') fu = -1;
18 c = getchar();
19 }
20 while(c >= '0' && c <= '9') {
21 as = as * 10 + c - '0';
22 c = getchar();
23 }
24 return as * fu;
25 }
26 const int N = 3005;
27 //head
28 int n,rt;
29 int val[N];
30 int head[N],nxt[N],mo[N],cnt;
31 void _add(int x,int y) {
32 mo[++cnt] = y;
33 nxt[cnt] = head[x];
34 head[x] = cnt;
35 }
36 void add(int x,int y){if(x^y)_add(x,y),_add(y,x);}
37
38 int f[N],g[N];
39 void dfs(int x,int fa) {
40 f[x] = 1,g[x] = 0;
41 for(int i = head[x];i;i = nxt[i]) {
42 int sn = mo[i];
43 if(sn == fa) continue;
44 dfs(sn,x);
45 g[x] += f[sn];
46 f[x] += min(g[sn],f[sn]);
47 }
48 }
49
50 int main() {
51 n = read();
52 rep(i,1,n) {
53 int x = read() + 1;
54 int k = read();
55 rep(j,1,k) {
56 int y = read() + 1;
57 add(x,y);
58 }
59 }
60 dfs(1,1);
61 printf("%d
",min(f[1],g[1]));
62 return 0;
63 }
(2)luoguP2279 [HNOI2003]消防局的设立 传送门
上面那个是简单情况
考虑步数大于1
多开几倍空间? 很抱歉是错的。
多开空间能处理根节点还能向上延伸几步
但是无法考虑经过父节点更新其他兄弟的情况
考虑最小覆盖只求总结果 而且每次操作只有++
完全可以考虑dp[]表示其他情况
所以用dp[x]表示x向上还能延伸几步
如果为负 则表示需要上面多远的节点延伸几步
dfs回溯的时候记录所有儿子里面向上延伸步数的最大值和最小值
Code:
1 #include<cstdio> 2 #include<cstring> 3 #include<algorithm> 4 #include<queue> 5 #define ms(a,b) memset(a,b,sizeof a) 6 #define rep(i,a,n) for(int i = a;i <= n;i++) 7 #define per(i,n,a) for(int i = n;i >= a;i--) 8 #define inf 1000000007 9 using namespace std; 10 typedef long long ll; 11 typedef double D; 12 #define eps 1e-8 13 ll read() { 14 ll as = 0,fu = 1; 15 char c = getchar(); 16 while(c < '0' || c > '9') { 17 if(c == '-') fu = -1; 18 c = getchar(); 19 } 20 while(c >= '0' && c <= '9') { 21 as = as * 10 + c - '0'; 22 c = getchar(); 23 } 24 return as * fu; 25 } 26 const int N = 100005; 27 //head 28 int n,ans; 29 int head[N],nxt[N],mo[N],cnt; 30 void add(int x,int y) { 31 mo[++cnt] = y; 32 nxt[cnt] = head[x]; 33 head[x] = cnt; 34 } 35 36 int dp[N]; 37 void dfs(int x) { 38 int minn = 10,maxx = -10; 39 for(int i = head[x];i;i = nxt[i]) { 40 int sn = mo[i]; 41 dfs(sn); 42 minn = min(minn,dp[sn]); 43 maxx = max(maxx,dp[sn]); 44 } 45 if(minn == 10) dp[x] = -1; 46 //trick,没有儿子一定不如父亲放 47 else if(minn <= -2) ans++,dp[x] = 2; 48 //有节点不能被已有覆盖 49 //注意不会出现未被覆盖的情况 50 else if(maxx + minn > 0) dp[x] = maxx - 1; 51 //最大的儿子可以覆盖所有的儿子 52 else dp[x] = minn - 1; 53 //目前有节点不能被覆盖 但是可以向上继续决策 54 } 55 56 int main() { 57 n = read(); 58 rep(i,2,n) add(read(),i); 59 dfs(1); 60 // rep(i,1,n) printf("%d ",dp[i]); 61 ans += (dp[1] < 0); 62 //修正trick的边界 63 printf("%d ",ans); 64 return 0; 65 }
T3 树上背包 luoguP2014 选课
树上背包题目比较多 这里还是举最基础的例子
(其实还有个名字叫有依赖背包?)
具体就是 选儿子必须选祖先
依然是dfs处理
dp[i][j]表示i为根的子树中花费为j的最大价值
回溯的时候仿照01背包猜dp方程就是
dp[i][j] = max( dp[i][j] , dp[i][k] + dp[sn][j-k] );
但是复杂度O(n*v^2)
考虑优化 复杂度瓶颈在于每次要枚举子树的花费和总花费
利用之前的思路 能不能用儿子的状态代替整个子树的状态
发现 儿子如果不选 它的子树都不能选
考虑dfs序 同时可以处理出子树大小
在前序遍历上面做的话 子树的所有儿子一定在儿子的后面且大小是sze[sn]
转移方程就比较简单了qwq
dp[i][j] = max( dp[i + 1][j - cst[ dfs[i] ] + val[ dfs[i] ] , dp[i + sze[i]][j] );
前一个表示选这个子树 后一个不选
正序遍历dfs序就ok 复杂度O(n*v)
注意要dfs的时候初始化
而且 必须有一个明确的dfs序 即有根树
Code:
1 #include<cstdio>
2 #include<cstring>
3 #include<algorithm>
4 #include<cmath>
5 #include<queue>
6 #include<vector>
7 #define itn int
8 #define ms(a,b) memset(a,b,sizeof a)
9 #define rep(i,a,n) for(int i = a;i <= n;i++)
10 #define per(i,n,a) for(int i = n;i >= a;i--)
11 #define inf 2147483647
12 using namespace std;
13 typedef long long ll;
14 ll read() {
15 ll as = 0,fu = 1;
16 char c = getchar();
17 while(c < '0' || c > '9') {
18 if(c == '-') fu = -1;
19 c = getchar();
20 }
21 while(c >= '0' && c <= '9') {
22 as = as * 10 + c - '0';
23 c = getchar();
24 }
25 return as * fu;
26 }
27 const int N = 3005;
28 //head
29 int n,m;
30 int head[N],nxt[N],mo[N],cnt;
31 void add(int x,int y) {
32 mo[++cnt] = y;
33 nxt[cnt] = head[x];
34 head[x] = cnt;
35 return;
36 }
37
38 int val[N],sze[N],dp[N][N];
39
40 void dfs(int x) {
41 sze[x] = 1;
42 for(int i = head[x];i;i = nxt[i]) {
43 dfs(mo[i]);
44 sze[x] += sze[mo[i]];
45 }
46 for(int i = head[x];i;i = nxt[i]) {
47 int sn = mo[i];
48 per(j,sze[x],1) rep(k,1,j)
49 dp[x][j] = max(dp[x][j],dp[x][j-k] + dp[sn][k]);
50 }
51 per(i,sze[x],0) dp[x][i+1] = dp[x][i] + val[x];
52 return;
53 }
54
55 int main() {
56 n = read();
57 m = read();
58 rep(i,1,n) {
59 int x = read();
60 val[i] = read();
61 add(x,i);
62 }
63 dfs(0);
64 printf("%d
",dp[0][m+1]);
65 return 0;
66 }
T4 最优连通块问题
(I) 给定一棵无向树,树上每个点有权值,求一个权值和最大的连通块并输出该权值和。
Sol:
开始的时候会想到分成选根和不选根两种情况
但是会发现如果根不选是没有必要维护的 也不能更新答案
所以dp[i]表示以i为根的子树(必须选i)的最大价值
显然 dp[i] = w[i] + ∑max(0,dp[sn]) (sn->i∈E)
由于是无向树 答案就是所有dp中最大的
而且只用搜一遍 复杂度O(n)
(II) 给定一棵树,要求删去最少的边,得到一个恰好有 k 个点的连通块
luoguP1272 传送门
Sol:
按照上一问的思路 dp[i][j]表示以i为根大小为j最少删多少边
儿子同时具有大小和删多少边两个属性 考虑背包
dp[i][j] = min(dp[i][j],dp[i][k] + dp[sn][j-k] - 2)(i->sn∈E)
初始化dp[i][1] = 儿子数+1
由于i->sn在dp[i][k]和dp[sn][j-k]中考虑了两次 而我们最后还要加上 所以-2
最后所有点 i 求max(dp[i][k]) 就行
复杂度O(n*V*V)
Code:
1 #include<cstdio>
2 #include<cstring>
3 #include<algorithm>
4 #include<queue>
5 #define ms(a,b) memset(a,b,sizeof a)
6 #define rep(i,a,n) for(int i = a;i <= n;i++)
7 #define per(i,n,a) for(int i = n;i >= a;i--)
8 #define inf 1000000007
9 using namespace std;
10 typedef long long ll;
11 typedef double D;
12 #define eps 1e-8
13 ll read() {
14 ll as = 0,fu = 1;
15 char c = getchar();
16 while(c < '0' || c > '9') {
17 if(c == '-') fu = -1;
18 c = getchar();
19 }
20 while(c >= '0' && c <= '9') {
21 as = as * 10 + c - '0';
22 c = getchar();
23 }
24 return as * fu;
25 }
26 //head
27 const int N = 1005;
28 int n,m,p;
29 int head[N],nxt[N<<1],mo[N<<1],cnt;
30 int du[N];
31 void _add(int x,int y) {
32 du[x]++;
33 mo[++cnt] = y;
34 nxt[cnt] = head[x];
35 head[x] = cnt;
36 }
37 void add(int x,int y) {if(x^y)_add(x,y),_add(y,x);}
38
39 int dp[N][N],res;
40 void dfs(int x,int f) {
41 dp[x][1] = du[x];
42 for(int i = head[x];i;i = nxt[i]) {
43 int sn = mo[i];
44 if(sn == f) continue;
45 dfs(sn,x);
46 per(j,p,1) rep(k,1,j)
47 dp[x][j] = min(dp[x][j],dp[x][j-k] + dp[sn][k] - 2);
48 }
49 res = min(res,dp[x][p]);
50 }
51
52 int main() {
53 n = read(),p = read();
54 ms(dp,60),res = inf;
55 rep(i,2,n) add(read(),read());
56 dfs(1,1);
57 printf("%d
",res);
58 return 0;
59 }
T5 最大收益问题
给定一棵有根树,第 i 个节点上有 w[i] 价值的宝物 ( 只能被获取一 次 ) ,每通过第 i 条边一次就会消耗 c[i] 的费用,现在从根节点开始,每次可移动到相邻的节点上 ( 并付出相应的代价及获得相应的收益 ) ,可以在任意节点停止,求最大收益。
考虑路径可能的情况
无非是进一个子树再出来或者进一个子树不出来了
所以 f[i] 表示进 i 为根的子树不出来答案
g[i]表示进 i 为根的子树再出来的答案
由于需要子树的答案 所以回溯的时候更新
求f的时候枚举进去的儿子sn
f[i] = max(f[i] , w[i] - cst[fa[i] -> i] + f[sn] + ∑( i -> k ∈ E && k != j ) g[k] );
求g的时候就不用枚举了
g[i] = max(0,w[i] - cst[fa[i]->i] * 2 + ∑(i -> j ∈ E) g[j]);
初值全是0就Ok
这里发现 枚举sn的时候有一个 其他子树权值和 会吃复杂度
所以可以在更新之前预处理一遍所有子树的g[sn]和
至此 大部分知识点就BB完了
----Updated on 18.11.01----- Thank to mrclr