以节点从深到浅(子树从小到大)的顺序作为dp的阶段;状态表示中,第一维通常是节点的编号(代表以该节点为根的子树),第二维根据具体题目分析。大多数时候,采用递归的方式实现树形dp。对于每个节点x,先递归在他的每个子节点上进行dp,回溯时,从子节点向节点x转移。
目录
代码框架
void dfs(int u, int father) {
If (...) ... //初始化、一些判断等
for(){
int v = e[i];
if(v == father) continue; //保证无后效性,只向儿子遍历
①dp; //先更新后dfs,自顶向下
dfs(v, u);
②dp; //先dfs后更新,自底向上
}
dp; //此处是对每一个根节点已经遍历完了其所有的儿子后进行的操作,通常是用于对以u为根节点的子树的操作
}
一、状态机
1. 没有上司的舞会
题意:1~N个职员(点),每个点加入舞会后快乐为h[i]。在满足每个职员不与它的直接上司在一起时,求将一部分职员加入舞会的最大快乐和。
转化:一棵树上每个节点有权值w,求每条边上最多选择一个节点的情况下总权值最大值
dp分析:状态表示:f[x][1或0]表示在以x为根的子树内,x节点参加/不参加的最大快乐和(第二维为是否选该节点)
所求:max(f[root][1], f[root][0])
状态计算:不参会则下属可加可不加:f[x][0] = Σmax( f[son][0], f[son][1])
参会则下属不可加:f[x][1] = happy[x]+Σf[son][0]
初始化:如果根节点选了,那么其初始权值为根节点的权值,否则为0
#include <bits/stdc++.h>
using namespace std;
const int N = 6010;
int e[N] , ne[N] , h[N] , idx;
int happy[N], n,f[N][2];
bool has_fa[N];
void add(int a , int b)
{
e[idx] = b , ne[idx] = h[a] , h[a] = idx++;
}
void dfs(int u)
{
f[u][1] = happy[u];
for(int i = h[u] ; ~i ; i = ne[i])
{
int j = e[i];
dfs(j);
f[u][1] += f[j][0];
f[u][0] += max(f[j][0] , f[j][1]);
}
}
int main()
{
cin >> n;
for(int i = 1 ; i <= n ; i++) cin >> happy[i];
memset(h , -1 , sizeof h);
for(int i = 0 ; i < n - 1 ; i++)
{
int a ,b;
cin >> b >> a;
has_fa[b] = true;
add(a , b);
}
int root = 1;
while (has_fa[root]) root ++ ;
dfs(root);
printf("%d\n", max(f[root][0], f[root][1]));
}
2. 战略游戏
题意:每个节点的士兵可以观察到所有与该点相连的边,求所有的边都能被看到,最少在多少个节点上放置士兵。
转化:一棵树上每个节点有权值1,求每条边上最少选择一个节点的情况下总权值最小值
dp分析:状态表示:f[x][1或0]表示在以x为根的子树内,x节点放/不放的方案(第二维为是否选该节点)
所求:min(f[root][1], f[root][0])
状态计算:不参会则下属可加可不加:f[x][0] = Σmin( f[son][0], f[son][1])
参会则下属不可加:f[x][1] = Σf[son][0]
初始化:如果根节点选了,那么其初始权值为根节点的权值,否则为0
#include <bits/stdc++.h>
using namespace std;
const int N = 1510;
int n;
int h[N], e[N], ne[N], idx;
int f[N][2];
bool not_root[N];
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u)
{
f[u][0] = 0, f[u][1] = 1; //initialize
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
dfs(j);
f[u][0] += f[j][1];
f[u][1] += min(f[j][0], f[j][1]);
}
}
int main()
{
while (~scanf("%d", &n))
{
memset(h, -1, sizeof h); idx = 0;
memset(not_root, 0, sizeof not_root);
for (int i = 0; i < n; i ++ )
{
int a, b, siz;
scanf("%d:(%d) ", &a, &siz);
while (siz -- )
{
scanf("%d", &b);
add(a, b);
not_root[b] = true;
}
}
int root = 0;
while (not_root[root]) root ++ ;
dfs(root);
printf("%d\n", min(f[root][0], f[root][1]));
}
}
3. 皇宫看守
题意:每个节点的士兵可以看到该点和所有与该点相连的点,求所有的点都能被看到,最少在多少个节点上放置士兵。
对于节点x,有两种情况:
放置x,可以看到所有子节点,子节点可选可不选;
不放x,还要考虑其父节点放置情况的影响,再分成两种情况:
- 父节点放置,则x就能由父节点看到,其子节点就可选可不选;
- 父节点不放,则其子节点就至少要选一个
我们将以上三种情况作为第二维,用f[x][0], f[x][1], f[x][2]表示。
(待补)
二、有依赖的背包
当物品的依赖关系以树的形式进行存在时,如果是一棵树,那么通常的根节点为1;如果是森林,新建一个虚拟根节点0,连接所有的树。
下面复习一下分组背包:
f[u][k]表示在以u为根节点 选k个点的值。我们进行递归自底向上更新,每个子树v看做一个组,在组内进行0 ~ k(k = min(sz[v], m)决策 (sz是以v为根节点的子树大小,m是通常可以供选择的体积上限) ,一般转移方程为 f[u][k] = min(f[u][k], f[u][j - k] + f[v][k] + val)
此处f[u][j - k]是在以u为根节点,排除v这个子树选择j - k 个点的最值(因为枚举到v时还没有更新),而f[v][k]则已经被更新过了,此处代表的是一个值,即以v为根节点,选择k个的值。
4. 二叉苹果树
题意:一棵树节点1~n,求只保留树中的m条边,使得树根所在的连通块的所有边边权之和最大值
物品从点变为边,但由于对于一棵树来说任意节点的入边,即连向父节点的边都是唯一的,所以将入边价值作为点价值。
#include <bits/stdc++.h>
using namespace std;
const int N = 110, M = N << 1;
int n, m;
int h[N], e[M], w[M], ne[M], idx;
int f[N][N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u, int father)
{
for (int i = h[u]; ~i; i = ne[i])
{
int ver = e[i];
if (ver == father) continue;
dfs(ver, u);
for (int j = m; j >= 0; j -- )
for (int k = 0; k <= j - 1; k ++ )
//枚举体积预留一条连向父节点的边,所以要-1
f[u][j] = max(f[u][j], f[u][j - k - 1] + f[ver][k] + w[i]);
}
}
int main()
{
memset(h, -1, sizeof h);
scanf("%d%d", &n, &m);
for (int i = 1; i < n; i ++ )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c), add(b, a, c);
}
dfs(1, -1);
printf("%d\n", f[1][m]);
}
5. 选课
题意:有些课必须上了先修课(只有一门)才能上的情况下,求选m个课的最多学分。
对于任意课最多只有一个先修课,所以这是一个n棵树的森林。设一个虚拟根节点将所有树连起来形成一棵大树来求解。注意更新完子节点后再更新该节点。
注意:如果是选择不超过m个课程,那么最后更新自己的时候还要加上一步,清空没选上u的所有状态,因为选附件有价值的前提是至少要选上主件,我们是自下向上计算,如果背包容积不如当前物品的体积大,那就不能选择当前结点及其子节点,因此赋值为零 。
#include <bits/stdc++.h>
using namespace std;
const int N = 110;
int n, m, root;
int h[N], e[N], ne[N], idx;
int v[N], w[N], f[N][N];
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u)
{
for (int i = h[u]; ~i; i = ne[i])
{
int son = e[i];
dfs(son);
for (int j = m ; j >= 0; -- j)
{
for (int k = 0; k <= j; ++ k)
{
f[u][j] = max(f[u][j], f[u][j - k] + f[son][k]);
}
}
}
for (int j = m; j >= v[u]; -- j) f[u][j] = f[u][j - v[u]] + w[u];
//for (int j = 0; j < v[u]; ++ j) f[u][j] = 0; //清空没选上u的所有状态
}
int main()
{
memset(h, -1, sizeof h);
cin >> n >> m;
for (int i = 1; i <= n; ++ i)
{
int p;
cin >> v[i] >> w[i] >> p;
if (p == -1) root = i;
else add(p, i);
}
dfs(root);
cout << f[root][m] << endl;
}
三、二次扫描换根
一般的dp中,人为规定一个遍历方向,却不能方便直接更新所有由父节点转移过来的信息 (对于不定根问题,枚举所有目标节点,然后求解每个节点到其他节点的最大流量,O(n^2)超时)。由于并不是所有的u→v的信息都截然不同,所以换根就是利用已经求出的信息更新,速度更快。
先任选一点作为根,第一次扫描,进行树形DP,统计子树内的节点对当前节点的贡献(自底向上);第二次扫描,从刚才的根出发,对整棵树DFS(自顶向下),即统计父亲节点对当前节点的贡献并合并统计最终答案。
6. 积蓄程度
题意:一条河(边)的最大水流容量为c[i],整个水系向任一固定方向流动,交汇点流入量=流出量。求以哪一个节点作为源点可以让水系流量最大,求最大值。
在求一个节点的流量时,有两种情况:
- 从当前节点往下走
- 从当前节点往上走到其父节点,再从其父节点出发且不回到该节点
这样的方向才能保证无后效性(无环),才能使用dp求解。
设D[x]为x为根节点的子树的最大流量,F[x]为以x为根节点整个水系的最大流量,可以得到
状态计算:当y不是叶节点时F[y] = D[y] + min(F[x] - min(D[y], c[x,y])
当y是叶节点时 F[y] = D[y] + c[x, y]
(F[y]就是将根从x变为y的计算结果,这是一个自顶向下的递推方程)
#include <bits/stdc++.h>
using namespace std;
const int N = 200010, M = N*2;
int n;
int h[N], e[M], w[M], ne[M], idx;
int d[N], f[N];
int deg[N], root;
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void dp(int u, int father) {
d[u] = 0;
for(int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];
if(j == father) continue;
dp(j, u);
if(deg[j] == 1) d[u] += w[i];
else d[u] += min(d[j], w[i]);
}
}
void dfs(int u, int father)
{
for(int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];
if(j == father) continue;
if(deg[u] == 1) f[j] = d[j] + w[i];
else if(deg[j] == 1) f[j] = d[j] + min(f[u] - w[i], w[i]);
else f[j] = d[j] + min(f[u] - min(d[j], w[i]), w[i]);
dfs(j, u);
}
}
int main()
{
int T;
scanf("%d", &T);
while(T--)
{
scanf("%d", &n);
memset(deg, 0, sizeof deg); //不要忘记清空
memset(h, -1, sizeof h);
idx = 0;
for(int i = 0; i < n - 1; i++)
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c), add(b, a, c);
deg[a]++, deg[b]++;
}
root = 1; //任选一个点作为根
dp(root, -1);
f[root] = d[root];
dfs(root, -1);
int res = 0;
for(int i = 1; i <= n; i++) res = max(res, f[i]);
printf("%d\n", res);
}
}
7. 树的中心
题意:求一棵树中的一个点,它到所有节点的最远距离最小
与一般换根不同,第一次扫描时要算出当前子树对于根的最大贡献和次大贡献,因为如果该点是其父节点子树的最大路径上的点,则父节点子树的最大贡献不能算作对节点的贡献,否则不符合无后效性。第二次扫描求解出每个节点的父节点对他的贡献后(即每个节点往上能到的最远路径)两者比较取最大即答案。
#include <bits/stdc++.h>
using namespace std;
const int N = 10010, M = N << 1, INF = 0x3f3f3f3f;
int n, h[N], e[M], w[M], ne[M], idx;
int d1[N], d2[N], up[N];
int s1[N], s2[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs1(int u, int father)
{
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
if (j == father) continue;
dfs1(j, u);
if (d1[j] + w[i] >= d1[u])
{
d2[u] = d1[u], s2[u] = s1[u];
d1[u] = d1[j] + w[i], s1[u] = j;
}
else if (d1[j] + w[i] > d2[u])
{
d2[u] = d1[j] + w[i], s2[u] = j;
}
}
}
void dfs2(int u, int father)
{
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
if (j == father) continue;
if (s1[u] == j) up[j] = w[i] + max(up[u], d2[u]); //son_u = j,则用次大更新
else up[j] = w[i] + max(up[u], d1[u]); //son_u != j,则用最大更新
dfs2(j, u);
}
}
int main()
{
memset(h, -1, sizeof h);
scanf("%d", &n);
for (int i = 1; i < n; i ++ )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c), add(b, a, c);
}
dfs1(1, -1);
dfs2(1, -1);
int res = INF;
for (int i = 1; i <= n; i ++ ) res = min(res, max(d1[i], up[i]));
printf("%d\n", res);
}