树形动态规划总结详讲

1,039 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

树形动态规划总结

在设计树形动态规划时,一般以节点从深到浅(子树从小到大)的顺序作为DP的“阶段”

状态第一维主要是节点编号(代表以该节点的子树),一般以递归的形式实现,对于每个节点,先递归它的每个子节点上进行DP,回溯时,从子节点向父节点进行状态转移

时间复杂度基本上是O(n),有附加维m,则为O(nm)

树形动态规划主要题型

第一种、最普通的树形动态规划

主要为父节点和子节点之间的关系,即一般题中说,选了父节点不能选子节点,或者父节点放种东西可以在子节点看到,求最少设置数

主要例题:P1352 没有上司的舞会 P2016 战略游戏

讲解

  1. 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]);
  1. 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 加分二叉树

你有没有发现题目中都有一个二叉欸 (~ ̄▽ ̄)→)

讲解

  1. 二叉苹果树

题目中让我们保留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这个节点
			}
		}
	}
}
  1. 加分二叉树

枚举左右子树,设一个数组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]树上染色

讲解

  1. 选课

因为每门课的前修课最多只有一门,所以组成了一个森林,因为有许多课,每个课的前修还不一样,那我们应该怎么办呢???

因为有的课是不需要前修的,所以我们可以建一个虚结点(就是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];
	}
}
  1. “访问”美术馆

不得不说这一道题读入真是一个毒瘤,根本就不会深搜读入,这是什么鬼操作,以前从来就没有听说好不好(于是我愉快的翻开题解,学习了一下深搜读入,真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 让我们异或吧

这种题一看就是咱们这种蒟蒻不会做的类型,而且差不多都是省选难度了(所以我们学不学都无所谓

讲解

  1. [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]);//他本身和他儿子与他儿子的个数最大值
	}
}
  1. 让我们异或吧

这道题还是挺简单的,我们设一个数组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;
	}
}

经过长达四个多小时的时间,我终于把一些关于树形动态规划的浅显的知识介绍完了,希望各位读者能够受益匪浅

END