动态规划

动态规划简介

动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
由于动态规划并不是某种具体的算法,而是一种解决特定问题的方法,因此它会出现在各式各样的数据结构中,与之相关的题目种类也更为繁杂

原理

能用动态规划解决的问题,需要满足三个条件:最优子结构,无后效性和子问题重叠
1、最优子结构
(1)具有最优子结构也可能是适合用贪心的方法求解。
(2)要保持子问题空间尽量简单,只在必要时扩展。
(3)最优子结构的不同体现在两个方面:
①原问题的最优解中涉及多少个子问题
②确定最优解使用哪些子问题时,需要考虑多少种选择
2、无后效性
已经求解的子问题,不会再受到后续决策的影响
3、子问题重叠
如果有大量的重叠子问题,我们可以用空间将这些子问题的解存储下来,避免重复求解相同的子问题,从而提升效率

基本思路

对于一个能用动态规划解决的问题,一般采用如下思路解决:
1、将原问题划分为若干阶段,每个阶段对应若干子问题,提取这些子问题的特征(称之为状态
2、寻找每一个状态的可能决策,或者说是各状态间的相互转移方程(用数学的语言描述就是状态转移方程
3、按顺序求解每一个阶段的问题

线性dp

具有线性“阶段”划分的动态规划算法被统称为线性DP。
线性DP的三个经典入门例题:数字三角形(IOI1994)、最长上升子序列(LIS)、最长公共子序列(LCS)

数字三角形

https://www.luogu.com.cn/problem/P1216
算法一:
暴搜:最简单粗暴的思路是尝试所有的路径。
时间复杂度:O(2^r),这样的时间复杂度是无法接受的
算法二:
1、一个点(x, j),只能由正上(x-1, y)和左上(x-1, y-1)转移而来,因此只需要知道前一行的每个点的最大权值,即可推出当前点的最大权值
2、这样就缩小了问题的规模,将一个问题分成了多个规模更小的问题。要想得到从顶端到r行的最优方案,只需要知道从顶端到r-1行的最优方案的信息就可以了
3、可以把每个子问题的解存储下来,通过记忆化的方式限制访问顺序,确保每个子问题只被访问一次
这其实就是动态规划的思想
时间复杂度:O(n^2)
示例代码:

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;
const int inf = 0x3f3f3f3f;

int n = 0, m = 0;
int a[maxn][maxn] = {};
//f[i][j]表示到达第i行j列这个位置的最大值	 
int f[maxn][maxn] = {};

int main()
{	 
	scanf("%d", &n);
	//读入三角形数据
	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=i; j++)
		{
			scanf("%d", &a[i][j]);
		}
	}
	
	//初始化f
	for(int i=0; i<=n; i++)
	{
		for(int j=0; j<=n; j++)
		{
			f[i][j] = -inf;
		}
	}
	
	//线性dp
	f[0][0] = 0;
	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=i; j++)
		{
			f[i][j] = max(f[i-1][j-1], f[i-1][j]) + a[i][j];
		}
	}
	
	int ans = -inf;
	for(int i=1; i<=n; i++)
	{
		ans = max(ans, f[n][i]);
	}
	printf("%d", ans);

	return 0;
}

最长上升序列(LIS)

问题描述:给定一个长度为\(n\)的序列\(A\),求出一个最长的\(A\)的子序列,满足该子序列的最后一个元素大于前一个元素。
例题: 最长上升序列(LIS)
算法一:
\(f(i)\)表示以\(A_i\)为结尾的最长不下降子序列的长度,则所求为\(max(f(i))(1<=i<=n)\)
计算\(f(i)\)时,尝试将\(A_i\)接到其他最长不下降子序列后面,以更新答案。
状态转移方程:\(f(i)=max(f(j)+1)(1<=j<i,A_j<=A_i)\)
时间复杂度: \(O(n^2)\)
代码示例:

#include <bits/stdc++.h>
using namespace std;

/*
O(n^2)做法,记录路径
*/

const int maxn = 1010; 
const int inf = 0x3f3f3f3f;

int n = 0;
int a[maxn] = {};
//f[i]表示到第i个数中的最长上升降序列长度
//a[i]>a[j]时 f[i] = max(f[i], f[j] + 1)  1<=j<i
int f[maxn] = {}, ans = 1, pre[maxn];

void p(int x)
{
	if(x == 0) return;
	
	p(pre[x]);
	printf("%d ", a[x]);
}

int main()
{	 
	int x = 0, te = 0;
	while(scanf("%d", &x) != EOF) a[++n] = x; 
	
	for(int i=1; i<=n; i++)
	{
		f[i] = 1;
		for(int j=1; j<i; j++)
		{
			if(a[i]>a[j] && f[i]<f[j]+1) 
			{
				f[i] = f[j] + 1;
				pre[i] = j;	 
				if(ans < f[i])
				{
					ans = f[i];
					te = i; 
				}
			}
		}
	}
	
	printf("max=%d\n", ans);
	p(te);

	return 0;
} 

算法二:
\(n\)的范围扩大到\(n<=10^5\)时,第一种做法就会\(TLE\),因此给出一个\(O(nlog(n))\)的做法
\(f[i]\)表示以当\(LIS\)长度为i时,最大的\(a[i]\)
状态转移:
当进来一个元素\(a_i\)
1、元素大于等于\(f[len]\)时,直接将该元素插入到\(f\)序列的末尾
2、元素小于\(f[len]\)时,找到\(i∈[1, len]\)中,第一个大于\(a_i\)的元素,用\(a_i\)替换它
3、\(len\)为已求出的LIS的最长长度
原理:当\(len\)一样时,\(a_i\)的值越小,\(LIS\)变长的潜力越大
时间复杂度: \(O(nlog(n))\)
代码示例:

#include <bits/stdc++.h>
using namespace std;

/*
1、LIS的nlogn写法,并记录路径
2、给原序列的每个下标都记录其在LIS中排在第几位
3、从后往前找,依次找到LIS中的每个位置的数
4、和f的区别在于,在查找时,后面的数可能会更新f,因此,直接输出f不对
*/

const int maxn = 1010;  

int n = 0;
int a[maxn];
//pos[i]用来标记第i个数,在最长上升序列中排在哪个位置
//p用来存储处理后的最长上升序列
int f[maxn], ans = 0, pos[maxn], p[maxn];

int main()
{   
	int x = 0;
	
	while(scanf("%d", &x) != EOF)
	{
		a[++n] = x;
	}

	f[++ans] = a[1];
	pos[1] = 1;
	for(int i=2; i<=n; i++)
	{
		if(f[ans] < a[i])
		{
			f[++ans] = a[i];
			pos[i] = ans;
			continue;
		}
		
		x = lower_bound(f+1, f+1+ans, a[i]) - f;
		f[x] = a[i];
		pos[i] = x;
	}
	
	printf("max=%d\n", ans);
	int tmp = ans;
	for(int i=n; i>=1&&tmp>=1; i--)
	{
		if(pos[i] == tmp) p[tmp--] = a[i];
	}
	for(int i=1; i<=ans; i++) printf("%d ", p[i]);

	return 0;
}   

