动态规划之树形dp

动态规划之树形dp

(南昌理工学院acm集训队)

树形dp顾名思义就是建立在树状结构基础上的动态规划思想,不同的题目推导方法也不一样。当得到题目构造出的树状图之后,思考是否可以通过遍历树状图,得到状态转移方程,再通过状态转移方程,求出题目最终的答案。

关于树形dp有很多很好的例题,本文挑三道不同的树形dp供大家参考


本人小白如有不对欢迎指正ლ(╹◡╹ლ)

P2015 二叉苹果树

题目传送门

这是一道非常经典的一道利用树形dp的思想解决的问题,跟下面的一题一样,都是大部分人接触树形dp的第一题,遇到此类问题首先要考虑的就是建树与推dp方程。

题意:输入N个节点与Q,输入N-1根树枝与树枝上的苹果数量,求出保留哪Q根树枝能让苹果树留住的苹果最多。
树状模型已经直接给我们了,因为题目本身就是一颗苹果“树”,接下来问题就剩推出状态转移方程来求出能留住的最多的苹果。

因为是树形dp,所以我们要通过对树的遍历来“量身定做”一个状态转移方程的推导,可以通过分析节点与节点之间的关系,不难看出,父节点之下(包括父节点)的最多的苹果数量等于子节点的之下的最多的苹果数量加上父节点的苹果数量。所以我们只需要遍历子节点的苹果数量,找出最多的那根树枝的苹果数量再加上父节点的苹果数量,就能最终得出整棵树的最多的苹果数量。

到这里我们不难看出,这道题就是一道依赖于树状模型的背包题,我们把留住的树枝数量看做背包的容量,树枝内的苹果数看做最大的苹果数,不同的树枝上的不同的苹果数量看做不同的可以拿的物品,这样一来状态转移方程也就成了我们熟悉的样子。

因此,我们设节点为 i ,节点之下取 j 根树枝时苹果数量最大,因此就有了二维数组dp[ i ][ j ],也得到了状态转移方程。

dp[ i ][ j ] = max(dp[ i ][ j ], dp[ i ][ j - k ] + dp[ i-1 ][k])

当然,我们要把这个状态转移方程嵌进我们的遍历树的代码中,所以其中的 i - 1 代表 i 的子节点。因为是树形结构,所以肯定有一个节点是祖先节点,也就是没有父节点的节点,我们输出它的dp[ i ][ Q ]的值,就是本题的最终答案。

首先我们先处理输入的数字,这里因为是二叉树,输入的数据不是很复杂,题目的范围给的也很小,所以我们就直接用 struct 将所有的点暴力存下来,方便大家理解。

设struct 存图

struct {
	int edge[10];//edge[0]存的是t[i]连接的树枝的数量,之后隔两个点存子节点与边的权值
}t[110];

输入数据

int x,y,z;
for (int i = 1; i < N; i++) {
		cin >> x >> y >> z;
		t[x].edge[t[x].edge[0] + 1] = y; t[x].edge[t[x].edge[0] + 2] = z; t[x].edge[0] += 2;
		//存从x到y权值为z的一条边
		t[y].edge[t[y].edge[0] + 1] = x; t[y].edge[t[y].edge[0] + 2] = z; t[y].edge[0] += 2;
		//再存一条相反的从y到x权值为z的一条边,输入数据中方向没确定
	}

然后循环一遍所有的edge[0],因为祖先节点没有父节点,而且作为一个二叉树,必有两个子节点,所以edge[0]存的点的数量肯定是2,在这里的edge[0]便等于4
因此当找到edge[0]为4时,这个点便是祖先节点,我们就可以开始搜图了(๑•̀ㅂ•́)و✧)

搜图的思路类似dfs,从祖先节点遍历一直到最底层的子节点,再回溯,回溯到新的分支就向下遍历到另一个分支,最后遍历完所有的分支return回最初的祖先节点跳出函数。
无法理解dfs可以看看这个博客

void dfs(int x,int l) {
	judge[x] = 1;//设judge[i]标记已遍历过的点
	for (int i = 1; i <= t[x].edge[0]; i += 2) {
		if (judge[t[x].edge[i]] == 1) {
			continue;//judge等于1表示已经遍历过,则直接到下一个点
		}
		dfs(t[x].edge[i], t[x].edge[i + 1]);
		judge[t[x].edge[i]] = 0;
		for (int j = Q - 1 ; j >= 1; j--) {
			for (int k = 1; k <= j; k++) {//循环k种决策
				dp[x][j] = max(dp[x][j], dp[x][j - k] + dp[t[x].edge[i]][k]);
				//dp[x][j]等于当前x点之下保留j根树枝时能保留的最大苹果数目
			}
		}
	}
	
		for (int i = Q; i > 0; i--)dp[x][i] = dp[x][i - 1] + l;//遍历完了所有的子节点之后别忘了加上他自己
}

