首页 > 编程语言 >【算法笔记】树形DP算法总结&详解

【算法笔记】树形DP算法总结&详解

时间:2024-09-08 23:13:23浏览次数:10  
标签:sz 结点 int dfs 算法 树形 maxn DP

0. 定义

树形DP,又称树状DP,即在树上进行的DP,是DP(动态规划)算法中较为复杂的一种。

1. 基础

令\(f[u]=~\)与树上顶点\(u\)有关的某些数据,并按照拓扑序(从叶子节点向上到根节点的顺序)进行\(\text{DP}\),确保在更新一个顶点时其子节点的dp值已经被更新好,以更新当前节点的\(\text{DP}\)值。为方便计算,一般写成dfs的形式,如下:

void dfs(int v) { // 遍历节点v
	dp[v] = ...; // 初始化
	for(int u: G[v]) { // 遍历v的所有子节点
		dfs(u);
		update(u, v); // 用子节点的dp值对当前节点的dp值进行更新
	}
}

下面来看一道简单的例题:

【例1.1】子树大小

给定一棵有\(N\)个结点的树,根结点为结点\(1\)。对于\(i=1,2,\dots,N\),求以结点\(i\)为根的子树大小(即子树上结点的个数,包括根结点)。

本题明显可以使用树形DP的方法,令\(f[v]=~\)以\(v\)为根的子树大小,则易得

\[f[v]=1+\sum_{i=1}^{\text{deg}_v} G[v][i] \]

即:一个结点的子树大小\(~=1\)(根节点)\(+~\)每个子树的大小。

沿用刚才的模板,可得:

#include <cstdio>
#include <vector>
#define maxn 100
using namespace std;

vector<int> G[maxn]; // 邻接表
int sz[maxn]; // dp数组,sz[v] = 子树v的大小

void dfs(int v)
{
	sz[v] = 1; // 初始化,最初大小为1,后面累加
	for(int u: G[v]) // 遍历子结点
	{
		dfs(u); // 先对子结点进行dfs
		sz[v] += sz[u]; // 更新当前子树的大小
	}
}

int main()
{
	int n;
	scanf("%d", &n); // 结点个数
	for(int i=1; i<n; i++) // N-1条边
	{
		int u, v;
		scanf("%d%d", &u, &v); // 读入一条边
		G[u].push_back(v); // 存入邻接表
	}
	dfs(1);
	for(int i=1; i<=n; i++)
		printf("%d\n", sz[i]);
	return 0;
}

下面来看一道稍微复杂一点的题:

【例1.2】洛谷P1352 没有上司的舞会

本题即树的最大独立集问题。

有\(N\)名职员,编号为\(1\dots N\),他们的关系就像一棵以老板为根的树,父节点就是子节点的直接上司。每个职员有一个快乐指数\(r_i\),现在要召开一场舞会,使得没有职员和直接上司一起参会。主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。

令\(f(v)\)表示以\(v\)为根的子树中,选择\(v\)的最优解,\(g(v)\)表示以\(v\)为根的子树中,不选\(v\)的最优解。

则对于每个状态,都存在两种决策(其中\(u\)代表\(v\)的儿子):

  • 不选\(v\)时,可选也可不选\(u\),此时有\(g(v)=\sum\max\{f(u),g(u)\}\);
  • 选择\(v\)时,一定不能选\(u\),此时有\(f(v)=r_i+\sum g(u)\)。

时间复杂度为\(\mathcal O(N)\)。
注意本题需要寻找根节点,没有上司的结点即为根节点,读入时用数组标记即可。

#include <cstdio>
#include <vector>
#define maxn 6005
using namespace std;

inline int max(int x, int y) { return x > y? x: y; }

vector<int> G[maxn]; // 邻接表
bool bad[maxn]; // 根结点标记
int f[maxn], g[maxn]; // 数据存储