最长公共子序列(LCS)

问题描述:给定两个长度分别为n和m的字符串A和B,求既是A的子序列又是B的子序列的字符串长度最长时多少
状态表示:f[i][j]表示s1的前i个字符,s2的前j个字符的最长公共子串
状态转移方程:
\(F[i,j] = \max\begin{cases}F[i-1,j] \\F[i,j-1] \\F[i-1,j-1] + 1 \quad \text{if } A[i]=B[j]\end{cases}\)
边界:f[i,0]=f[0,j]=0
目标:f[n, m]
例题: 最长公共子序列(LCS)

#include <bits/stdc++.h>
using namespace std;

/*
最长公共子序列(LCS)模板题
*/

const int maxn = 1010;

int n = 0;
int a[maxn], b[maxn];
int f[maxn][maxn];

int main()
{	  
	scanf("%d", &n);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	for(int i=1; i<=n; i++) scanf("%d", &b[i]);
	
	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=n; j++)
		{
			f[i][j] = max(f[i-1][j], f[i][j-1]);
			if(a[i] == b[j])
			{
				f[i][j] = max(f[i][j], f[i-1][j-1] + 1);
			}
		}
	}
	printf("%d", f[n][n]);

	return 0;
}  

背包dp

0/1背包

题目概要:
\(n\)个物品和一个容量为\(V\)的背包,每个物品有体积\(v_i\)和价值\(w_i\)两种属性,要求选若干物品放入背包,使背包中物品的总价值最大且背包中物品的总体积不超过背包的容量。
在上述例题中,由于每个物品只有两种可能得状态(取与不取),对应二进制中的0和1,所以这类问题便被称为01背包问题。

0/1背包 & 朴素版

例题中,已知条件有第\(i\)个物品的体积\(v_i\),价值\(w_i\)以及背包的总容量\(V\)
设DP的状态\(f[i][j]\)为在只能放前\(i\)个物品的情况下,容量为\(j\)的背包所能达到的最大总价值
考虑转移
假设当前已经处理好了前\(i-1\)个物品的所有状态,那么对于第\(i\)个物品:
1、当其不放入背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为\(f[i-1][j]\)
2、当其放入背包时,背包的剩余容量会减小\(v_i\),背包中物品的总价值会增大\(w_i\),故这种情况的最大价值为\(f[i-1][j-v_i] + w_i,j>=v_i\)
由此,可以得出状态转移方程
\(f[i][j] = max(f[i-1][j], f[i-1][j-v_i] + w_i)\)
时间复杂度: \(O(nm)\)
空间复杂度: \(O(nm)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

//f[i][j]表示前i个物品,体积不超过j时的最大价值
//不选第i个物品时,f[i][j] = f[i-1][j]
//选第i个物品时,f[i][j] = f[i-1][j-v[i]]+w[i],保证j>=v[i] 
int f[maxn][maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=0; j<=m; j++)
		{
			f[i][j] = f[i-1][j];
			if(j>=v[i]) f[i][j] = max(f[i][j], f[i-1][j-v[i]] + w[i]);
		}
	}
	printf("%d", f[n][m]);

	return 0;
}

0/1背包 & 滚动数组

上述朴素做法中,直接采用二维数组对状态进行记录,定义数组\(f[数量][体积]\),如果数量和体积过大,就会出现\(MLE\)
通过梳理题目逻辑及观察代码发现,\(f[i][j]\)的状态只与\(f[i-1][j]\)有关,因此可以用滚动数组优化。
滚动数组简介:
1、假设定义一个数组\(f[2][maxn]\)
2、计算\(f[1][j]\)的时候,可以通过\(f[0][j]\)得出
3、计算\(f[2][j]\)时,通过\(f[1][j]\)得出,并保存到\(f[0][j]\)数组
4、计算\(f[3][j]\)时,通过\(f[0][j]\)得出,并保存到\(f[1][j]\)数组
5、依次类推,只需要两维数组,滚动使用即可
6、可利用维数的奇偶性实现滚动,借助二进制的按位运算符\(&\)实现
时间复杂度: \(O(nm)\)
空间复杂度: \(O(2m)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

int f[2][maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=0; j<=m; j++)
		{
			f[i&1][j] = f[(i-1)&1][j];
			if(j>=v[i]) f[i&1][j] = max(f[i&1][j], f[(i-1)&1][j-v[i]] + w[i]);
		}
	}
	printf("%d", f[n&1][m]);

	return 0;
}

0/1背包 & 终极版

上述方法中,用滚动数组优化,已经很好了,但仍然需要二维数组,那有没有可能优化为一维呢,答案是肯定的。
通过梳理逻辑及观察代码发现:
1、当计算\(f[i][j]\)时,其状态要么由\(f[i-1][j]\)转移来,要么由\(f[i-1][j-v_i]\)转移来,总之都是由第\(i\)维的\(j\)\(j\)之前的状态转移来
2、因此,可以优化到一维,然后倒序循环\(j\),这样\(j\)之前的数值还保存着\(i-1\)维的状态,直接用就可以
image

状态转移方程:
\(f[j] = max(f[j], f[j-v_i] + w_i)\)
注意:01背包代码,\(j\)一定是倒序
时间复杂度: \(O(nm)\)
空间复杂度: \(O(m)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

int f[maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=m; j>=v[i]; j--)
		{ 
			f[j] = max(f[j], f[j-v[i]] + w[i]);
		}
	}
	printf("%d", f[m]);

	return 0;
}

完全背包

题目概要:
\(n\)个物品和一个容量为\(V\)的背包,每种物品都有无限件可用,每个物品有体积\(v_i\)和价值\(w_i\)两种属性,要求选若干物品放入背包,使背包中物品的总价值最大且背包中物品的总体积不超过背包的容量。
注意:
1、完全背包模型与01背包类似,与01背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。
2、完全背包也可以采用下面多重背包进行二进制拆分,然后转换为01背包,但拆完后时间负责度会多个log,效率反而更低

完全背包 & 朴素版

