想写这期博客已经很久了,主要是想总结一下自己学习的知识,同时也想把自己的见解分享给大家。
树形dp,简单理解就是在树上进行动态规划的过程,动态规划的本质就是要拆解子问题,再寻找每个子问题对应的最优解,而在树形dp中,拆解子问题很显然要把一整棵树上的问题拆解到每一棵子树上面,就是这样一个递归的过程。
话不多说,我们先看一个树形dp最最经典的题目没有上司的舞会 - 洛谷
题目描述
某大学有 n 个职员,编号为 1…n。
他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。
现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 ri,但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。
所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
输入格式
输入的第一行是一个整数 n。
第 2 到第 (n+1) 行,每行一个整数,第 (i+1) 行的整数表示 i 号职员的快乐指数 ri。
第 (n+2) 到第 2n 行,每行输入一对整数 l,k,代表 k 是 l 的直接上司。
输出格式
输出一行一个整数代表最大的快乐指数。
输入输出样例
输入 #1复制
7 1 1 1 1 1 1 1 1 3 2 3 6 4 7 4 4 5 3 5输出 #1复制
5说明/提示
数据规模与约定
对于 100% 的数据,保证 1 ≤ n ≤ 6 × 10^3,−128 ≤ ri ≤ 127,1 ≤ l, k ≤ n,且给出的关系一定是一棵树。
本题的一个限制条件是职员与他的直接上司不能同时被邀请,最终要求出最多可以邀请多少职员,那么我们将题意拆解发现,一个职员只有邀请与不邀请两种状态,且所有职员与直接上司构成了一棵树的关系,所以我们想到了可以使用树形dp求解。
下面使用闫氏dp分析法分析一下:
这样分析之后,我们只要找出根节点,之后从根节点进行dfs遍历,每次再对儿子进行遍历,这样就能递归得求出最后的所有职员的快乐指数的最大值了。
下面附上带有解析的源代码:
#include<iostream>
using namespace std;
#include<cstring>
const int N = 6010;
int f[N][2];//因为所有数据排列下来是一棵树的结构,所以可以用
//每一层是否筛选的方式确定最大值
int happy[N];
bool has_father[N];//一棵树只有一个根节点,设置数组方便确定根节点
int h[N],ne[N],e[N],idx;//存储树的结构
int n;
void add(int a,int b){
e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void dfs(int root){//采取深度遍历的方式对每一个节点对应的树计算题干条件下的最大值
f[root][1]=happy[root];//当初始进行计算时,如果选择根节点,根节点初始化为对应的快乐值
for(int i=h[root];i!=-1;i=ne[i]){
int s=e[i];//开始对根节点以下的所有子节点进行遍历
dfs(s);
f[root][1]+=f[s][0];//如果选择了根节点,则下一层节点不能选择
f[root][0]+=max(f[s][0],f[s][1]);//如果没有选择根节点,那么下一层节点可以选择,也可以不选
}
}
int main(){
cin>>n;
memset(h,-1,sizeof(h));
for(int i=1;i<=n;i++)scanf("%d",&happy[i]);//从第一个节点开始存储
for(int i=0;i<n-1;i++){
int a,b;
scanf("%d%d",&a,&b);
has_father[a]=true;
add(b,a);
}
int root=1;
while(has_father[root])root++;
dfs(root);
cout<<max(f[root][0],f[root][1])<<endl;
return 0;
}
你学会了吗?下面是一道如出一辙的题目练手:
这两道题都是对节点数进行动态规划,也有题目对边的权值进行动态规划,下面是一道非常经典的题目:
题目描述
有一棵苹果树,如果树枝有分叉,一定是分二叉(就是说没有只有一个儿子的结点)
这棵树共有 N 个结点(叶子点或者树枝分叉点),编号为 1∼N,树根编号一定是 1。
我们用一根树枝两端连接的结点的编号来描述一根树枝的位置。下面是一颗有 4 个树枝的树:
2 5 \ / 3 4 \ / 1
现在这颗树枝条太多了,需要剪枝。但是一些树枝上长有苹果。
给定需要保留的树枝数量,求出最多能留住多少苹果。
输入格式
第一行 2 个整数 N 和 Q,分别表示表示树的结点数,和要保留的树枝数量。
接下来 N−1 行,每行 3 个整数,描述一根树枝的信息:前 2 个数是它连接的结点的编号,第 3 个数是这根树枝上苹果的数量。
输出格式
一个数,最多能留住的苹果的数量。
输入输出样例
输入 #1复制
5 2 1 3 1 1 4 10 2 3 20 3 5 20输出 #1复制
21说明/提示
1⩽Q<N⩽100,每根树枝上的苹果 ⩽3×10^4。
乍一眼一看,我们发现这道题其实也是在一定数量限制上的在树上求某个最值的问题,所以我们也直接想到了能够用树形dp解决这道问题。
只不过这道题由于给的是边数的限制,所以转移方式上要发生一定的变化,我们一样先分析一下:
这里为什么状态转移变成了f[ u ][ s - t - 1]与上一个题不同呢,因为如果j是u的儿子,那么如果选择了j,u与j之间的边一定要选择,所以父节点所在的树上选的边数要再少一条。
同时我们仔细分析这道题,发现就是在选择边数为s时,能够获得的苹果数的最大值,于是我们想到了01背包问题,这里选择的边数就是体积,苹果数量就是最终的价值,所以在枚举子树的选择边数时我们需要倒序枚举。
下面附上源代码:
#include<iostream>
using namespace std;
#include<cstring>
const int N = 310, M = N*2;
int h[N],e[M],ne[M],w[M],idx;
//s数组统计每个点作为树根时对应的树的包含的边数
//01背包问题,倒序枚举每个商品对应的价值
int f[N][N],n,m,s[N];
void add(int a,int b,int c){
e[idx]=b,ne[idx]=h[a],w[idx]=c,h[a]=idx++;
}
void dfs(int u,int fa){
for(int i=h[u];~i;i=ne[i]){
int j=e[i];
if(j==fa) continue;
dfs(j,u);
//计算以u为根的子树含有的边的数量
s[u]+=s[j]+1;
//01背包问题要确定枚举的上界,这里就是每棵树对应含有的边数
for(int k=min(m,s[u]);k>=0;k--)
for(int t=min(s[j],k-1);t>=0;t--)
f[u][k]=max(f[u][k],f[u][k-t-1]+f[j][t]+w[i]);
}
}
int main(){
cin>>n>>m;
memset(h,-1,sizeof(h));
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);
}
dfs(1,0);
cout<<f[1][m]<<endl;
return 0;
}
看懂了吗?下面是一道例题练习:在拓扑排序上的树形dp[CTSC1997] 选课 - 洛谷
这几道题属于是树形dp非常基础的题目了,其实其他很多问题都可以再这个思想上求解,就是要将整个问题分解到每棵小的子树上求解,只不过每道题需要求解的变量以及条件不一样。
下面是几道经典好题:
[ZJOI2007] 时态同步 - 洛谷 每次dfs子节点时,维护一个到子节点距离的最大值
[HAOI2015] 树上染色 - 洛谷 看似要分别求黑白两点各自的情况,实则枚举子树一个颜色个数的时候,另一个颜色的个数已经确定,不过要注意枚举顺序的细节
[HAOI2009] 毛毛虫 - 洛谷 每次统计邻接树的最大顶点数与次大顶点数,同时维护一个直接相连的点数,最后递归求解
Choosing Capital for Treeland - 洛谷 正向建立边权为0的图,反向建立边权为1的图,进行量变由根节点开始的dfs,通过子节点算父节点的反向边数量,再通过父节点计算子节点的反向边数量
希望小伙伴们在这些好题的练习基础上可以更加娴熟地掌握树形dp
下面是一份题单,可以多多练习!!!
做dp的时候要多思考状态表示以及状态之间的转移关系,这样做起来会更流畅。