void dfs(int v) // 遍历结点v
{
	// 读入时已初始化,这里可省略
	for(int u: G[v]) // 遍历子结点
	{
		dfs(u); // 先对子结点进行dfs
		// 更新当前dp状态
		f[v] += g[u]; // 选择v,不能选u
		g[v] += max(f[u], g[u]); // 不选v,u可选可不选
	}
}

int main()
{
	int n;
	scanf("%d", &n); // 结点个数
	for(int i=0; i<n; i++)
		scanf("%d", f + i); // 相当于提前初始化好f[i]=r[i]
	for(int i=1; i<n; i++) // N-1条边
	{
		int u, v;
		scanf("%d%d", &u, &v); // 读入一条边
		G[--v].push_back(--u); // 0-index,存入邻接表
		bad[u] = true; // 标记不可能是根结点
	}
	int root = -1; // 根结点变量
	for(int i=0; i<n; i++)
		if(!bad[i]) // 找到根结点
		{
			root = i; // 记录根结点
			break;
		}
	dfs(root); // 开始进行树形DP
	printf("%d\n", max(f[root], g[root])); // 根结点也有两种选择
	return 0;
}

习题

2. 树上背包

在基本算法之上,树形dp还可以用于树上背包问题。来看一道例题:

【例2.1】洛谷P2014 / AcWing 286 选课

有\(N\)门课,第\(i\)门课的学分是\(s_i\)。每门课有不超过一门先修课,需要上了先修课才能上这门课。现要选\(M\)门课,使得学分总和最大。

每门课最多只有一门先修课,这符合树结构的特点,与有根树中一个点最多只有一个父亲结点的特点类似。因此,我们根据数据构造一棵树,课程的先修课为这门课的父结点。又由于给定的输入是一个森林(多棵树组成的不一定连通的图),不是一棵完整的树,因此我们添加虚拟根结点\(0\)(\(s_0=0\)),将没有先修课的结点全部连到它下面,并从这里开始dfs。注意此时必须选中\(0\)号结点(它是所有课程的直接或间接先修课),所以操作前先将\(M\)加上\(1\)。

格式问题解决,下面考虑如何\(\text{DP}\)。
令\(f[i][j]\)表示当前在结点\(i\)、且已经选了\(j\)门课时的最大学分数量,则答案为\(f[0][M+1]\)。状态转移方程等详见代码。时间复杂度为\(\mathcal O(NM)\),有兴趣的可以自己尝试证明。

#include <cstdio>
#include <vector>
#include <algorithm>
#define maxn 305
using namespace std;

// dp算法中常用的模板,等效于x=max(x,y)
inline void setmax(int& x, int y)
{
	if(x < y) x = y;
}

vector<int> G[maxn]; // 邻接表
int n, m, f[maxn][maxn];

int dfs(int u) // 遍历结点u,返回值为其子树大小
{
	int tot = 1; // 记录子树大小,初始为1
	for(int v: G[u]) // 遍历u的所有子结点
	{
		int sz = dfs(v); // 对当前子结点进行搜索
		// 状态转移,注意i倒序,防止串连转移现象
		for(int i=min(tot, m); i>0; i--) // 子树大小优化可降低算法复杂度
			for(int j=1, lim=min(sz, m-i); j<=lim; j++)
				setmax(f[u][i + j], f[u][i] + f[v][j]); // 更新状态
		tot += sz; // 加到当前子树下
	}
	return tot; // 返回子树大小
}

int main()
{
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++)
	{
		int a;
		scanf("%d%d", &a, f[i] + 1); // 初始化f[i][1]=s[i]
		G[a].push_back(i);
	}
	m ++; // 别忘了这一句
	dfs(0);
	printf("%d\n", f[0][m]);
	return 0;
}

习题

3. 换根 DP

换根DP,即为不知道根结点时使用的一种树形DP,时间复杂度一般为\(\mathcal O(N)\)。

【例3.1】洛谷 P3478 [POI2008] STA-Station