思路:
1、可以借鉴01背包的思路,进行状态定义:设\(f[i][j]\)为只能选\(i\)个物品时,容量为\(j\)的背包可以达到的最大价值
2、虽然定义与01背包类似,但是其状态转移方程与01背包并不相同
朴素做法: 对于第\(i\)件物品,枚举其选了多少个来转移
时间复杂度: \(O(n^2m)\)
空间复杂度: \(O(nm)\)
状态转移方程:
\(f[i][j] = max(f[i-1][k*v_i] + k*w_i) (k∈[0, +∞], k*v_i<=j)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

//f[i][j]表示前i个物品,体积不超过j时的最大价值
//f[i][j]=max(f[i-1][j], f[i-1][j], f[i-1][j-v[i]]+w[i], f[i-1][j-2*v[i]]+2*w[i], ....)
int f[maxn][maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=0; j<=m; j++)
		{ 
			for(int k=0; k*v[i]<=j; k++)
			{
				f[i][j] = max(f[i][j], f[i-1][j-k*v[i]] + k*w[i]);
			}
		}
	}
	printf("%d", f[n][m]);

	return 0;
}

完全背包 & 二层循环

将状态转移方程拆开来写:
50014b37a711b9417b68dc1d7b7f2c2d
由此推出新的状态转移方程:
\(f[i][j] = max(f[i-1][j], f[i][j-v_i]+w_i)\)
这样就可以将三层循环优化到二层循环
时间复杂度: \(O(nm)\)
空间复杂度: \(O(nm)\)
示例代码:

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

//f[i][j]表示前i个物品,体积不超过j时的最大价值 
//f[i][j] = max(f[i-1][j], f[i][j-v] + w)
int f[maxn][maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=0; j<=m; j++)
		{ 
			f[i][j] = f[i-1][j];
			if(j >= v[i]) f[i][j] = max(f[i][j], f[i][j-v[i]] + w[i]);
		}
	}
	printf("%d", f[n][m]);

	return 0;
}

完全背包 & 终极版

类似01背包,也可以将状态\(f\)数组优化为一维,此时\(j\)需要正序循环
image

注意:
此时神奇的一幕出现了,对比01背包和完全背包的代码发现:
\(j\)倒序循环即为01背包,正序循环即为完全背包
时间复杂度: \(O(nm)\)
空间复杂度: \(O(m)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1010;

int f[maxn] = {};	//默认全为0,这样后面就不需要再初始化
int n = 0, m = 0;	//n件物品,m为背包总容量
int v[maxn] = {}, w[maxn] = {};	//v表示第i件物品体积,w为第i件物品价值

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d%d", &v[i], &w[i]); 
	
	for(int i=1; i<=n; i++)
	{
		for(int j=v[i]; j<=m; j++)
		{ 
			f[j] = max(f[j], f[j-v[i]] + w[i]);
		}
	}
	printf("%d", f[m]);

	return 0;
}

多重背包

题目概要:
\(n\)个物品和一个容量为\(V\)的背包,第\(i\)种物品最多有\(s_i\)件,每个物品有体积\(v_i\)和价值\(w_i\)两种属性,要求选若干物品放入背包,使背包中物品的总价值最大且背包中物品的总体积不超过背包的容量。
分析:
多重背包也是01背包的一个变式,与01背包的区别在于每种物品有\(s_i\)个,而非一个。

直接转化为0/1背包

一个很朴素的想法:把每种物品选\(s_i\)次,等价转换为有\(s_i\)个相同的物品,每个物品选一次,这样就转换成了01背包模型,直接用01背包解题即可。
时间复杂度: \(O\left(V \sum_{i=1}^{n} s_i\right)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 110;

int n = 0, m = 0;	 
int f[maxn] = {};	 
int v[maxn] = {}, w[maxn] = {}, s[maxn] = {};	 

int main()
{	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &v[i], &w[i], &s[i]);
	}

	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=s[i]; j++)
		{
			for(int k=m; k>=v[i]; k--)
			{
				f[k] = max(f[k], f[k-v[i]] + w[i]);
			}			
		}
	}
	printf("%d", f[m]);

	return 0;
}

多重背包 & 二进制拆分

显然,复杂度中的\(O(nV)\)部分无法再优化了,我们只能从\(O(∑s_i)\)处入手。
朴素做法当中是将\(s_i\)拆分成一个一个,这样可以拼凑起所有的可能值
而我们可以通过二进制分组的方式,使拆分方式更加优美
即将\(s_i\)拆分成\(2^0,2^1,2^2\),....,余数。
根据二进制的原理,这样拆分也可以拼凑成s_i中的任何数。
时间复杂度: \(O\left(V \sum_{i=1}^{n} \log_{2} s_i\right)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 15000;
const int maxm = 2010;

int n = 0, m = 0;	 
int f[maxm] = {};	 
int v[maxn] = {}, w[maxn] = {}, s[maxn] = {}, cnt = 0;	 

int main()
{	
	int vi = 0, wi = 0, si = 0;
	scanf("%d%d", &n, &m);
	//二进制拆分
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &vi, &wi, &si);
		if(si > m / vi) si = m / vi;
		for(int j=1; j<=si; j<<=1)
		{
			v[++cnt] = j * vi;
			w[cnt] = j * wi;
			si -= j;
		}
		if(si > 0)
		{
			v[++cnt] = si * vi;
			w[cnt] = si * wi;
		}
	}
	
	//0/1背包
	for(int i=1; i<=cnt; i++)
	{
		for(int j=m; j>=v[i]; j--)
		{
			f[j] = max(f[j], f[j-v[i]] + w[i]);
		}	
	}
	printf("%d", f[m]);

	return 0;
}

混合背包

题目概要:
\(n\)个物品和一个容量为\(V\)的背包,有的物品只能取1件,有的物品可以取无数件,有的物品只能取\(s_i\)件,每个物品有体积\(v_i\)和价值\(w_i\)两种属性,要求选若干物品放入背包,使背包中物品的总价值最大且背包中物品的总体积不超过背包的容量。
分析:
这种题目是01背包、完全背包、多重背包的混合,可取无数次的物品,可以算出最多取多少件,即\(s_i=V/v_i\),然后进行二进制拆分,转换为01背包求解

分组背包

题目概要:
\(n\)组物品和一个容量为\(V\)的背包,每组物品有若干个,同一组的物品最多只能选一个,每件物品的体积是\(v_{i,j}\),价值是\(w_{i,j}\),其中\(i\)是组号,\(j\)是组内编号。要求选若干物品放入背包,使背包中物品的总价值最大且背包中物品的总体积不超过背包的容量。
分析:
这种题怎么想呢?其实是从“在所有物品中选择一件”变成了“从当前组中选择一件”,于是就对每一组进行一次01背包就可以了

分组背包 & 朴素版

例题: 分组背包

#include <bits/stdc++.h>
using namespace std;

/*
1、按朴素版01背包来做
2、f[i][j]表示前i组物品,体积不超过j的最大价值
3、f[i][j]=max(f[i][j], f[i-1][j-v[x]+w[x]])。(x∈g[i])
*/

const int maxn = 40;
const int maxm = 210;

