动态规划

1.背包DP

背包DP问指的是将若干个若干种物品放进容量为若干的背包里的问题的最优方法。
给出一道经典的背包DP问题:01背包问题。

01背包问题:

给定 \(n\) 种物品和一个背包,第 \(i\) 个物品的体积为 \(c_i\),价值为 \(w_i\),背包的总容量为 \(C\)。一种物品要么装入背包,要么不装入背包。问如何使装入背包中的物品价值和最大?

我们定义 \(dp[i]\) 表示处理到当前物品时背包容量为 \(i\) 的最大价值,状态转移方程即为 \(dp[j] = max(dp[j], dp[j - c[i]] + w[i])\)

核心代码展示:

for(int i = 1; i <= n; i++){
	for(int j = C; j >= c[i]; j--){
		dp[j] = max(dp[j], dp[j - c[i]] + w[i]);
	}
}

01背包还有几个变种.

1.完全背包

完全背包模型与01背包类似,与01背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。

例题:luogu P1616

核心代码:

for(int i = 1; i <= n; i++){
	for(int j = c[i]; j <= C; j++){
		dp[j] = max(dp[j], dp[j - c[i]] + w[i]);
	}
}
2.多重背包

多重背包也是01背包的一个变式。与01背包的区别在于每种物品有 \(k_i\) 个,而非一个。

例题:luogu P1776

先给出TLE代码:

for(int i = 1; i <= n; i++){
	for(int j = W; j >= w[i]; j--){
		for(int k = 1; k <= m[i] && k * w[i] <= j; k++){
			dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
		}
	}
}

这样的三重循环是明显会T的(虽然吸氧能过,数据水),这里要用到一种简单而有效的技巧:二进制拆分优化。

这种优化的原理很简单。例如,有一种物品有 \(19\) 个,把这些物品放入背包一共有 \(20\) 种情况(放 \(0\) ~ \(19\) 个)。但组合出 \(20\) 种情况,并不需要 \(19\) 个物品,因为任何数都能拆分成 \(2\) 的倍数的和(转换成二进制)。如 \(20 = 4 + 16\),这鞋 \(2\) 的倍数只有 \(log_2 X\) 个。在题目中,第 \(i\) 种物品有 \(m[i]\) 个,而用 \(log_2 m[i]\)个数就能组合出 \(0\) ~ \(m[i]\) 种情况。总复杂度从 \(O(W\sum\limits_{i = 1}^nm[i])\) 变为 \(O(W\sum\limits_{i = 1}^nlog_2m[i])\)

注意拆分时不能全部拆成 \(2\) 的倍数,而是先按 \(2\) 的倍数从小到大拆,最后剩下一个小于或等于最大倍数的余数。

AC代码:

//二进制拆分 
for(int i = 1; i <= n; i++){
	for(int j = 1; j <= m[i]; j <<= 1){
		m[i] -= j;
		new_w[++new_n] =  j * w[i];	//新物品 
		new_v[new_n] =  j * v[i];
	}
	if(m[i]){	//余数 
		new_w[++new_n] = m[i] * w[i];
		new_v[new_n] =  m[i] * v[i];
	}
}
//滚动数组的01背包 
for(int i = 1; i <= new_n; i++){
	for(int j = W; j >= new_w[i]; j--){
		dp[j] = max(dp[j], dp[j - new_w[i]] + new_v[i]);
	}
}
3.分组背包

先给题面:luogu P1757
这种题怎么想呢?其实是从在所有物品中选择一件变成了从当前组中选择一件,于是就对每一组物品进行一次01背包就可以了。

核心代码:

for(int i = 1; i <= n; i++){ //有几组 
	for(int j = m; j >= 0; j--){	//枚举容量 
		for(int k = 1; k <= b[i]; k++){	//小组中的物品 
			if(j >= c[a[i][k]]){	//c[a[i][k]]表示第i组中物品k的容量 
				dp[j] = max(dp[j], dp[j - c[a[i][k]]] + w[a[i][k]]);	//w为价值 
			}
		}
	}
}

2.线性DP

线性DP是指状态之间有线性关系的动态规划问题,它不像背包DP、区间DP一样有固定的模板。

给出两道经典的线性DP例题:
1.luogu P1115(最大子段和)