最后ac代码

#include<iostream>
#include<cstring>
using namespace std;
struct {
	int edge[10];
}t[110];
int N, Q, x, y, z;
int dp[110][110];
int judge[110];
void dfs(int x,int l) {
	judge[x] = 1;
	for (int i = 1; i <= t[x].edge[0]; i += 2) {
		if (judge[t[x].edge[i]] == 1) {
			continue;
		}
		dfs(t[x].edge[i], t[x].edge[i + 1]);
		judge[t[x].edge[i]] = 0;
		for (int j = Q - 1 ; j >= 1; j--) {
			for (int k = 1; k <= j; k++) {
				dp[x][j] = max(dp[x][j], dp[x][j - k] + dp[t[x].edge[i]][k]);
			}
		}
	}
	
		for (int i = Q; i > 0; i--)dp[x][i] = dp[x][i - 1] + l;
}
int main()
{
	cin >> N >> Q;
	memset(t, 0, sizeof t);
	for (int i = 1; i < N; i++) {
		cin >> x >> y >> z;
		t[x].edge[t[x].edge[0] + 1] = y; t[x].edge[t[x].edge[0] + 2] = z; t[x].edge[0] += 2;
		t[y].edge[t[y].edge[0] + 1] = x; t[y].edge[t[y].edge[0] + 2] = z; t[y].edge[0] += 2;
	}
	for (int i = 1; i <= N; i++) {
		if (t[i].edge[0] == 4) {
			Q++;//因为是Q根树枝,也就是Q条边,所以要遍历Q+1个点,这里将Q+1
			dfs(i, 0);
			
			cout << dp[i][Q] << endl;
			break;
		}
	}

}

P2014 选课

题目传送门

题意:有N门课,不同的课有不同的学分,但是有些课必须要选修先修课才能选,求出选 M 门课程的最大得分。

同样是一道非常简单的题,去掉不同课之间的先修关系,这道题的就是一道非常普通的0 1 背包问题,M为容量,得分为价值。加上选修课之间的关系之后,很容易就能构造出一个树状的模型。
先修课为父节点,选了先修课才能选之后的课,因此之后的课就是先修课的子节点。
与上一题不同的是,这道题的关系比上一题复杂,父节点有可能有多个子节点,题目中也有可能有多个祖先节点。那么便不能用普通的方法暴力存图。这里我首推链式前向星,当然用邻接表等方法存图也可以 = = 。
(这里我就不详述了)

定义一个struct,和一个head数组

struct node {
	int next,to,s;
}t[310];
int head[310];

输入之前将所有点初始化为-1

    memset(head, -1, sizeof head);
	memset(t, -1, sizeof t);
	for (int i = 1; i <= n; i++, cnt++) {
		cin >> k >> s;
		t[cnt].next = head[k];
		t[cnt].s = s;
		head[k] = cnt;
		t[cnt].to = i;
	}
	m++;

因为题目中没说只有一个祖宗节点,也就是说题目内可能有很多的树,所以我们需要自己定义一个虚拟的祖宗节点,且权值为0,从这个虚拟的祖宗节点开始遍历,就能方便地处理这种很多棵树的问题,本题与上一道题的思路是相同的,只不过上一题是计算边的权值,这题是计算点的权值。

状态转移方程

dp[ i ][ j ] = max(dp[ i ][ j ], dp[ i ][ j - k ] + dp[ i-1 ][k])

dp[ i ][ j ]代表选 j 门第 i 门课的子课能拿到的最大学分

ac代码

#include<iostream>
#include<cstring>
using namespace std;
struct node {
	int next,to,s;
}t[310];
int head[310],judge[310];
int dp[310][310];
int n, m, k, s, cnt = 1;
void az(int x) {
	for (int i = head[x]; i != -1; i = t[i].next) {
		az(t[i].to);
		for (int j = m - 1; j >= 0; j--) {
			for (int k = 1; k <= j; k++) {
				dp[x][j] = max(dp[x][j], dp[x][j - k] + dp[t[i].to][k]);	
			}
		}
	}
	for (int i = m; i ; i--) {
		dp[x][i] = dp[x][i - 1] + t[x].s;
	}
}
int main()
{
	cin >> n >> m;
	memset(head, -1, sizeof head);
	memset(t, -1, sizeof t);
	for (int i = 1; i <= n; i++, cnt++) {
		cin >> k >> s;
		t[cnt].next = head[k];
		t[cnt].s = s;
		head[k] = cnt;
		t[cnt].to = i;
	}
	m++;//因为多一个虚拟节点,所以计算的是m+1门课
	t[0].s = 0;
	az(0);
	cout << dp[0][m] << endl;
}