//分组背包
//n表示物品数量,m表示背包容量,t表示分组数量
int n = 0, m = 0, t = 0;	 
int v[maxn] = {}, w[maxn] = {};
//g[i][0]表示第i组共有多少个物品
//g[i][j]表示第i组第j个物品的编号
int g[15][maxn] = {};
//f[i][j]表示前i组物品,体积不超过j的最大价值
int f[15][maxm] = {};

int main()
{	 
	int x = 0;
	scanf("%d%d%d", &m, &n, &t); 
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &v[i], &w[i], &x);
		g[x][++g[x][0]] = i;
	}
	
	for(int i=1; i<=t; i++)
	{
		for(int j=0; j<=m; j++)
		{
			f[i][j] = f[i-1][j];
			//组内物品循环放到最内层,以保证每组物品只用一次
			for(int k=1; k<=g[i][0]; k++)
			{
				if(j >= v[g[i][k]]) 
				{
					x = g[i][k];
					f[i][j] = max(f[i][j], f[i-1][j-v[x]] + w[x]);	
				}
			}
		}
	}
	printf("%d", f[t][m]);

	return 0;
}

分组背包 & 终极版

#include <bits/stdc++.h>
using namespace std;

/*
1、按终极版01背包来做,将f优化到一维 
*/

const int maxn = 40;
const int maxm = 210;

//分组背包
//n表示物品数量,m表示背包容量,t表示分组数量
int n = 0, m = 0, t = 0;	 
int v[maxn] = {}, w[maxn] = {};
//g[i][0]表示第i组共有多少个物品
//g[i][j]表示第i组第j个物品的编号
int g[15][maxn] = {};
//f[j]表示前i组物品,体积不超过j的最大价值
int f[maxm] = {};

int main()
{	 
	int x = 0;
	scanf("%d%d%d", &m, &n, &t); 
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &v[i], &w[i], &x);
		g[x][++g[x][0]] = i;
	}
	
	for(int i=1; i<=t; i++)
	{
		for(int j=m; j>=0; j--)
		{
			//组内物品循环放到最内层,以保证每组物品只用一次
			for(int k=1; k<=g[i][0]; k++)
			{
				if(j >= v[g[i][k]]) 
				{
					x = g[i][k];
					f[j] = max(f[j], f[j-v[x]] + w[x]);	
				}
			}
		}
	}
	printf("%d", f[m]);

	return 0;
}

二维费用背包

题目概要:
\(n\)个任务需要完成,完成第\(i\)个任务需要花费\(t_i\)分钟,产生\(c_i\)元的开支。现在有\(T\)分钟时间,\(W\)元钱来处理这些任务,求最多能完成多少任务
分析:
很明显,这是01背包问题,可是不同的是,选一个物品会消耗两种价值(经费、时间),只需要在状态中增加一维存放第二种价值即可

二维费用背包 & 朴素版

按朴素01背包处理,定义三维数组f[i][j][k],表示前i个物品,花费j分钟、k元的最大价值
模板题: NASA的食物计划

#include <bits/stdc++.h>
using namespace std;

const int maxn = 60;
const int maxm = 410;

//二维费用背包
int n = 0, v = 0, m = 0;	 
int a[maxn] = {}, b[maxn] = {}, c[maxn] = {};
int f[maxn][maxm][maxm] = {};

int main()
{	 
	scanf("%d%d%d", &v, &m, &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &a[i], &b[i], &c[i]);
	}
	
	for(int i=1; i<=n; i++)
	{
		for(int j=0; j<=v; j++)
		{
			for(int k=0; k<=m; k++)
			{
				f[i][j][k] = f[i-1][j][k];
				if(j>=a[i] && k>=b[i]) f[i][j][k] = max(f[i][j][k], f[i-1][j-a[i]][k-b[i]] + c[i]);
			}
		}
	}
	printf("%d", f[n][v][m]);

	return 0;
}

二维费用背包 & 终极版

上述朴素版中,三维数组很容易MLE,因此按01背包的终极版本,优化成一维即可

#include <bits/stdc++.h>
using namespace std;

const int maxn = 60;
const int maxm = 410;

//二维费用背包
int n = 0, v = 0, m = 0;	 
int a[maxn] = {}, b[maxn] = {}, c[maxn] = {};
int f[maxm][maxm] = {};

int main()
{	 
	scanf("%d%d%d", &v, &m, &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d%d%d", &a[i], &b[i], &c[i]);
	}
	
	for(int i=1; i<=n; i++)
	{
		for(int j=v; j>=a[i]; j--)
		{
			for(int k=m; k>=b[i]; k--)
			{
				f[j][k] = max(f[j][k], f[j-a[i]][k-b[i]] + c[i]);
			}
		}
	}
	printf("%d", f[v][m]);

	return 0;
}

有依赖的背包

题目概要:
「Luogu P1064」金明的预算方案
今明有\(n\)元钱,想要买\(m\)个物品,第\(i\)件物品的价格为\(v_i\),重要度为\(p_i\),有些物品是从属于某个主件物品的附件,要买这个物品,必须购买它的主件,目标是让所有购买的物品的\(v_i*p_i\)之和最大。

分析:
对于一个主件和它的若干附件,有以下几种可能
①什么都不买
②只买主件
③买主件和附件1
④买主件和附件2
⑤买主件和两个附件
这五种情况为一组,一组只能选择一种情况
所以可以将这看成分组背包

区间dp

定义

区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系
令状态\(f(i, j)\)表示将下标位置\(i\)\(j\)的所有元素合并能获得的价值的最大值,那么
\(f(i, j) = max(f(i, k) + f(k+1, j) + cost)\)
\(cost\)为将这两组元素合并起来的价值

特点

合并: 意思就是将两个或多个部分进行整合,当然也可以反过来,也就是将一个问题分解成两个或多个部分
特征: 能将问题分解成为两两合并的形式
求解: 对整个问题设最优值,枚举合并点,将问题分解成为左右两个部分,最后将左右两个部分的最优值进行合并得到原问题的最优值。

例题

石子合并1

分析:
1、假设只有\(2\)堆石子,显然只有\(1\)种合并方案
2、如果有\(3\)堆石子,则有\(2\)种合并方案,\(((1,2),3)和(1,(2,3))\)
3、如果有\(k\)堆石子呢?
4、不管怎么合并,最后总会归结为\(2\)堆,如果把最后\(1\)堆分开,左边和右边无论怎么合并,都必须满足最优合并方案,整个问题才能得到最优解。
如下图:
image
算法设计:
1、第\(i\)堆到第\(j\)堆石子数总和用前缀和维护,\(sum[i,j]=s[j]-s[i-1]\)
2、\(f[i][j]\)表示将第\(i\)堆石子合并到第\(j\)堆石子的最小得分,\(g[i][j]\)表示将第\(i\)堆石子合并到第\(j\)堆石子的最大得分。
3、初始值:\(f[i][i]=0,g[i][i]=0\),因为\(f\)表示求最小得分,所以其他值要赋为极大值
4、状态转移方程:
\(f[i][j]=min(f[i][j], f[i][k]+f[k+1][j]+s[j]-s[i-1]),k∈[i,j-1]\)
\(g[i][j]=max(g[i][j], g[i][k]+g[k+1][j]+s[j]-s[i-1]),k∈[i,j-1]\)
时间复杂度: \(O(n^3)\)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 110;