给定一个\(n\)个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。

先考虑最简单粗暴的方法,即为枚举所有结点,代码如下:

#include <cstdio>
#include <vector>
#define maxn 1000005
using namespace std;

vector<int> G[maxn];

int dfs(int v, int d, int par)
{
	int s = d;
	for(int u: G[v])
		if(u != par)
			s += dfs(u, d + 1, v);
	return s;
}

int main()
{
	int n;
	scanf("%d", &n);
	for(int t=n; --t; )
	{
		int u, v;
		scanf("%d%d", &u, &v);
		G[--u].push_back(--v);
		G[v].push_back(u);
	}
	int ans = 0, maxDepth = dfs(0, 0, -1);
	for(int root=1; root<n; root++)
	{
		int d = dfs(root, 0, -1);
		if(d > maxDepth) ans = root, maxDepth = d;
	}
	printf("%d\n", ++ans);
	return 0;
}

很明显,这种做法时间复杂度为\(\mathcal O(n^2)\),又因为\(n\le 10^6\),所以无法得全分,评测结果如下:
评测结果

好家伙,居然还有50分,本以为最多30..

下面来考虑换根DP的方法。不妨令\(u\)为当前结点,\(v\)为其子结点。先预处理出每个结点的子树大小\(s[u]=1+\sum s[v]\)和以\(1\)为根结点时所有结点的深度(\(\text{depth}_i\)),此时第一遍DFS即为预处理。

令\(f_u\)表示以\(u\)为根时,所有结点的总深度和,则\(f_1=\sum\text{depth}_i\)。
考虑\(f_u\to f_v\)的转移,即“根结点从\(u\)变成\(v\)时所有结点深度和的变化”,则有:

  • 所有在\(v\)的子树上的结点深度全部\(-1\),则总深度和减少\(s_v\);
  • 所有不在\(v\)的子树上的结点深度都\(+1\),则总深度和增加\(n-s_v\);

此时,可得\(f_v=f_u-s_v+n-s_v=f_u+n-2s_v\)。注意数据类型,使用long long

#include <cstdio>
#include <vector>
#define maxn 1000005
using namespace std;

using LL = long long;

vector<int> G[maxn];
LL sz[maxn], f[maxn];
int n, ans;

LL dfs1(int v, int d, int par)
{
	sz[v] = 1;
	LL s = d;
	for(int u: G[v])
		if(u != par)
			s += dfs1(u, d + 1, v), sz[v] += sz[u];
	return s;
}

void dfs2(int v, int par)
{
	if(f[v] > f[ans]) ans = v;
	for(int u: G[v])
		if(u != par)
		{
			f[u] = f[v] + n - (sz[u] << 1LL);
			dfs2(u, v);
		}
}

int main()
{
	scanf("%d", &n);
	for(int t=n; --t; )
	{
		int u, v;
		scanf("%d%d", &u, &v);
		G[--u].push_back(--v);
		G[v].push_back(u);
	}
	f[0] = dfs1(0, 0, -1);
	dfs2(0, -1);
	printf("%d\n", ++ans);
	return 0;
}
AC

习题

4. 后记

好像这玩意也并不是开头所说的那么难…… 记得给个三连哦!

参考文献:

标签:sz,结点,int,dfs,算法,树形,maxn,DP
From: https://www.cnblogs.com/stanleys/p/18403694/algonotes-tree-dp