这里定义 \(dp[i]\) 为以 \(a[i]\) 为结尾的最大子段和,那么有:

    1. \(a[i]\) 放入前序列,即:\(dp[i] = dp[i - 1] + a[i]\)
    1. 不将 \(a[i]\) 放入前序列,即:\(dp[i] = dp[i - 1] + a[i]\)

可以得到递推式:\(dp[i] = max(a[i], dp[i - 1] + a[i])\),这明显是一个线性的状态。
初始化:\(dp[1] = a[1]\)
给出代码:

#include<bits/stdc++.h>
#define N 200010
using namespace std;
int n, ans = -0x3f3f3f3f;  //注意!有可能都是负数!这里不初始化为负无穷会WA一个点
int a[N], dp[N];
int main(){
	scanf("%d", &n);
	for(int i = 1; i <= n; i++){
		scanf("%d", &a[i]);
	}
	dp[1] = a[1];  //初始化
	for(int i = 2; i <= n; i++){
		dp[i] = max(a[i], dp[i - 1] + a[i]);  //递推
	}
	for(int i = 1; i <= n; i++){
		ans = max(ans, dp[i]);  //计算所有dp[i]中的最大值
	}
	printf("%d", ans);
	return 0;
}

2.luogu P1216(数字三角形)

发现从底往上推较为简单,所以定义 \(dp[i][j]\) 为从第 \(r\) 行到 第 \(i\)\(j\) 列所能获得的最大和。则有:\(dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j]\),往上递推,上面的取它左下与右下两个数中的更大的那个加上自己。

给出代码:

#include <bits/stdc++.h>
#define N 1010 
using namespace std;
int r, x;
int a[N][N], f[N][N];
int main(){
	scanf("%d", &r)
	for(int i = 1; i <= r; i++){
		for(int j = 1; j <= i; j++){
			scanf("%d", &a[i][j]);
		}
	}
	for(int i = 1; i <= r; i++){
		dp[r][i] = a[r][i];	//初始化,第r行的的最优解为自己 
	}
	for(int i = r - 1; i; i--){	//从底遍历 
		for(int j = 1; j <= i; j++){
			dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j];
		}
	}
	printf("%d", dp[1][1]);
	return 0;
}

3.区间DP

区间DP是线性动态规划的扩展,它的主要思想现在小区间上做DP得到最优解,通过把小区间的答案合并来得到大区间的最优解,最终得到整个区间的答案。
令状态 \(dp(i, j)\) 表示将下标位置 \(i\)\(j\) 的所有元素合并能获得的价值的最大值,那么 \(f(i, j) = \max\{f(i, k) + f(k + 1, j) + cost\}\)\(cost\) 为将这两组元素合并起来的代价。

石子合并(luogu P1880)是一道经典的区间DP题。

这里设计 \(dp[i][j]\) 为将区间 \([i, j]\) 内的石子合并起来得到的最值。

写出状态转移方程:\(dp[i][j] = max/min\{dp[i][k] + dp[k + 1][j] + \sum\limits_{l = i}^ja[l]\}(i \leq k < j)\)

而题中说“在一个圆形操场的四周摆放 \(N\) 堆石子”,只要将原来的数组复制一遍就好了。

完整代码:

#include<bits/stdc++.h>
#define N 210
#define inf 0x3f3f3f3f
using namespace std;
int n, minn = inf, maxx;
int a[N], s[N], dp_min[N][N], dp_max[N][N];
int main(){
	scanf("%d", &n);
	memset(dp_min, inf, sizeof(dp_min));
	for(int i = 1; i <= n; i++){
		scanf("%d", &a[i]);
		a[i + n] = a[i];	//开两倍数组 
	}
	for(int i = 1; i <= n << 1; i++){
		s[i] = s[i - 1] + a[i];	//s[i]表示a[1] ~ a[i]的和 
		dp_min[i][i] = 0;	//自己和自己不需要合并,代价为零 
	}
	for(int len = 2; len <= n << 1; len++){	//len为区间[i, j]的长度 
		for(int i = 1; len + i - 1 <= n << 1; i++){	//i为起点 
			int j = len + i - 1;	//j为终点 
			for(int k = i; k < j; k++){
				dp_max[i][j] = max(dp_max[i][j], dp_max[i][k] + dp_max[k + 1][j] + s[j] - s[i - 1]);	//状态转移方程 
				dp_min[i][j] = min(dp_min[i][j], dp_min[i][k] + dp_min[k + 1][j] + s[j] - s[i - 1]);
			}
		}
	}
	for(int i = 1; i <= n << 1; i++){
		minn = min(minn, dp_min[i][i + n - 1]);
		maxx = max(maxx, dp_max[i][i + n - 1]);
	}
	printf("%d\n%d", minn, maxx);
	return 0;
}