int n = 0;
//f[i][j]表示i~j堆这个区间的总分最少
//f[i][j]=min(f[i][j], f[i][k]+f[k+1][j]+s[j]-s[i-1]),k属于i~j-1
//g[i][j]表示i~j堆这个区间的总分最多
int f[maxn][maxn], g[maxn][maxn];
//s表示a的前缀和
int a[maxn], s[maxn];

int main()
{	
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);
		s[i] = s[i-1] + a[i];	
	}
	
	//f表示最小值,所以都初始化为极大值
	memset(f, 0x3f, sizeof(f));
	//只有一个石子的时候,不能合并,因此初始化为0
	for(int i=1; i<=n; i++) f[i][i] = 0;
	
	for(int len=2; len<=n; len++)	//阶段,区间长度
	{
		for(int i=1; i+len-1<=n; i++)
		{
			int j = i + len - 1;
			for(int k=i; k<j; k++)
			{
				f[i][j] = min(f[i][j], f[i][k]+f[k+1][j]+s[j]-s[i-1]);
				g[i][j] = max(g[i][j], g[i][k]+g[k+1][j]+s[j]-s[i-1]);
			}
		}
	}
	printf("%d\n%d\n", f[1][n], g[1][n]);

	return 0;
}

石子合并2

分析:
1、与石子合并1的区别在于,现在\(n\)堆石子围成了一个环
2、一个直接的想法是,尝试在每个位置将环断开,拆成一条链,然后同石子合并1
3、时间复杂度: \(O(n^4)\)
优化:
1、处理环的问题,一般是将环断开,然后在后面复制一遍,即将这条链延长至\(2\)倍,扩展成\(2n-1\)
2、其中第\(1\)堆与\(n+1\)堆完全相同,第\(i\)堆与\(n+i\)堆完全相同,这样我们只要对这\(2n\)堆动态规划后,枚举\(f(1,n),f(2,n+1),···,f(n, 2n-1)\)取最优值即可
3、时间复杂度: \(O((2n)^3)\)\(O(n^3)\)
image

#include <bits/stdc++.h>
using namespace std;

const int maxn = 210;

int n = 0;
//f[i][j]表示i~j堆这个区间的总分最少
//f[i][j]=min(f[i][j], f[i][k]+f[k+1][j]+s[j]-s[i-1]),k属于i~j-1
//g[i][j]表示i~j堆这个区间的总分最多
int f[maxn][maxn] = {}, g[maxn][maxn] = {};
//s表示a的前缀和
int a[maxn] = {}, s[maxn] = {};

int main()
{	
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);
		a[n+i] = a[i]; 
	}
	for(int i=1; i<=n*2; i++) s[i] = s[i-1] + a[i];	
	
	//f表示最小值,所以都初始化为极大值
	memset(f, 0x3f, sizeof(f));
	//只有一个石子的时候,不能合并,因此初始化为0
	for(int i=1; i<=n*2; i++) f[i][i] = 0;
	
	for(int len=2; len<=n; len++)	//阶段,区间长度
	{
		for(int i=1; i+len-1<=2*n; i++)
		{
			int j = i + len - 1;
			for(int k=i; k<j; k++)
			{
				f[i][j] = min(f[i][j], f[i][k]+f[k+1][j]+s[j]-s[i-1]);
				g[i][j] = max(g[i][j], g[i][k]+g[k+1][j]+s[j]-s[i-1]);
			}
		}
	}
	int ans1 = 0x7fffffff, ans2 = 0;
	for(int i=1; i<=n; i++)
	{
		ans1 = min(ans1, f[i][n+i-1]);
		ans2 = max(ans2, g[i][n+i-1]);
	}
	printf("%d\n%d\n", ans1, ans2);

	return 0;
} 

石子合并3

分析:
1、\(n\)的最大值为\(2000\),此时\(O(n^3)\)做法就会\(TLE\)
2、猜想: 合并第\(i\)堆到第\(j\)堆石子的最优断开位置\(s[i, j]\),要么等于\(i+1\),要么等于\(j-1\),也就是说最优合并方案是:
image
3、直观理解(不严谨):
①令\(f[i][j]\)表示\(i~j\)堆这个区间的总分最多
\(f[i][j]\)\(f[i+1][j]\)\(a[i]\)合并而来 或者 \(f[i][j]\)\(f[i][j-1]\)\(a[j]\)合并而来
4、严谨的证明:https://www.cnblogs.com/kuangbiaopilihu/p/19510799
5、这类问题一般都是打表,找规律,猜性质
6、时间复杂度:\(O(n^2)\)
注意:
这种方式只适用于求最大值
求最小值需要用到Garsia–Wachs算法。https://oi-wiki.org/misc/garsia-wachs/

#include <bits/stdc++.h>
using namespace std;

const int maxn = 4010;

int n = 0;
//f[i][j]表示i~j堆这个区间的总分最多
//1、f[i][j]由f[i+1][j]与a[i]合并而来
//2、f[i][j]由f[i][j-1]与a[j]合并而来
int f[maxn][maxn] = {};
//s表示a的前缀和
int a[maxn] = {}, s[maxn] = {};

int main()
{	
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);
		a[n+i] = a[i]; 
	}
	for(int i=1; i<=n*2; i++) s[i] = s[i-1] + a[i];	
	
	for(int len=2; len<=n; len++)	//阶段,区间长度
	{
		for(int i=1; i+len-1<=2*n; i++)
		{
			int j = i + len - 1;
			f[i][j] = max(f[i+1][j], f[i][j-1]) + s[j] - s[i-1];
		}
	}
	int ans = 0;
	for(int i=1; i<=n; i++)
	{
		ans = max(ans, f[i][n+i-1]);
	}
	printf("%d\n", ans);

	return 0;
} 

坐标dp

一般是在矩阵上进行状态转移

例题 传纸条

贪心做法(错误做法)

很容易想到一个算法:
1、求出1个纸条从(1,1)到(m,n)的路线最大值
2、删除路径上的点值
3、再求出1个纸条从(m,n)到(1,1)的路线最大值
4、统计两次和
上述做法很容易找到反例,如下图
image
第1次找最优值传递后,导致第2次无法传递