P1270 “访问”美术馆

题目传送门

题意:美术馆有很多画,每幅画偷的时间为5秒,同时也有很多走廊,每条走廊的时间不等,输入警察赶来的时间,计算在警察赶来之前,他最多能偷到多少幅画。

美术馆的结构我们就可以看成是一棵树,不同的展厅就是不同的节点,边有权值的同时节点也有权值,也就是上面两题融合起来的样子,学名究极缝合怪。ヽ(*。>Д<)o゜

不过这题输入方法很巧妙,它是按照深度优先的方法输入的数据,也就是说我们在输入的时候就必须按照深度优先的方式循环。其实跟我们树状dp的思路是一样的,只不过是一边输入一边遍历。本题每个节点都有一个父节点和最多两个子节点,只有最底层的子节点有权值,且每条边都有权值。
既然每个节点只有两个子节点,那么我这次就使用类似线段树的存图方法,当前的边下标为x,通向两个子节点的边的下标为 x * 2 和 x * 2 + 1 ,这样就可以保证不重不漏也能合理利用空间。
定义一个struct

struct {
	int  t, s, time[610];
}tr[500];

t代表这条边的权值,也就是通过这条走廊所花的时间。
s代表点的权值,也就是展室里画的数量。
time代表之后用来遍历的数组

通过走廊花的时间也就等于偷画的时间加上经过走廊所需要的时间,由于小偷偷完了画要返回逃跑,所以每条走廊所花的时间要乘2,小偷偷每幅画的时间为5秒,所以还要同时加上画的数量 *5。本题不用考虑每个展室只偷一半的画的情况。

那么接下来的步骤就跟我们之前一样了,每条边里的time[ i ]表示在i秒时走到这条走廊最多能偷走多少画,其他的步骤也和之前的题目大同小异。

愉快acヾ(=゚・゚=)ノ♪

#include<iostream>
using namespace std;
struct {
	int  t, s, time[610];
}tr[500];
int Q;
void dfs(int x) {
	int n, m;
	cin >> n >> m;//输入
	tr[x].t += n * 2;//因为小偷有来有回,所以每条走廊花费的时间是走一遍的两倍
	if (m != 0) {//当m不等于0时说明已经到最底层了
		tr[x].s = m;
		tr[x].t += m * 5;//加上偷画所需要的时间
		for (int i = Q; i >= tr[x].t; i--)tr[x].time[i] = tr[x].time[i - tr[x].t] + m;
		for (int i = tr[x].t - 1; i >= 1; i--)tr[x].time[i] = 0;//加上偷完这个展室所需要的时间
		return;
	}
	for (int i = x * 2; i <= x * 2 + 1; i++) {//遍历两个子树
		dfs(i);
		for (int j = Q - 1; j > 0; j--) {
			for (int k = 1; k <= j; k++) {
				tr[x].time[j] = max(tr[x].time[j - k] + tr[i].time[k], tr[x].time[j]);
				//在x走廊花j秒钟时能偷到的最多的画
			}
		}
	}
	for (int i = Q; i >= tr[x].t; i--)tr[x].time[i] = tr[x].time[i - tr[x].t] + m;
	for (int i = tr[x].t - 1; i >= 1; i--)tr[x].time[i] = 0;
//遍历完了之后不忘加上走完这条走廊本身所需要的时间
}
int main()
{
	cin >> Q;//输入警察在多少秒内赶到
	dfs(1);
	cout << tr[1].time[Q - 1];
	//因为警察会在Q秒内赶到,所以输出的答案要严格小于Q,也就是Q-1秒时小偷走到最开始的边的能偷到的最多的画
}

谢谢你看到最后

树形dp是一种dp的思想,当你能通过题目所给的信息能构造出树状的模型并且题目要求求最大、最小问题时,就能考虑是否能用树形dp的思想,依赖树状模型去推导状态转移方程。一般是用深度优先遍历的方法遍历树状的图,当然也有许多其他的方法。结合贪心、前缀和等思路去解决问题。

2021/7/15 ヾ(´▽‘)ノ

  • 10
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值