本文已参与「新人创作礼」活动,一起开启掘金创作之路。
树形动态规划总结
在设计树形动态规划时,一般以节点从深到浅(子树从小到大)的顺序作为DP的“阶段”
状态第一维主要是节点编号(代表以该节点的子树),一般以递归的形式实现,对于每个节点,先递归它的每个子节点上进行DP,回溯时,从子节点向父节点进行状态转移
时间复杂度基本上是O(n),有附加维m,则为O(nm)
树形动态规划主要题型
第一种、最普通的树形动态规划
主要为父节点和子节点之间的关系,即一般题中说,选了父节点不能选子节点,或者父节点放种东西可以在子节点看到,求最少设置数
主要例题:P1352 没有上司的舞会 P2016 战略游戏
讲解
- P1352 没有上司的舞会
这应该是大多数初学者的第一道题,现在让我们来看一下这道题
因为职员不会和他的上司一起参加舞会,所以我们就有两种状态,1.上司参加,他的所有职员都不会参加,2.上司不参加,他的职员可以参加,也可以不参加
所以,我们就可以设一个数组f[x,0]表示x不参加舞会,快乐指数总和的最大值,f[x,1]为x参加,快乐指数总和的最大值
其次,因为这是一颗有根树,所以我们应该先找到这棵树的根,然后最后输出根节点参加和不参加的最大值即可
我们可以用vector来存每一个节点的子节点,就是下面这种方式
for(int i=1;i<n;i++) {
int x,y;
scanf("%d%d",&x,&y);
v[x]=1;
son[y].push_back(x);
}
在寻找根的时候,我们可以用一个bool数组来存每一个节点是否有父节点,就是上面的v数组,真表示有父节点
遍历每个节点,找到没有父节点的则为根
for(int i=1;i<=n;i++) {
if(!v[i]) {
root=i;
break;
}
}
之后就可以愉快的DP了,
void dp(int x) {
f[x][0]=0;//不选
f[x][1]=h[x];//选,快乐指数为他本身
for(int i=0;i<son[x].size();i++) {
int y=son[x][i];//遍历每个子节点
dp(y);
f[x][0]+=max(f[y][0],f[y][1]);//上司不去,儿子去不去都可以
f[x][1]+=f[y][0];//上司去,儿子不去
}
}
最后输出结果,四不四感觉很简单呢
cout<<max(f[root][1],f[root][0]);
- P2016 战略游戏
这道题其实和刚才那一道差不多(双倍经验美滋滋)
这道题其实就改了一点
如果父节点不放,所有的子节点都必须要放
核心代码
void dp(int x){
f[x][0]=0;//x上设士兵
f[x][1]=1;//不设士兵
if(node[x].num==0) return;//处理叶节点
for(int i=1;i<=node[x].num;i++) {
dp(node[x].child[i]);
f[x][0]+=f[node[x].child[i]][1];//必须选
f[x][1]+=min(f[node[x].child[i]][0],f[node[x].child[i]][1]);//选不选无所谓
}
}
第二种、由根分成左右两种子树
就是分别处理左右子树,一般需要枚举左节点需要的时间,从而推出右节点的情况
主要例题:P2015 二叉苹果树 P1040 加分二叉树
你有没有发现题目中都有一个二叉欸 (~ ̄▽ ̄)→)
讲解
- 二叉苹果树
题目中让我们保留Q个树枝,也就是Q+1个节点,这样就很好处理了
用一个vector来存每条树枝的情况
for(int i=1;i<n;i++) {
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
val[x][y]=val[y][x]=z;//苹果数
son[x].push_back(y);
son[y].push_back(x);
}
枚举左右子树保留的树枝
void dfs(int x) {
v[x]=1;//已经遍历过
for(int i=0;i<son[x].size();i++) {
int y=son[x][i];//枚举每个子节点
if(v[y]) continue;//遍历过跳过
dfs(y);
for(int j=q;j>=1;j--) {
for(int k=j-1;k>=0;k--) {
f[x][j]=max(f[x][j],f[x][j-k-1]+f[y][k]+val[x][y]);//分别枚举每个树枝,保留x到y这个节点
}
}
}
}
- 加分二叉树
枚举左右子树,设一个数组f记从左节点到右节点的最高分,r数组来存中序遍历
AC代码
#include<bits/stdc++.h>
using namespace std;
long long n,f[35][35];
int r[35][35];
bool fw=true;
long long search(int L,int R) {
long long now;
if(L>R) return 1;
if(f[L][R]==-1) {
for(int k=L;k<=R;k++) {
now=search(L,k-1)*search(k+1,R)+f[k][k];
if(now>f[L][R]) {
f[L][R]=now;
r[L][R]=k;
}
}
}
return f[L][R];
}
void pre(int L,int R) {
if(L>R) return;
printf("%d ",r[L][R]);
pre(L,r[L][R]-1);
pre(r[L][R]+1,R);
}
int main() {
cin>>n;
memset(f,0xff,sizeof(f));
for(int i=1;i<=n;i++) {
scanf("%lld",&f[i][i]);
r[i][i]=i;
}
printf("%d\n",search(1,n));
pre(1,n);
return 0;
}
第三种 背包类树形动态规划
还记得最开始学动态规划时,只会背包吗(尽管现在还是)
一听名字就知道其实就是在树上套背包的板子(哪儿有你说的这么简单)
背包类树形DP(也就是有树形依赖的背包问题),一般以“节点编号”作为树形DP的阶段,把当前背包的“体积”作为第二维状态
这种题可以用一个:“左儿子右兄弟”方法,把多叉树转化成二叉树,这样在状态转移时只需要枚举左右两棵子树中的一棵选了多少门课,但是这种转化在复杂的题目中容易把父子和兄弟关系混淆,一般不建议使用,直接在原始的多叉树上使用分组背包类型进行DP
主要例题:P2014 选课 P1270 “访问”美术馆 P3177 [HAOI2015]树上染色
讲解
- 选课
因为每门课的前修课最多只有一门,所以组成了一个森林,因为有许多课,每个课的前修还不一样,那我们应该怎么办呢???
因为有的课是不需要前修的,所以我们可以建一个虚结点(就是0号)作为没有前修课的前修
之后我们可以设f[x][t]表示以x为根的子树选t门课能获得的最高分,之后在修完这些课之后,可以再在x节点的节点里面选若干门课
可以看出来,其实这是一个分组背包的板子,所以我们就可以愉快的写代码了
for(int i=1;i<=n;i++) {
int x;
scanf("%d%d",&x,&score[i]);
son[x].push_back(i);
}
dp(0);
void dp(int x) {
f[x][0]=0;
for(int i=0;i<son[x].size();i++) {
int y=son[x][i];
dp(y);
for(int t=m;t>=0;t--) {当前体积,还能选
for(int j=t;j>=0;j--) {
if(t-j>=0) f[x][t]=max(f[x][t],f[x][t-j]+f[y][j]);//选不选这一门课
}
}
}
if(x!=0) {选修x
for(int t=m;t>=0;t--) f[x][t]=f[x][t-1]+score[x];
}
}
- “访问”美术馆
不得不说这一道题读入真是一个毒瘤,根本就不会深搜读入,这是什么鬼操作,以前从来就没有听说好不好(于是我愉快的翻开题解,学习了一下深搜读入,真TM的奇怪呢)
read(1);
void read(int x) {
scanf("%d%d",&a[x],&b[x]);
a[x]*=2;
if(!b[x]) {
read(x<<1);
read(x<<1|1);
}
}
然后就很简单了,只需要遍历左右子树就可以了
void dfs(int x,int y) {
if(f[x][y]||!y) return;
if(b[x]) {
f[x][y]=min(b[x],(y-a[x])/5);
return;
}
for(int i=0;i<=y-a[x];i++) {
dfs(x<<1,i);//遍历左子树“访问”i副画
dfs(x<<1|1,y-a[x]-i);//那么右子树就是y-a[x]-i副了
f[x][y]=max(f[x][y],f[x<<1][i]+f[x<<1|1][y-a[x]-i]);//取最大值
}
}
第四种、一些奇奇怪怪的树形DP
这些题普遍都有一个特点,就是标签上写着树形动态规划,你也知道这就是个树形DP,但是你就是不会写,233
主要例题:P4186 [USACO18JAN]Cow at Large G
P3174 [HAOI2009]毛毛虫 P3565 [POI2014]HOT-Hotels
P3237 [HNOI2014]米特运输 P2420 让我们异或吧
这种题一看就是咱们这种蒟蒻不会做的类型,而且差不多都是省选难度了(所以我们学不学都无所谓)
讲解
- [HAOI2009]毛毛虫
这道题一看就没有什么思路,但是我们仔细一想(翻题解)
就知道这原来就是求树的直径的变式
那么我们很快就得到了一个用DP来求树的直径的程序
void dp(int x) {
v[x]=1;
for(int i=head[x];i;i=e[i].next) {
int y=e[i].to;
if(v[y]) continue;
dp(y);
ans=max(ans,d[x]+d[y]+e[i].w);
d[x]=max(d[x],d[y]+e[i].w);
}
}
但是我们发现好像连样例过不去欸,有点尴尬
因为这道题求的不仅仅是树的直径,还要加上最长链(这条直径)上的点的连接的兄弟节点(注意不是儿子节点)
于是我们只需要在上面的代码改变一点,设一个数组f来存这个节点的最长毛毛虫有多长,s来存这个节点的子节点的个数
因此我们就可以得到下面的代码
核心代码
void dfs(int x,int fa) {
for(int i=head[x];i;i=e[i].next) {
int y=e[i].to;
if(y==fa) continue;
s[x]++;//求出每个节点子节点的个数
}
for(int i=head[x];i;i=e[i].next) {
int y=e[i].to;
if(y==fa) continue;
dfs(y,x);
if(fa>0) val=1;//特判,如果这个节点为根节点
else val=0;那么不需要加上根节点,否则加1(根节点)
ans=max(ans,f[x]+f[y]+1+val);
f[x]=max(f[x],f[y]+s[x]);//他本身和他儿子与他儿子的个数最大值
}
}
- 让我们异或吧
这道题还是挺简单的,我们设一个数组d来存这个节点到根结点一路异或的值,那么u到v的异或值就应该是(d[u] ^ d[root]) ^ (d[v] ^ d[root])所以一化简就是d[u]^d[v],那么就应该没有什么问题了
用vector来存每个节点的情况
for(int i=1;i<n;i++) {
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
e[x].push_back(make_pair(y,z));
e[y].push_back(make_pair(x,z));
}
在深搜的时候,记得从根节点开始搜,根节点的异或值为1
深搜代码
dfs(1,1,1);
void dfs(int x,int fa,int _xor) {
dis[x]=_xor;
for(int i=0;i<e[x].size();i++) {
int y=e[x][i].first;
int z=e[x][i].second;
if(y==fa) continue;
dfs(y,x,_xor^z);
}
}
第五种、带有基环树的树形动态规划
其实这些题也没有什么难的,就是加了一个基环树,众所周知 ,一棵树由n个节点和n-1条边构成,而基环树由n个节点和n条边构成
定义:我们把这种N个点N条边的连通无向图,即在树上加一条边构成的边恰好包含一个环的图,称为“基环树”。如果不保证连通,那么N个点N条边的无向图也可能是若干棵基环树组成的森林,简称“基环树森林”
求解基环树相关问题一般都是先找出图中唯一的环,把环作为基环树的广义“根节点”,把除了环之外的部分按照若干棵树来处理,再考虑与环一起计算
主要例题:P2607 [ZJOI2008]骑士
一道典型的带基环树的树形动态规划,这道题还是非常难的(于是我翻开了题解,看各位大佬如何解决)
我们先找出树中的环,如何找呢,我们用并查集来辅助我们来寻找,每次都把有父亲的节点的父节点存起来,找到之后,把环断开,在两端都进行一次DP
代码
//初始化
for(int i=1;i<=n;i++) {
scanf("%d%d",&val[i],&x);
add(x,i);
fa[i]=x;
}
//从第一个节点开始,遍历找根
for(int i=1;i<=n;i++) {
if(!vis[i]) fc(i);
}
void fc(int x) {
vis[x]=1;
root=x;//把当前节点为根节点
while(!vis[fa[root]]) {
root=fa[root];//一直向上找根节点
vis[root]=1;
}
dp(root);//从一端开始DP
long long t=max(f[root][0],f[root][1]);
vis[root]=1;
root=fa[root];//从另一端开始DP
dp(root);
ans+=max(t,max(f[root][0],f[root][1]));
return;
}
之后的DP代码就很好写了,跟没有上司的舞会非常像
void dp(int x) {
vis[x]=1;
f[x][0]=0,f[x][1]=val[x];
for(int i=head[x];i;i=e[i].pre) {
int y=e[i].to;
if(y!=root) {
dp(y);
f[x][0]+=max(f[y][0],f[y][1]);
f[x][1]+=f[y][0];
}
else f[y][1]=-maxn;
}
}
经过长达四个多小时的时间,我终于把一些关于树形动态规划的浅显的知识介绍完了,希望各位读者能够受益匪浅