做法1(\(O(n^2 m^2)\))

贪心算法错误,因此我们需要同时考虑两个纸条的传递
由于小渊和小轩的路径可逆,因此,尽管出发点不同,但都可以看成同时从(1,1)出发到达(m,n)点
1、设 \(f(i₁,j₁,i₂,j₂)\) 表示纸条1到达 \((i₁,j₁)\) 位置,纸条2到达 \((i₂,j₂)\) 位置的最优值。
2、任意点(i,j)只能由上面或左面的点转移过来,因此

\[f(i_1,j_1,i_2,j_2) = \max \begin{cases} f(i_1-1,j_1,i_2-1,j_2) \\ f(i_1,j_1-1,i_2-1,j_2) \\ f(i_1-1,j_1,i_2,j_2-1) \\ f(i_1,j_1-1,i_2,j_2-1) \end{cases} + C[i_1,j_1] + C[i_2,j_2] \]

以上四种情况,分别对应从上上、左上、上左、左左转移而来
其中:

  • \((i_1,j_1) \lt (i_2,j_2)\)
  • \(1 \le i_1,i_2 \le M,\ 1 \le j_1,j_2 \le N\)
  • 时间复杂度 \(O(N^2M^2)\)

示例代码:

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 55;
const int maxm = 55;

//该算法时间和空间复杂度都是n^2*m^2
int n = 0, m = 0; 
//f[i1][j1][i2][j2]表示两个纸条分别到达(i1,j1)和(i2,j2)时的最大值
int f[maxn][maxm][maxn][maxm] = {};
int a[maxn][maxm] = {};

int mymax(int a, int b, int c, int d)
{
	a = max(a, b);
	a = max(a, c);
	a = max(a, d);
	return a;
}

int main() 
{	 
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=m; j++)
		{
			scanf("%d", &a[i][j]);
		}
	}
	for(int i1=1; i1<=n; i1++)
	{
		for(int j1=1; j1<=m; j1++)
		{
			for(int i2=1; i2<=n; i2++)
			{
				for(int j2=1; j2<=m; j2++)
				{
					if(i1==i2 && j1==j2) continue;
					f[i1][j1][i2][j2] = mymax(f[i1-1][j1][i2-1][j2],//1上2上
											f[i1-1][j1][i2][j2-1],//1上2左
											f[i1][j1-1][i2-1][j2],//1左2上
											f[i1][j1-1][i2][j2-1])//1左2左
											+ a[i1][j1] + a[i2][j2];
				}
			}
		}
	}
	//因为最后一个点i==n && j==m,所以f[n][m][n][m]=0
	//f[n][m-1][n-1][m]表示一个点在右下角的左面,一个在上面
	printf("%d", f[n][m-1][n-1][m]);
 
	return 0;
}  

做法2(\(O((n+m)m^2)\))

1、每个纸条都需要走m+n-1步才能到达目标
2、因此,设 \(f(k,i_1,i_2)\) 表示两个纸条都走了 \(k\) 步,第 1 个纸条横坐标为 \(i_1\)、第 2 个纸条横坐标为 \(i_2\) 的最优值。
3、两个纸条的纵坐标分别为 \(j_1=k+1-i_1\)\(j_2=k-i+1_2\),状态转移方程如下:

\[f(k,i_1,i_2) = \max \begin{cases} f(k-1,i_1,i_2) \\ f(k-1,i_1-1,i_2) \\ f(k-1,i_1,i_2-1) \\ f(k-1,i_1-1,i_2-1) \end{cases} + C[i_1,k+1-i_1] + C[i_2,k+1-i_2] \]

其中:

  • \(i_1 <> i_2\)
  • \(1 \le i_1,i_2 \le M,\ 1 \le k \le N+M-1\)
  • 时间复杂度 \(O((N+M) \cdot M^2)\)

解释:
以上四种情况,分别对应左左、上左、左上、上上转移而来
\(f(k-1, i_1, i_2-1)\)为例
\(f(k,i_1, i_2)\)对应的点为\((i_1, j_1)(i_2, j_2)\)
\(f(k-1, i_1, i_2-1)\)对应的点为\((i_1, j_1-1)(i_2-1, j_2)\)
即第一个点由左边转移而来,第二个点由上面转移而来

示例代码:

#include <bits/stdc++.h>
using namespace std;

/*
题意是m行n列,我的代码是按n行m列
*/

typedef long long ll;
const int maxn = 55;
const int maxm = 55;

int n = 0, m = 0; 
//f[k][i1][i2]表示两个纸条都走了k步,分别到达点(i1, j1)、(i2, j2)
//j1 = k+1-i1, j2=k+1-i2
//f[n+m-2][n][n-1]即为所求
int f[maxn+maxm][maxn][maxm] = {};
int a[maxn][maxm] = {}; 
  
int mymax(int a, int b, int c, int d)
{
	a = max(a, b);
	a = max(a, c);
	a = max(a, d);
	return a;
}
  
int main()
{	  
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++)
	{
		for(int j=1; j<=m; j++)
		{
			scanf("%d", &a[i][j]);
		}
	}
	
	for(int k=2; k<=n+m-1; k++)
	{
		for(int i1=1; i1<=n; i1++)
		{
			for(int i2=1; i2<=n; i2++)
			{
				int j1 = k + 1 - i1, j2 = k + 1 - i2;
				if(j1<1 || j2<1) continue;	//有可能会导致j越界
				if(i1 == i2) continue;	//不能经过同一个点
				f[k][i1][i2] = mymax(	f[k-1][i1-1][i2-1],	//1上2上
									f[k-1][i1-1][i2],	//1上2左
									f[k-1][i1][i2-1],	//1左2上
									f[k-1][i1][i2]		//1左2左
									) + a[i1][k+1-i1] + a[i2][k+1-i2];
			}
		}
	}
	printf("%d", f[n+m-2][n-1][n]);
 
	return 0;
}  

树形dp

定义

树形dp,即在树上进行的dp。由于树固有的递归性质,树形dp一般都是递归进行的。
由于树没有环,dfs是不会重复的,并且具有明显而又严格的层数关系。利用这一特性,我们可以很清晰地根据题目写出一个在树形结构上的记忆化搜索的程序。

一般过程

判断是否是一道树归题:即判断数据结构是否是一棵树,然后是否符合动态规划的要求。如果是,那么执行以下步骤,如果不是,那么换台

建树

通过数据量和题目要求,选择合适的树的存储方式
如果节点数小于5000,那么我们可以用邻接矩阵存储,如果更大可以用邻接表来存储(注意:根据题目判断边是否是双向的,如果是双向的,边要开到2 * n。这是血与泪的教训)。如果是二叉树或者是需要多叉转二叉,那么我们可以用两个一维数组brother[],child[]来存储

写出树归方程

