树形DP
一、树形DP
1. 定义
树形DP即为在树形结构上的DP;
给定一个 N N N 个结点的树 (通常是无根树,即有 N − 1 N - 1 N−1 条无向边) 可以通过根节点定义出每个节点,则在此树上设计动态规划;
2. 过程
由于树是由若干的子树递归定义的,所以树形 DP 通常在 DFS 的方式中实现,即在 DFS 遍历整棵树的过程中进行状态转移;
一般以结点从浅到深作为 DP 的 ”阶段“ ;
DP 状态的第一维通常为一个节点的编号,代表在以这个点为根节点的子树上的状态;
一般通过子节点与父节点的状态关系进行状态的转移;
即对于每个节点,先递归在它的每个子节点上进行DP,在回溯时,从子节点向根结点转移;
用邻接表存储下 N − 1 N - 1 N−1 条无向边,在递归时,注意标记结点是否访问过,避免沿反向边父节点;
3. 例题
题目描述
Bob 喜欢玩电脑游戏,特别是战略游戏。但是他经常无法找到快速玩过游戏的方法。现在他有个问题。
现在他有座古城堡,古城堡的路形成一棵树。他要在这棵树的节点上放置最少数目的士兵,使得这些士兵能够瞭望到所有的路。
注意:某个士兵在一个节点上时,与该节点相连的所有边都将能被瞭望到。
请你编一个程序,给定一棵树,帮 Bob 计算出他最少要放置的士兵数。
输入格式
输入数据表示一棵树,描述如下。
第一行一个数 N ,表示树中节点的数目。
第二到第 N + 1 行,每行描述每个节点信息,依次为该节点编号 i ,数值 k ,k 表示后面有 k 条边与节点 i 相连,接下来 k 个数,分别是每条边的所连节点编号 r 1 , r 2 , . . . , r n r_1, r_2, ... , r_n r1,r2,...,rn 。
对于一个有 N 个节点的树,节点标号在 0 到 N - 1 之间,且在输入文件中每条边仅出现一次。
输出格式
输出仅包含一个数,为所求的最少士兵数。
数据范围与提示
对于 100% 的数据,有 0 < n < 1500 0 < n < 1500 0<n<1500。
分析
此题意为 一个点可以覆盖与之连接的边,求用最少的点可以覆盖整棵树;
通过路形成一棵树与求最小的士兵数,想到树形DP;
状态
由于每个节点有两种情况,则站与不站士兵,分别对应两种不同的情况,则定义状态为
d p [ i ] [ 0 / 1 ] dp[i][0/1] dp[i][0/1] 表示覆盖完以 i i i 为根节点的子树,站/不站(1/0)士兵时,所需要的最少士兵数量;
转移
当以 i i i 为根节点的子树需要全部覆盖时;
i i i 结点不站士兵,则它的子节点必须全部放置士兵,才可以满足士兵看到所有的边,则
d p [ i ] [ 0 ] = d p [ v ] [ 1 ] dp[i][0] = dp[v][1] dp[i][0]=dp[v][1] ,其中 v v v 为 i i i 的子节点;
i i i 结点站士兵,则其子节点可站可不站士兵,则选取站士兵最少的方案即可;
d p [ i ] [ 1 ] = m i n ( d p [ v ] [ 1 ] , d p [ v ] [ 0 ] ) dp[i][1] = min(dp[v][1], dp[v][0]) dp[i][1]=min(dp[v][1],dp[v][0]) ,其中 v v v 为 i i i 的子节点;
又由于每个结点只需计算一次,所以不需要回溯;
答案
答案则为根结点站与不站士兵满足要求的总士兵数最小值;
代码
#include <cstdio>
#include <vector>
#include <algorithm>
#define MAXN 1505
using namespace std;
int n, dp[MAXN][2];
bool flag[MAXN];
vector < int > g[MAXN];
void dfs(int i) {
dp[i][1] = 1;
flag[i] = true;
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (!flag[v]) {
dfs(v);
dp[i][1] += min(dp[v][1], dp[v][0]);
dp[i][0] += dp[v][1];
}
}
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
int x, t, y;
scanf("%d %d", &x, &t);
for (int j = 1; j <= t; j++) {
scanf("%d ", &y);
g[x].push_back(y);
g[y].push_back(x);
}
}
dfs(0);
printf("%d", min(dp[0][0], dp[0][1]));
return 0;
}
二、树上背包
1. 说明
树上的背包问题,就是背包问题与树形 DP 的结合;
由于树由节点组成,将每个子树看作一组,则为每组物品中,选出 v v v 个,使得 c c c 最大,即为分组背包;
状态
即同滚动数组优化后的分组背包,将背包的容积当作状态中的第二维,第一位即为以 i i i 维根节点的子树;
d p [ i ] [ j ] dp[i][j] dp[i][j]: 以 i i i 为根节点的子树上,背包容量为 j j j 时的最大价值;
转移
在 DFS 遍历树时,枚举背包容量 j j j 与组内选择的物品数量 k k k ;
设这颗子树根结点为 i i i 其的一个子节点为 v v v ;
当选择 i , v i, v i,v 这条边时,要在以 i i i 为根结点的子树上选择 j j j 个,以 v v v 为根结点的子树上选择 k k k 个,则还应在以 i i i 为根结点的子树上选择 j − k − 1 j - k - 1 j−k−1 个;
状态转移方程
d
p
[
i
]
[
j
]
=
max
k
<
j
{
d
p
[
v
]
[
k
]
+
c
[
i
]
[
v
]
+
d
p
[
i
]
[
j
−
k
−
1
]
}
dp[i][j] = \max_{k < j} \{ dp[v][k] + c[i][v] + dp[i][j - k - 1] \}
dp[i][j]=k<jmax{dp[v][k]+c[i][v]+dp[i][j−k−1]}
由于转移根结点 i i i 时要用到子节点 u u u 的状态,所以先递归转移子节点 u u u 的状态,再在回溯时转移 i i i 的状态;
又由于转移 d p [ i ] [ j ] dp[i][j] dp[i][j] 时需要用到 d p [ i ] [ j − k − 1 ] dp[i][j - k - 1] dp[i][j−k−1] 的值,所以 j j j 应倒叙枚举;
2. 例题
题目描述
有一棵二叉苹果树,如果数字有分叉,一定是分两叉,即没有只有一个儿子的节点。这棵树共 N 个节点,标号 1 至 N ,树根编号一定为 1。
我们用一根树枝两端连接的节点编号描述一根树枝的位置。一棵有四根树枝的苹果树,因为树枝太多了,需要剪枝。但是一些树枝上长有苹果,给定需要保留的树枝数量,求最多能留住多少苹果。
输入格式
第一行两个数 N 和 Q, 表示树的节点数,Q 表示要保留的树枝数量。
接下来 N - 1 行描述树枝信息,每行三个整数,前两个是它连接的节点的编号,第三个数是这根树枝上苹果数量。
输出格式
输出仅一行,表示最多能留住的苹果的数量。
数据范围与提示
对于 100% 的数据, 1 ≤ Q ≤ N ≤ 100 , N ≠ 1 1\le Q \le N \le 100, N\neq 1 1≤Q≤N≤100,N=1,每根树枝上苹果不超过 30000 个。
分析
由于剪掉树枝,使苹果数量最多,想到树形背包DP;
树枝数量为容量,苹果数量为价值;
状态
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示以 i i i 号节点为根节点的子树上,保留 j j j 个树枝的最多苹果数量;
转移
分组背包,在DFS遍历树中,枚举背包容量,组内背包编号;
设这颗子树根结点为 i i i 其的一个子节点为 v v v ;
d p [ i ] [ j ] = max k < j { d p [ v ] [ k ] + c [ i ] [ v ] + d p [ i ] [ j − k − 1 ] } dp[i][j] = \max_{k < j} \{ dp[v][k] + c[i][v] + dp[i][j - k - 1] \} dp[i][j]=k<jmax{dp[v][k]+c[i][v]+dp[i][j−k−1]}
由于是滚动数组优化后的分组背包,所以倒着枚举 j j j ;
代码
#include <cstdio>
#include <vector>
#include <algorithm>
#define MAXN 105
using namespace std;
int n, q, dp[MAXN][MAXN];
bool flag[MAXN];
struct edge {
int to, tot;
};
vector < edge > g[MAXN];
void dfs(int i) {
flag[i] = true;
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t].to, tot = g[i][t].tot;
if (flag[v] == false) {
dfs(v);
for (int j = q; j >= 0; j--) {
for (int k = 0; k < j; k++) {
dp[i][j] = max(dp[i][j], dp[v][k] + tot + dp[i][j - k - 1]);
}
}
}
}
return;
}
int main() {
scanf("%d %d", &n, &q);
for (int i = 1; i < n; i++) {
int x, y, z;
scanf("%d %d %d", &x, &y, &z);
g[x].push_back( edge ( { y, z } ) );
g[y].push_back( edge ( { x, z } ) );
}
dfs(1);
printf("%d", dp[1][q]);
return 0;
}
三、换根DP
1. 说明
给定一个无根树形结构,需要以每个结点为根进行统计,这类问题则为 “不定根” 的树形DP;
一般通过两次扫描来解决
- 第一次扫描,任选一点为根,预处理出转移根时需要的数据,以及以该点为根时的状态;
- 第二次扫描,从开始选的根开始搜索遍历,通过父节点与子节点的关系,进行状态转移,算出从换根后的解;
既可以在 O ( n ) O(n) O(n) 内解决;
此类问题定义状态时通常为一个节点的编号,代表在以这个点为根时树上的状态;
2. 例题
题目描述
给出一个N个点的树,找出一个点来,以这个点为根的树时,所有点的深度之和最大
输入格式
给出一个数字N,代表有N个点.N<=1000000 下面N-1条边.
输出格式
输出你所找到的点,如果具有多个解,请输出编号最小的那个.
分析
由于此题树为无根树,又要求结点为根时的深度之和,即为换根DP;
状态
d p [ i ] dp[i] dp[i] 表示以 i i i 为根结点时,树上的状态;
转移
在从 i i i 换根到 其子节点 v v v 时;
- 以 u u u 为根结点的子树上的每个节点深度均减一,总共减去 s i z e [ u ] size[u] size[u] ;
- 其余的结点,深度均加1,总共加上 n − s i z e [ u ] n - size[u] n−size[u] ;
则有状态转移方程
d p [ v ] = d p [ i ] − s i z e [ v ] + n − s i z e [ v ] dp[v] = dp[i] - size[v] + n - size[v] dp[v]=dp[i]−size[v]+n−size[v]
所以第一次扫描时,预处理出以 i i i 为根结点的子树的大小;
代码
#include <cstdio>
#include <vector>
#include <algorithm>
#define MAXN 1000005
using namespace std;
int n, ans1;
long long ans = -1, size[MAXN], dep[MAXN], dp[MAXN];
vector < long long > g[MAXN];
bool flag[MAXN];
void dfs1(int i) {
size[i] = 1;
flag[i] = true;
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (!flag[v]) {
dep[v] = dep[i] + 1;
dfs1(v);
size[i] += size[v];
}
}
flag[i] = false;
return;
}
void dfs2(int i) {
flag[i] = true;
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (!flag[v]) {
dp[v] = dp[i] - size[v] + n - size[v];
dfs2(v);
}
}
return;
}
int main() {
scanf("%d", &n);
for (int i = 1; i < n; i++) {
int x, y;
scanf("%lld %lld", &x, &y);
g[x].push_back(y);
g[y].push_back(x);
}
dfs1(1);
for (int i = 1; i <= n; i++) {
dp[1] += dep[i];
}
dfs2(1);
for (int i = 1; i <= n; i++) {
if (dp[i] > ans) {
ans = dp[i];
ans1 = i;
}
}
printf("%d", ans1);
return 0;
}