再给出一道题经典区间DP例题,以更好地理解这种DP方式:luogu P1435

在这道题中,我们记 \(dp[i][j]\) 为将区间 \([i, j]\) 中的字符变成回文串所需要插入的最小字符数,答案即为 \(dp[1][n]\)

现在来考虑状态转移方程。

枚举 \([i, j]\) 时,有两种情况:

  • 1.\(s[i] == s[j]\)
    这时,转移到 \(dp[i][j]\) 不需要加入字符,因为最外面的两个字符是一样的,所以直接用往里缩进一个字符的结果,即 \(dp[i][j] = dp[i + 1][j - 1]\)

  • 2.\(s[i] != s[j]\)
    这是我们就直接考虑 \(dp[i + 1][j]\)\(dp[i][j - 1]\) 哪个更小了,因为 \([i + 1, j]\)\([i, j - 1]\) 已经花费代价变成了回文串,而 \(s[i] != s[j]\),所以考虑在 \([i, j]\) 前端或末尾插入一个字符,即 \(dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1\)

所以这道题的状态转移方程为 \(dp[i][j] = \begin{cases} dp[i + 1][j - 1] + 1 & s[i] == s[j] \\ max(dp[i + 1][j], dp[i][j - 1]) + 1 & s[i] != s[j]\end{cases}\)

给出核心代码:

for(int len = 1; len <= n; len++){
	for(int i = 1; i + len - 1 <= n; i++){
		int j = i + len - 1;
		if(s[i] == s[j]){
			dp[i][j] = dp[i + 1][j - 1];
		}
		else{
			dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
		}
	}
}

4.树形DP

树形DP,即在树上进行的DP。由于树固有的递归性质,树形DP一般都是递归进行的。

这里用一道经典例题:luogu P1352(没有上司的舞会)来解释树形DP的一般过程。

这里我们不妨设 \(dp[i][0/1]\) 为以点 \(i\) 为根的子树的最优解(\(dp\) 数组中第二位的 \(0\) 表示 \(i\) 没有参加舞会,反之亦然)。

可以发现:

  • 当上属不参加舞会时,下属可以参加可以不参加,取最大值,即:\(dp[x][0] = \sum max\{dp[v][0], dp[v][1]\}\)
  • 当上属参加舞会时,下属都不可以参加,即:\(dp[x][1] = \sum dp[v][0] + r[i]\)

根据递推写出代码:

#include<bits/stdc++.h>
#define N 6010
using namespace std;
int n, l, k, x = 1;
int r[N], fa[N], dp[N][2];
vector<int>a[N];
void dfs(int x){
	dp[x][0] = 0;	//不参与晚会,初始值为零 
	dp[x][1] = r[x];	//参与晚会,要加上自己的快乐值 
	for(auto v : a[x]){
		dfs(v);
		dp[x][0] += max(dp[v][0], dp[v][1]);	//上司不参加晚会,下是可以参加也可以不参加 
		dp[x][1] += dp[v][0];	//上司参加了晚会,下司不参加 
	}
}
int main(){
	scanf("%d", &n);
	for(int i = 1; i <= n; i++){
		scanf("%d", &r[i]);
	}
	for(int i = 1; i < n; i++){
		scanf("%d%d", &l, &k);
		a[k].push_back(l);
		fa[l] = k;	//寻找根 
	}
	while(fa[x]){
		x = fa[x];
	}
	dfs(x);	//x为根 
	printf("%d", max(dp[x][0], dp[x][1]));	//输出以x为根的数的最大值 
	return 0;
}

树形DP还有一些分支,如书上背包。

5.状压DP

状压DP是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。

这里给一道状压DP的题:luogu P1896

posted @ 2023-09-10 16:22  lijingqian  阅读(9)  评论(0编辑  收藏  举报