通过观察孩子和父亲之间的关系建立方程。我们通常认为,树形dp的写法有两种:
1、叶子到根:即根的子节点传递有用的信息给根,完后根得出最优解的过程。
2、根到叶子:这种动态规划在实际的问题中运用的不多。
注意:这两种写法一般情况下是不能相互转化的,但有时可以同时使用。

例题

加分二叉树

分析

中序遍历为 \(1,2,3,4,5\) 的二叉树有很多,下图是其中的三棵,其中第三棵加分最大,为 \(145\)
397868b7-0edc-466e-9e5f-98638a668df8
显然只给出中序遍历,任何一个点 \(k\) 都可以做根,\([1, k-1]\) 为左子树,\([k+1, n]\) 为右子树。
性质:中序遍历是按“左-根-右”方式进行遍历二叉树,因此二叉树左孩子遍历序列一定在根结点的左边,右孩子遍历序列一定在根结点的右边!
因此,假设二叉树的根结点为 \(k\),那么中序遍历为 \(1,2,\dots,n\) 的遍历序列,左孩子序列为 \(1,2,\dots,k-1\),右孩子序列为 \(k+1,k+2,\dots,n\),如下图:
b4ec8851-726a-408b-bd9a-9d52a44cc3b2
状态定义:
f[i][j]表示[i,j]这个区间的最高分数
状态转移方程:
尝试将[i,j]区间内每个节点k作为根节点,则[i,k)为左子树,(k,j]为右子树
f[i][j] = max(f[i][j], f[i][k-1] * f[k+1][j] + a[k]) 1<=i<=k<=j<=n
初始值:
先全部初始化为1,主要应对空子树情况。比如[1,2],当k为1时,左子树为f[1][0],此时f[1][0]应为1
再将每个节点最高加分初始化为自身的值

示例代码

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 40;

int n = 0;
//f[i][j]表示[i,j]这个区间的最高分数
//尝试将[i,j]区间内每个节点k作为根节点,则[i,k)为左子树,(k,j]为右子树
//f[i][j] = max(f[i][j], f[i][k-1] * f[k+1][j] + a[k])	1<=i<=k<=j<=n
ll f[maxn][maxn] = {}, r[maxn][maxn] = {}; 
ll a[maxn] = {};

void p(int x, int y)
{
	if(r[x][y] == 0) return;
	
	printf("%d ", r[x][y]);
	p(x, r[x][y] - 1);
	p(r[x][y] + 1, y);
}

int main()
{	
	//全部初始化为1,主要应对空子树情况
	//比如[1,2],当k为1时,左子树为f[1][0],此时f[1][0]应为1
	for(int i=0; i<maxn; i++)
		for(int j=0; j<maxn; j++)
			f[i][j] = 1;

	scanf("%d", &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%lld", &a[i]);
		f[i][i] = a[i];	//每个节点最高加分初始化为自身的值
		r[i][i] = i;	//每个节点的根节点初始化为自身
	}
	
	for(int len=2; len<=n; len++)
	{
		for(int i=1; i+len-1<=n; i++)
		{
			int j = i + len - 1;
			for(int k=i; k<=j; k++)
			{
				if(f[i][j] < f[i][k-1] * f[k+1][j] + a[k])
				{
					f[i][j] = f[i][k-1] * f[k+1][j] + a[k];
					r[i][j] = k;
				}
			}
		}
	}
	printf("%lld\n", f[1][n]);
	p(1, n);

	return 0;
}  

二叉苹果数

分析

题意给出:树根节点一定是\(1\)号点,所以从一号点开始\(dfs\),
在回溯的时候进行dp
定义状态:
\(f[x][j]\)表示以\(x\)为根节点,保留\(j\)条边,能留下的最大数量
状态转移方程
①对任一棵子树的根结点,有左右两个儿子lson和rson
②对每个son,可能会选0/1/2/3....边
③因此可以理解为分组背包模型,即:
有两个分组,每个分组有\(f[y][j]\)个物品,每组最多只能选一个
\(f[x][j] = max(f[x][j], f[x][j-k-1] + f[y][k] + w[i]) 0<=j<=min(q,a[x]), 0<=k<=min(a[y], j-1)\)
初始值
f全部设为0
其他思考
\(f[x][0]\)表示\(x\)点连接的\(0\)条边,那一定是\(0\)
\(f[x][1]\)表示x点连接的\(1\)条边,那就是子结点取\(f[y][0]\)时的情况
所以,这样定义是没问题的

示例代码

#include <bits/stdc++.h>
using namespace std;

const int maxn = 105;
const int maxm = maxn * maxn;

int n = 0, q = 0;
//f[x][j]表示以x为根节点,保留j条边,能留下的最大数量
//f[x][j] = max(f[x][j], f[x][j-k-1] + f[y][k] + w[i])
//0<j<min(q,a[x]), 0<=k<=min(a[y], j-1)
int f[maxn][maxn] = {};
int h[maxn] = {}, to[maxm] = {}, nxt[maxm] = {}, w[maxm] = {}, tot = 0;
//每个节点对应的边数量
int a[maxn] = {};

void addedge(int x, int y, int z)
{
	to[++tot] = y;
	w[tot] = z;
	nxt[tot] = h[x];
	h[x] = tot;
}

void dfs(int x, int fa)
{
	for(int i=h[x]; i; i=nxt[i])	
	{
		int y = to[i];
		if(y == fa) continue;
		
		dfs(y, x);
		a[x] += a[y] + 1;
		//此处是一个分组背包,套用分组背包模型解决
		for(int j=min(q, a[x]); j>=0; j--)	
		{ 
			for(int k=0; k<=min(j-1, a[y]); k++)	
			{
				f[x][j] = max(f[x][j], f[x][j-k-1] + f[y][k] + w[i]);
			}
		}
	}
}

int main()
{ 
	int x = 0, y = 0, z = 0;
	scanf("%d%d", &n, &q);
	for(int i=1; i<n; i++)
	{
		scanf("%d%d%d", &x, &y, &z);
		addedge(x, y, z);
		addedge(y, x, z);
	}

	dfs(1, 0);
	printf("%d", f[1][q]);

    return 0;
}   

换根dp

定义

树形dp中的换根dp问题,又被称为二次扫描,通常不会指定根节点,并且根节点的变化会对一些值,例如子节点深度和、点权和等产生影响。
通常需要两次dfs,第一次dfs预处理诸如深度、点权和之类的信息,在第二次dfs开始运行换根dp

例题 [POI2008] STA-Station

做法一(\(O(n^2)\))

分析