相关文章

  • 最小二乘回归算法原理及Python实践
    最小二乘回归算法原理主要基于最小化误差平方和的思想,以找到数据的最佳函数匹配。以下是对其原理的详细阐述:一、基本原理最小二乘法(LeastSquaresMethod,简称LS)是一种数学优化技术,它通过最小化误差的平方和来寻找数据的最佳函数匹配。在回归分析中,最小二乘法被广泛应用于......
  • 偏最小二乘回归算法原理及Python实践
    偏最小二乘回归(PartialLeastSquaresRegression,PLS回归)是一种统计学和机器学习中的多元数据分析方法,特别适用于处理因变量和自变量之间存在多重共线性问题的情况。其原理主要可以归纳为以下几点:一.原理概述PLS回归通过投影分别将预测变量(自变量X)和观测变量(因变量Y)投......
  • 如何在Java服务中实现分布式ID生成:雪花算法与UUID的对比
    如何在Java服务中实现分布式ID生成:雪花算法与UUID的对比大家好,我是微赚淘客返利系统3.0的小编,是个冬天不穿秋裤,天冷也要风度的程序猿!在现代分布式系统中,唯一标识符(ID)的生成是一个关键问题。常见的ID生成方案包括雪花算法(Snowflake)和UUID(通用唯一识别码)。本文将对这两种方案进行详......
  • MATLAB实现Dijkstra算法和Floyd算法
    目录1、文件功能介绍2、代码执行效果展示3、Dijkstra算法求图的单源最短路径4、DijkstrafullPath的更新逻辑5、DIjkstra算法流程图6、Floyd算法实现图的任意两点间最短路径7、Floyd算法流程图8、FloydfullPath的更新逻辑(非递归算法)1、文件功能介绍代码文件功能wor......
  • UCB算法(帮助做出最优选择的算法)
    UCB(UpperConfidenceBound)算法是一种用于解决多臂老x虎机问题的启发式方法。多臂老x虎机问题是一种用以模拟现实世界决策问题的数学模型,其中“臂”代表不同的行动或选择,而“老x虎机”代表这些行动的随机结果。UCB算法的目标是在探索(exploration)和利用(exploitation)之间找到最佳平......
  • 代码随想录算法训练营,9月7日 | 150. 逆波兰表达式求值,239. 滑动窗口最大值,347.前 K 个
    150.逆波兰表达式求值题目链接:150.逆波兰表达式求值文档讲解︰代码随想录(programmercarl.com)视频讲解︰逆波兰表达式求值日期:2024-09-07想法:用栈解决,遇到运算符取前两个数字计算(表达式总是成立的,不用做额外的判定)Java代码如下:classSolution{publicintevalRPN(Stri......
  • UDP通信
    入门特点:无连接、不可靠。不事先建立连接,数据按照包发,一包数据包括:自己的IP、程序端口,目的地IP、程序端口和数据(限制在64K内)等。发送方不管对方是否在线,数据在中间丢失也不管,如果接收方收到数据也不会确认,故是不可靠的。Java提供了一个java.net.DatagramSocket类来实现UDP通信。......
  • 《动手学深度学习》笔记4——线性回归 + 基础优化算法
    李沐老师:线性回归是机器学习最基础的一个模型,也是我们理解之后所有深度学习模型的基础,所以我们从线性回归开始1.线性回归由于是案例引入,没有很难的知识点,咱直接贴上李沐老师的PPT:1.1线性模型--单层神经网络李沐老师:神经网络起源于神经科学,但现在深度学习的发展......
  • 文心一言 VS 讯飞星火 VS chatgpt (342)-- 算法导论23.2 1题
    一、对于同一个输入图,Kruskal算法返回的最小生成树可以不同。这种不同来源于对边进行排序时,对权重相同的边进行的不同处理。证明:对于图G的每棵最小生成树T,都存在一种办法来对G的边进行排序,使得Kruskal算法所返回的最小生成树就是T。如果要写代码,请用go语言。文心一言:证明为了证明对......
  • WordPress在文章内容中对文字加模糊隐藏效果
    WordPress在文章内容中对文字加模糊隐藏效果,你可以放一些提取码,或者提示信息,甚至是一些秘密。。。实现也很简单,单纯css就可以了。教程开始:首先来到后台=>外观=>自定义=>额外CSS如图:将以下CSS代码加进去:.wponss{text-decoration:none;transition:filt......