1、根据题意,可遍历所有点,让每个点都作为一次根节点,求其深度之和
2、状态定义:
\(f[x]\)表示以\(x\)作为根节点的子树,其所有节点的深度之和
3、状态转移方程:
\(f[x] = Σ(f[y] + size[y]),y∈subtree[x]\)
\(y\)\(x\)的子树
\(size[y]\)表示以\(y\)为根节点的子树的节点数量
4、初始值:
\(f\)全部置为\(0\)
5、时间复杂度:(\(O(n^2)\))

示例代码

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 1e6 + 10;
const int maxm = maxn * 2;
 
int n = 0;
int h[maxn], to[maxm], nxt[maxm], tot = 0;
//f[x]表示以x作为根节点的子树,其所有节点的深度之和
//size[y]表示以y为根节点的子树的节点数量 
ll size[maxn], f[maxn];	
 
void addedge(int x, int y)
{
	to[++tot] = y; 
	nxt[tot] = h[x];
	h[x] = tot;
}

//求f
void dfs(int x, int fa)
{
	size[x] = 1;
	
	for(int i=h[x]; i; i=nxt[i])
	{
		int y = to[i];
		if(y == fa) continue;
		
		dfs(y, x);
		
		size[x] += size[y];
		f[x] += f[y] + size[y];
	}
}

int main()
{
	int x = 0, y = 0;
	
	scanf("%d", &n);
	for(int i=1; i<n; i++)
	{
		scanf("%d%d", &x, &y);
		addedge(x, y);
		addedge(y, x);
	}
	
	ll t = -1;
	int ans = 0;

	//让每个节点都作为根节点
	for(int i=1; i<=n; i++)
	{
		memset(size, 0, sizeof(size));
		memset(f, 0, sizeof(f));
		
		dfs(i, 0);
		if(t < f[i])
		{
			t = f[i];
			ans = i;
		}
	}

	printf("%d", ans);
	
	return 0;
} 

做法二(O(n))

分析

1、对于任意点\(x\),当其作为根节点时,深度和由两部分构成
①以\(x\)为根节点的子树的深度和
②除去①后的其他点到\(x\)节点的深度和
image

2、令\(1\)号节点为根节点,按方法一的步骤,求出\(f[]\),这样步骤\(1\)中的第一部分,即以\(x\)为根的子树的深度和就是\(f[x]\)
3、现在要求步骤\(1\)中第二部分的值,该部分的值也有三个小部分组成
\(x\)的父亲节点为\(fa\)
①除了以\(fa\)为根节点的子树外,其他节点到\(fa\)的深度和
\(x\)的兄弟节点到\(fa\)的深度和
③进行完前①②后,除以\(x\)为根节点的子树外,其他所有节点均已到达\(fa\),然后再由\(fa\)\(x\)转移
image

4、状态定义:
\(g[x]\)表示除了以\(x\)为根的子树外其他节点到\(x\)的深度和
5、状态转移方程
步骤3中的①:\(g[fa]\)
步骤3中的②:\(f[fa] - (f[x] + size[x])\)
步骤3中的③:\(n - size[x]\)
以上三部分相加,即:
\(g[x] = g[fa] + f[fa] - (f[x] + size[x]) + n - size[x]\)
化简得:
\(g[x] = g[fa] + f[fa] - f[x] - 2 * size[x] + n\)
6、初始值
\(g\)全部置为\(0\)
7、节点\(x\)作为根的深度和即为\(f[x]+g[x]\),遍历每个节点,求最大值即可
8、时间复杂度:\(O(n)\)

示例代码

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 1e6 + 10;
const int maxm = maxn * 2;
 
int n = 0;
int h[maxn], to[maxm], nxt[maxm], tot = 0;  
//g[x]表示除了以x为根的子树外其他节点到x的深度和
//f[x]表示以x作为根节点的子树,其所有节点的深度之和
//size[y]表示以y为根节点的子树的节点数量 
ll size[maxn], f[maxn], g[maxn];	
 
void addedge(int x, int y)
{
	to[++tot] = y; 
	nxt[tot] = h[x];
	h[x] = tot;
}

//第一次dfs,求出f
void dfs1(int x, int fa)
{
	size[x] = 1;
	
	for(int i=h[x]; i; i=nxt[i])
	{
		int y = to[i];
		if(y == fa) continue;
		
		dfs1(y, x);
		
		size[x] += size[y];
		f[x] += f[y] + size[y];
	}
}

//第二次dfs,求出g
void dfs2(int x, int fa)
{
	g[x] = g[fa] + f[fa] - f[x] - 2 * size[x] + n;
	
	for(int i=h[x]; i; i=nxt[i])
	{
		int y = to[i];
		if(y == fa) continue;
		
		dfs2(y, x);
	}
}

int main()
{
	int x = 0, y = 0;
	
	scanf("%d", &n);
	for(int i=1; i<n; i++)
	{
		scanf("%d%d", &x, &y);
		addedge(x, y);
		addedge(y, x);
	}
	
	dfs1(1, 0);
	
	//第二次dfs涉及到fa,因此不能直接从根节点开始遍历
	//要从根结点的子节点开始遍历 
	for(int i=h[1]; i; i=nxt[i])
	{
		dfs2(to[i], 1);
	} 
	
	//遍历每个节点,求深度和,找出深度和最大的那个节点
	ll t = -1;
	int ans = 0;
	for(int i=1; i<=n; i++)
	{
		if(t < f[i] + g[i])
		{
			t = f[i] + g[i];
			ans = i;
		}
	}
	printf("%d", ans);
	
	return 0;
}   

状态压缩dp

例题

特殊方格棋盘

提高组题库 313.特殊方格棋盘

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;   

int n = 0, m = 0;
int a[25] = {};
//f[i]表示前j(j为i的二进制表示中1的个数)行中最大的方案数
//比如i的二进制为010110,则表示前三行最大的方案数
//最后结果为f[(i<<n)-1]
ll f[(1<<20) + 5] = {};
 
int main()
{ 
	int x = 0, y = 0;
	scanf("%d%d", &n, &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d", &x, &y);
		a[x] |= (1<<(y-1));	//记录x行y-1列不能放置,因为下标从0开始,所以是y-1
	}
	 
	f[0] = 1;
	for(int i=1; i<(1<<n); i++)
	{ 
		//当状态为i时,找到i的二进制中有k个1,即表示当前为第k行
		int k = 0;
		for(int j=0; j<n; j++)
		{
			if(i & (1<<j)) k++;
		}
		 
		for(int j=0; j<n; j++)
		{
			if(a[k] & (1<<j)) continue;
			//^相同为0,不同为1.其实就是枚举如:0111是由0101,0110,0011情况和
			if(i & (1<<j)) f[i] += f[i ^ (1<<j)]; 
		}  
	}
	printf("%lld\n", f[(1<<n)-1]);

	return 0;
}  
posted @ 2024-01-23 08:44  毛竹259  阅读(800)  评论(0)    收藏  举报