动态规划入门

动态规划入门

1.简单动态规划

动态规划问题的关键是找到一个合适,符合题意的状态,找出状态之间的转移关系。

A.数塔问题

题目描述

如图1所示为一个数字三角形。请编一个程序,计算从顶到底的某处的一条路径,使该路径所经过的数字总和最大。只要求输出总和。
1、一步可沿左斜线向下或右斜线向下走。
2、三角形行数小于等于 \(100\).
3、三角形中的数字为 \(0\), \(1\)\(\ldots\)\(99\)
测试数据通过键盘逐行输入,如上例数据应以如图2所示格式输入。

            7
         3    8
        8   1   0
      2   7    4   4
    4   5   2   6   5

图1

5
7
3  8
8  1  0
2  7  4  4
4  5  2  6  5           

图2

输入

第一行一个整数 \(n\),总行数。
接下来 \(n\) 行为数据组成的数字三角形。

输出

输出总和

样例输入

5
13
11 8
12 7 26
6 14 15 8
12 7 13	24 11

样例输出

86

思路

这是一道简单的动态规划题目,定义状态 \(dp_{i,j}\) 表示走到第 \(i\) 行第 \(j\) 列时路径所经过的最大数字总和。

\[dp_{i,j} = \max (dp_{i-1,j},dp_{i-1,j-1})+a_{i,j} \]

根据题意,可以从当前格子向下或向右下走,即当前格子可以由上方的格子向下走或左上方的格子向右下走得到,所以当前状态是由上方和左上方两个状态转移而来,选择较大的一条路径,加上当前的权值即可。

代码

#include <bits/stdc++.h>
using namespace std;
int n, ans, a[1005][1005], dp[1005][1005];
int main() {
	scanf("%d", &n);
	for (int i = 1; i <= n; i ++) 
		for (int j = 1; j <= i; j ++) 
			scanf("%d", &a[i][j]);
	for (int i = 1; i <= n; i ++) 
		for (int j = 1; j <= i; j ++) { // 三角形
			dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - 1]) + a[i][j]; // 状态转移方程
			ans = max(ans, dp[i][j]); // 统计答案
		}
	printf("%d\n", ans);
	return 0;
} 

B.最长上升子序列

题目描述

输入一个长度为 \(n\) 的数组 \(a\) 。找到最长的上升子序列 \(a_{b_1} < a_{b_2} < a_{b_3} < \ldots < a_{b_{k-1}} < a_{b_k}\) ,其中 \(b_1 < b_2 < b_3 < \ldots < b_{k - 1} < b_k\) 。程序只要输出最长的上升子序列长度。

输入

若干个整数

输出

这些整数的最长上升子序列的长度

样例输入

300 250 275 252 200 138 245

样例输出

max=2

思路

暴力思路

定义状态 \(dp_i\) 表示第 \(i\) 个数结尾的最长上升子序列长度,不难发现当前状态 \(dp_i\) 一定是由一个 \(j < i\)\(a_j < a_i\)\(dp_j\)\(1\) 得到,状态转移方程:

\[dp_i = \max_{j = 1} ^ {j < i} dp_j + 1 (a_j < a_i) \]

初始值:\(dp_i = 1\),因为长度至少为 \(1\) ,不设也行。

时间复杂度:\(\text{O}(n ^ 2)\) ,爆炸。

正解思路

考虑状态 \(dp_i\) 表示长度为 \(i\) 的最长上升子序列的最小结尾(为了能容纳更多的数进入,贪心的思想),显然有 \(dp\) 数组单调递增,维护一个 \(len\) 表示当前最长上升子序列的长度,即 \(dp\) 数组内最后一个有数的位置。若当前位置 \(i\) 满足 \(a_i > dp_{len}\) 则将 \(a_i\) 插入 \(dp_i\) 的末尾,\(len + 1\)。否则将 \(dp\) 数组内第一个大于 等于\(a_i\) 的数替换为 \(a_i\)(为了能容纳更多的数),替换后的数组显然也满足单调性,容易想到使用二分或 lower_bound来求解,最终的 \(len\) 长度即为答案。

时间复杂度:\(\text{O}(n \log n)\) ,通过。

代码

二分写法

#include <bits/stdc++.h>
using namespace std;
int a[100005], dp[100005], len, n;
int my_lower_bound(int x) { // 二分寻找第一个大于等于x的数
	int l = 1, r = len, mid;
	while (l <= r) {
		mid = (l + r) >> 1;
		if (dp[mid] >= x) r = mid - 1;
		else l = mid + 1;
	}
	return l;
}
int main() {
	while (scanf("%d", &a[++ n]) != EOF);
	for (int i = 1; i <= n; i ++) {
		if (a[i] > dp[len]) dp[++ len] = a[i];
		else dp[my_lower_bound(a[i])] = a[i];
	}
	printf("max=%d\n", len);
	return 0;
}

lower_bound 写法

#include <bits/stdc++.h>
using namespace std;
int a[100005], dp[100005], len, n;
int main() {
	while (scanf("%d", &a[++ n]) != EOF);
	for (int i = 1; i <= n; i ++) {
		if (a[i] > dp[len]) dp[++ len] = a[i];
		else *lower_bound(dp + 1, dp + len + 1, a[i]) = a[i]; // 返回第一个大于等于a[i]的数的地址
	}
	printf("max=%d\n", len);
	return 0;
}

C.导弹拦截

题目描述

某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。

输入导弹依次飞来的高度,计算这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。

输入格式

一行,若干个整数,中间由空格隔开。

输出格式

两行,每行一个整数,第一个数字表示这套系统最多能拦截多少导弹,第二个数字表示如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。

样例输入

389 207 155 300 299 170 158 65

样例输出

6
2

思路

问题 \(1\) 显然是一个最长不上升子序列,问题 \(2\) 很好想到是一个最长上升子序列,因为每一发炮弹只能拦截比初始高度低的导弹,所以后面比初始高度高的导弹拦截不到,最优方案就是把最长上升子序列的每一个元素都来一发,这样能保证所有导弹被拦截。

最长不上升子序列:

定义状态 \(dp_i\) 表示长度为 \(i\) 的最长不上升子序列的最大结尾,类似于最长上升子序列,显然有 \(dp\) 数组单调递减,维护一个 \(len\) 表示当前最长上升子序列的长度,即 \(dp\) 数组内最后一个有数的位置。若当前位置 \(i\) 满足 \(a_i \le dp_{len}\) 则将 \(a_i\) 插入 \(dp_i\) 的末尾,\(len + 1\)。否则将 \(dp\) 数组内第一个小于 \(a_i\) 的数替换为 \(a_i\) (为了能容纳更多的数),替换后的数组显然也满足单调性,容易想到使用二分或 upper_bound来求解,最终的 \(len\) 长度即为答案。

时间复杂度:\(\text{O}(n \log n)\)

代码

#include <bits/stdc++.h>
using namespace std;
long long n = 1, a[100005];
long long dp1[100005], len1;
long long dp2[100005], len2;
int main() {
	while (scanf("%lld", &a[n]) != EOF) n ++;
	n --;
	dp1[++ len1] = a[1];
	for (int i = 2; i <= n; i ++) { // 最长不上升子序列
		if (dp1[len1] >= a[i]) dp1[++ len1] = a[i];
		else *upper_bound(dp1 + 1, dp1 + len1 + 1, a[i], greater<int>()) = a[i];
        // upper_bound返回第一个大于a[i]的数, 加了greater<int>()后返回第一个小于a[i]的数
	}
	dp2[++ len2] = a[1];
	for (int i = 2; i <= n; i ++) { // 最长上升子序列
		if (dp2[len2] < a[i]) dp2[++ len2] = a[i];
		else *lower_bound(dp2 + 1, dp2 + len2 + 1, a[i])= a[i];
	}
	printf("%lld\n%lld", len1, len2);
	return 0;
}

D.最长公共子序列

题目描述

给定两个字符串序列 \(X\)\(Y\),长度不超过 \(5000\) ,求出两个序列的最长公共子序列长度。注意:子序列不是子串,不要求连续,例如两个字符串 \(\text{cnblogs}\)\(\text{belong}\) 的公共子序列为 \(\text{blog}\)。可以发现,最长公共子序列是不唯一的,但是长度一定是唯一的。

输入

共两行,分别为字符串序列x和y

输出

一行一个整数

样例输入

cnblogs 
belong

样例输出

4

思路

定义状态 \(dp_{i,j}\) 表示第一个字符串考虑到 \(i\) 第二个字符串考虑到 \(j\) 的最长公共子序列,状态转移方程:

\[dp_{i,j} = \left \{ \begin{array}{l} dp_{i-1,j-1}+1 \space (X_i = Y_j) \\ \max (dp_{i-1,j}, dp_{i,j-1}) \space (X_i \ne Y_j)\end{array} \right. \]

第一行表示当前位置的两个字符相同,可以从上一个字符的状态转移过来,答案加 \(1\) ,第二行表示当前位置的两个字符不相同,就取前一个字符的状态的较大值,继承过来。不用设初始值。

时间复杂度:\(\text{O}(|A||B|)\)

空间复杂度:\(\text{O}(|A||B|)\)

代码

#include<bits/stdc++.h>
using namespace std;
int dp[1005][1005], ans, lena, lenb;
char a[1005], b[1005];
int main() {
	scanf("%s%s", a + 1, b + 1);
    lena = strlen(a + 1), lenb = strlen(b + 1);
	for (int i = 1; i <= lena; i ++) {
		for (int j = 1; j <= n; j ++) { // 转移
			dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
			if (a[i] == b[j]) 
                dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1);
			ans = max(ans, dp[i][j]);
		}
	}
	printf("%d\n", ans);
	return 0;
}

E.编辑距离

题目描述

设A和B是两个字符串。我们要用最少的字符操作次数,将字符串A转换为学符串B。这里所说的字符操作共有三种:
(1)删除一个字符;
(2)插人一个字符;
(3)将一个字符改为另一个字符。
对任意的两个字符串A和B,计算出将字符串A变换为字符串B所用的最少字符操作次数。

输入

第1行为字符串A;第2行为字符串B;字符串A和B的长度均小于200。

输出

只有一个正整数,为最少字符操作次数。

样例输入

sfdqxbw
gfdgw

样例输出

4

思路

定义状态 \(dp_{i,j}\) 表示把字符串 \(A\) 的前 \(i\) 个字符变成字符串 \(B\) 的前 \(j\) 个字符的最少操作次数,状态转移方程。

\[dp_{i,j} = \left \{ \begin{array}{l} dp_{i-1,j-1} \space (A_i = B_j) \\ \min(dp_{i-1,j},dp_{i,j-1},dp_{i-1,j-1})+1 \space (A_i \ne B_j)\end{array} \right. \]

第一行指当这两个字符相同时,最少次数即从 \(i-1\) 变成 \(j-1\) 的最少次数,因为当前两个字符不用变。

第二行指当前两个字符不相同,分三种情况。1. \(i-1\) 已经变成 \(j\) 了,\(dp_{i,j} = dp_{i-1,j} + 1\),即把第 \(i\) 个字符删去。2. \(i\) 已经变成 \(j-1\) 了,\(dp_{i,j} = dp_{i,j-1} + 1\),即添加一个字符。3. \(i-1\) 已经变成 \(j-1\) 了,\(dp_{i,j}=dp_{i-1,j-1}+1\) ,即修改 \(i\) 变成 \(j\)。 取最小值即可。

初始值:\(dp_{i,0}=i,dp_{0,i}=i\),即删去 \(i\) 个和添加 \(i\) 个。

时间复杂度:\(\text{O}(|A||B|)\)

空间复杂度:\(\text{O}(|A||B|)\)

代码

#include <bits/stdc++.h>
using namespace std;
char stra[2005], strb[2005];
int lena, lenb, dp[2005][2005];
int main() {
	scanf("%s%s", stra + 1, strb + 1);
	lena = strlen(stra + 1), lenb = strlen(strb + 1);
	for (int i = 0; i <= lena; i ++) // 初始值
		dp[i][0] = i;
	for (int i = 0; i <= lenb; i ++)
		dp[0][i] = i;
	for (int i = 1; i <= lena; i ++) {
		for (int j = 1; j <= lenb; j ++) { // 转移
			if (stra[i] == strb[j])
				dp[i][j] = dp[i - 1][j - 1];
			else dp[i][j] = min(dp[i - 1][j], min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
		}
	}
	printf("%d\n", dp[lena][lenb]);
	return 0;
}

2.背包类型动态规划

背包类型动态规划指给定一个背包容量 \(m\),有 \(n\) 件物品,每件物品都有自己的价值 \(w_i\) 和体积 \(v_i\) 和一些限制条件,在背包体积有限的情况下使总价值最大。背包问题大致分为:01背包,完全背包,多重背包,混合背包,分组背包。

A.01背包

思路

01背包指每种物品只有1件,只能选择装入背包或不装入背包。

定义状态 \(dp_{i,j}\) 表示考虑前 \(i\) 件物品,背包总容量为 \(j\) 的最大价值,状态转移方程:

\[dp_{i,j} = \left\{\begin{array}{l} dp_{i-1,j} \space (j<w_i) \\ \max(dp_{i-1,j}, dp_{i-1,j-w_i}+v_i) \space (j \ge w_i) \end{array}\right. \]

第一种情况 \(j < w_i\) 表示容量为 \(j\) 的背包装不下体积为 \(w_i\) 的这件物品,直接继承上一件物品的状态即可。

第二种情况 \(j \ge w_i\) 表示容量为 \(j\) 的背包装得下体积为 \(w_i\) 的这件物品,取上一件物品的状态和加入这一件物品的状态的较大值。

其中加入这一件物品的状态 \(dp_{i-1,j-w_i} + v_i\) 表示考虑前 \(i - 1\) 个物品,背包容量为 \(j - w_i\) 的最大价值再加上这件物品的价值,这样体积为 \(j\) 的背包刚好被装满。

答案即为 \(dp_{n,m}\)

时间复杂度:\(\text{O}(nm)\)

空间复杂度:\(\text{O}(nm)\)

代码

#include <bits/stdc++.h>
using namespace std;
int n, m, w[205], v[205], dp[205][205];
int main() {
	scanf("%d%d", &m, &n);
	for (int i = 1; i <= n; i ++)
		scanf("%d%d", &w[i], &v[i]);
	for (int i = 1; i <= n; i ++) 
		for (int j = 1; j <= m; j ++) { // 转移
        	dp[i][j] = dp[i - 1][j];
            dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i]] + v[i]);
        } 
	printf("%d\n", dp[n][m]);
	return 0;
}

优化

这个思路需要开一个二维数组,空间可能不够,考虑优化成一维数组。

观察发现 \(dp_{i}\) 只需要 $dp_{i-1} $ 进行转移,可以把 \(dp\) 数组优化成 \(2m\) 的空间。再次观察发现 \(dp_{i,j}\) 只需要 \(dp_{i-1,j-w_i}\) 进行转移,只会涉及到第二维小于 \(j\) 的状态,不会涉及到第二维大于 \(j\) 的状态,可以把 \(dp\) 数组优化成 \(m\) 的空间(把第一维 \(i\) 舍去)并把第二层循环改为倒序,这样由小于 \(j\) 的状态转移过来时,\(dp_{j-w_i}\) 还是未更新状态,即 \(dp_{i-1, j-w_i}\) ,转移时,当 \(k < j\)\(dp_{k}\) 为上一层状态,即 \(dp_{i-1,k}\) ,当 \(k \ge j\)\(dp_{k}\) 为这一层状态,即 \(dp_{i, k}\)

时间复杂度:\(\text{O}(nm)\)

空间复杂度:\(\text{O}(m)\)

优化后代码

#include <bits/stdc++.h>
using namespace std;
int n, m, w[205], v[205], dp[205];
int main() {
	scanf("%d%d", &m, &n);
	for (int i = 1; i <= n; i ++)
		scanf("%d%d", &w[i], &v[i]);
	for (int i = 1; i <= n; i ++) 
		for (int j = m; j >= w[i]; j --) // 注意循环顺序
			dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
	printf("%d\n", dp[m]);
	return 0;
}

升级

题目描述

潜水员为了潜水要使用特殊的装备。他有一个带 \(2\) 种气体的气缸:一个为氧气,一个为氮气。让潜水员下潜的深度需要各种的数量的氧和氮。潜水员有一定数量的气缸。每个气缸都有重量和气体容量。潜水员为了完成他的工作需要特定数量的氧和氮。他完成工作所需气缸的总重的最低限度的是多少?
例如:潜水员有5个气缸。每行三个数字为:氧,氮的(升)量和气缸的重量:

3 36 120
10 25 129
5 50 250
1 45 130
4 20 119

如果潜水员需要 \(5\) 升的氧和 \(60\) 升的氮则总重最小为 \(249\)\(1\)\(2\) 或者\(4\)\(5\) 号气缸)。
你的任务就是计算潜水员为了完成他的工作需要的气缸的重量的最低值。

输入

第一行有2整数 \(m\)\(n\)\(1 \le m \le 21\)\(1 \le n \le 79\))。它们表示氧,氮各自需要的量。
第二行为整数 \(k\)\(1 \le k \le 1000\))表示气缸的个数。
此后的k行,每行包括 \(a_i\)\(b_i\)\(c_i\)\(1 \le a_i \le 21\)\(1 \le b_i \le 79\)\(1 \le c_i \le 8000\)\(3\) 个整数。这些各自是:第 \(i\) 个气缸里的氧和氮的容量及汽缸重量。

输出

仅一行包含一个整数,为潜水员完成工作所需的气缸的重量总和的最低值。

样例输入

5 60
5
3 36 120
10 25 129
5 50 250
1 45 130
4 20 119

样例输出

249

思路

这是一个变形的01背包,重量有两维(氧气,氮气),把原 \(dp\) 数组变成两维即可。

代码

#include <bits/stdc++.h>
using namespace std;
int m, n, k, a[1005], b[1005], c[1005], dp[1005][1005];
int main() {
	scanf("%d%d%d", &m, &n, &k);
	for (int i = 1; i <= k; i ++)
		scanf("%d%d%d", &a[i], &b[i], &c[i]);
	memset(dp, 0x3f, sizeof(dp));
	dp[0][0] = 0;
	for (int i = 1; i <= k; i ++) 
		for (int j = m; j >= 0; j --) // 倒序
			for (int l = n; l >= 0; l --) { // 倒序
				int x = max(0, j - a[i]), y = max(0, l - b[i]); // 边界,因为大于需求量也是可以的
				dp[j][l] = min(dp[j][l], dp[x][y] + c[i]);
			} 
	printf("%d\n", dp[m][n]);
	return 0;
}

B.完全背包

思路

完全背包指每种物品有无数件,能装下的前提下可以无限装。

定义状态 \(dp_{i,j}\) 表示考虑前 \(i\) 个物品,背包容量为 \(j\) 的最大价值,状态转移方程:

\[dp_{i,j} = \left \{\begin{array}{l} dp_{i-1, j} \space (j < w_i) \\ dp_{i, j - w_i} + v_i \space (j \ge w_i)\end{array}\right. \]

第一行与01背包相同,为放不下第 \(i\) 件物品的情况。

第二行与01背包有区别,\(dp_{i-1,j-w_i}\) 变成了 \(dp_i,j-w_i\) 。这是因为01背包规定一种物品只能拿一件,所以考虑到这一件物品的状态一定是从考虑到上一件物品的状态转移过来的,这样能够保证只拿一件,不重复。而完全背包就不同,当前考虑到这一件物品的状态可以从考虑到上一件物品的状态转移过来,即第一次拿本物品,也可以从考虑到这一件物品的状态转移过来,即拿很多次本物品,所以 \(dp_{i-1,j-w_i}\) 就要改成 \(dp_i,j-w_i\) 。可能会有疑问:不是说既可以从上一个物品转移过来(第一次拿)也可以从这一个物品转移过来(多次拿)吗,那上一个物品转移过来的情况怎么体现呢?当 \(j - w_i < w_i\) 时,因为 \(j - w_i\) 已经放不下本物品了,即背包里没有本物品,就是第一次拿的情况,而将第一行的方程带入,得到 \(dp_{i,j} = dp_{i-1,j-w_i} + v_i \space (j - w_i < w_i)\) ,就变成01背包的转移方程了,体现出第一次拿的情况。

时间复杂度:\(\text{O}(nm)\)

空间复杂度:\(\text{O}(nm)\)

代码

#include <bits/stdc++.h>
using namespace std;
int n, m, w[205], v[205], dp[205][205];
int main() {
	scanf("%d%d", &m, &n);
	for (int i = 1; i <= n; i ++)
		scanf("%d%d", &w[i], &v[i]);
	for (int i = 1; i <= n; i ++) 
		for (int j = 1; j <= m; j ++) {
        	dp[i][j] = dp[i - 1][j];
            dp[i][j] = max(dp[i][j], dp[i][j - w[i]] + v[i]); // 注意与01背包的不同
        } 
	printf("%d\n", dp[n][m]);
	return 0;
}

优化

和01背包一样,完全背包也可以压缩空间,但注意一点,完全背包的第二层循环需要正序循环,原因是状态转移方程有改变,当前状态需要从已经更新过的当前状态转移得到,而不是上一层状态,01背包倒序枚举是为了满足从上一层状态转移的需求。

时间复杂度:\(\text{O}(nm)\)

空间复杂度:\(\text{O}(m)\)

优化后代码

#include <bits/stdc++.h>
using namespace std;
int n, m, w[205], v[205], dp[205];
int main() {
	scanf("%d%d", &m, &n);
	for (int i = 1; i <= n; i ++)
		scanf("%d%d", &w[i], &v[i]);
	for (int i = 1; i <= n; i ++) 
		for (int j = w[i]; j <= m; j ++) // 注意循环顺序
			dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
	printf("%d\n", dp[m]);
	return 0;
}

C.多重背包

多重背包指每个物品有限定的个数,在不超过总重量的前提下使总价值最大。

思路

暴力思路

多次01背包,很好理解。在01背包外套一层循环。

时间复杂度:\(\text{O}(m n c)\)

空间复杂度:\(\text{O}(m)\)

优化思路

将每个数都拆成 \(1,2,4,8,\ldots,2^{k-1},c-2^k + 1\),这样就能表示出 \([1,2^k-1]\) 中的每个数了,对于 \([2^k,c]\) 的数,用 \([1,2^k-1]\) 中的数加上 \(c-2^k + 1\) 次方就能表示了。如 \(22\) 拆成 \(1,2,4,8,7\)\(1,2,4,8\) 表示 \([1,15]\) 中的每个数,加上 \(7\) 后可以表示 \([1,22]\) 中的数了。我们把每个物品的数量 \(c\) 像这样拆分,再乘上物品重量和价值,就变成了 \(\log c\) 个物品,做一次01背包就行了。

时间复杂度:\(\text{O}(m n \log c)\)

空间复杂度:\(\text{O}(n \log c)\)

代码

#include <bits/stdc++.h> 
using namespace std;
int n, m, w[105], v[105], c[105], cnt;
int dp[1000005], a[1000005], b[1000005];
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++) 
        scanf("%d%d", &w[i], &v[i]);
    for (int i = 1; i <= n; i ++)
        scanf("%d", &c[i]);
    for (int i = 1; i <= n; i ++) { // 拆分
        int t = c[i], k = 1;
        while (k <= t) {
            cnt ++;
            a[cnt] = w[i] * k; // 拆分装入
            b[cnt] = v[i] * k; // 拆分装入
            t -= k;
            k <<= 1; // 1,2,4,8
        }
        if (t > 0) {
            cnt ++; // 剩余 (7)
            a[cnt] = w[i] * t;
            b[cnt] = v[i] * t;
        }
    }
    for (int i = 1; i <= cnt; i ++)
        for (int j = m; j >= a[i]; j --) // 01 背包
            dp[j] = max(dp[j], dp[j - a[i]] + b[i]);
    printf("%lld\n", dp[m])
    return 0;
}

D.混合背包

指01背包,完全背包,多重背包的结合体,把每个程序的循环掏出来合在一起就行了,01背包可以当特殊的多重背包处理,依然可以用二进制拆分。

E.分组背包

指物品分成了若干组,每组 \(c_i\) 件,第 \(k\) 件为 \(z_{i,k}\),每个组内的物品只能选一件或不选,求价值最大。

思路

定义 \(dp_{i,j}\) 表示考虑前 \(i\) 组物品,背包容量为 \(j\) 的最大价值,状态转移方程:

\[dp_{i,j} = \max_{k=1} ^ {c_i} dp_{i-1,j-w_{z_{i,k}}} + v_{z_{i,k}} \]

很好理解,每次装物品从考虑到上一组的状态转移过来,不会有重复拿的问题,可以看出,方程和01背包的很想,只是多了一层枚举,加上即可,空间优化与01背包相同。优化空间时,注意循环顺序,\(k\) 只能在最里面,因为 \(j\) 倒序枚举的原因,\(dp_{j-w_{z_{i,k}}}\) 是未被更新过的,即 \(dp_{i-1,j-w_{z_{i,k}}} + v_{z_{i,k}}\),如果交换循环顺序,\(dp_{j-w_{z_{i,k}}}\) 就不能保证是未被更新过的,会出错。

时间复杂度:\(\text{O}(nm)\)

空见复杂度:\(\text{O}(m)\)

代码

#include <bits/stdc++.h>
using namespace std;
int n, m, w[1005], v[1005];
int dp[1005], z[105][1005];
int mx;
int main() {
	scanf("%d%d", &m, &n);
	for (int i = 1, x; i <= n; i ++) {
		scanf("%d%d%d", &w[i], &v[i], &x);
		z[x][++ z[x][0]] = i;
		mx = max(mx, x);
	}
	for (int i = 1; i <= mx; i ++) 
		for (int j = m; j >= 0; j --) 
			for (int k = 1; k <= z[i][0]; k ++) // 注意循环顺序,k只能在最里面
				if (j >= w[z[i][k]]) 
					dp[j] = max(dp[j], dp[j - w[z[i][k]]] + v[z[i][k]]);
	printf("%d\n", dp[m]);
	return 0;
}

3.区间类型动态规划

区间动态规划一般解决的是区间类型的问题,先求解一个个小区间,再把小区间合并成大一点的区间,重复,得到更大的区间,重复,得到答案。

A.合并石子

题目描述

在一个操场上一排地摆放着 \(N\) 堆石子。现要将石子有次序地合并成一堆。规定每次只能选相邻的两堆石子合并成新的一堆,并将新的一堆石子数记为该次合并的得分。
试设计一个程序,计算出将 \(N\) 堆石子合并成一 堆的最小得分。

输入

\(1\) 行为一个正整数 \(N\) \(2 \le N \le 100\)

以下 \(N\) 行,每行一个正整数,分别表示第 \(i\) 堆石子的个数 \(a_i\)

输出

一个正整数,即最小得分。

样例输入

7
13
7
8
16
21
4
18

样例输出

239

思路

定义状态 \(dp_{i,j}\) 表示合并 \([i,j]\) 的石子的最小得分,状态转移方程:

\[dp_{i,j} = \max_{k=i}^{j-1} dp_{i,k} + dp_{k+1,j} + sum_{i,j} \]

其中 \(sum_{i,j}\) 表示 \([i,j]\) 的和,可以用前缀和统计。剩下的都很直接,很好理解,枚举一个中间点,左右两边的区间加起来。

时间复杂度:\(\text{O}(n^2)\)

空间复杂度:\(\text{O}(n^2)\)

代码

#include <bits/stdc++.h>
#define sum(i, j) (cnt[j] - cnt[i - 1]) //  前缀和
using namespace std;
int n, dp[105][105], cnt[105]; 
int main() {
	scanf("%d", &n);
	memset(dp, 0x3f, sizeof(dp));
	for (int i = 1, a; i <= n; i ++) {
		scanf("%d", &a);
		cnt[i] = cnt[i - 1] + a; // 前缀和
		dp[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 ++) { // 状态转移
				if (dp[i][j] > dp[i][k] + dp[k + 1][j] + sum(i, j)) 
					dp[i][j] = dp[i][k] + dp[k + 1][j] + sum(i, j);
			}
		}
	}
	printf("%d\n", dp[1][n]); // 答案
	return 0;
}

B.能量项链

题目描述

在 Mars 星球上,每个 Mars 人都随身佩带着一串能量项链。在项链上有 \(N\) 颗能量珠。能量珠是一颗有头标记与尾标记的珠子,这些标记对应着某个正整数。并且,对于相邻的两颗珠子,前一颗珠子的尾标记一定等于后一颗珠子的头标记。因为只有这样,通过吸盘(吸盘是 Mars 人吸收能量的一种器官)的作用,这两颗珠子才能聚合成一颗珠子,同时释放出可以被吸盘吸收的能量。如果前一颗能量珠的头标记为 \(m\),尾标记为 \(r\),后一颗能量珠的头标记为 \(r\),尾标记为 \(n\),则聚合后释放的能量为 \(m \times r \times n\)(Mars 单位),新产生的珠子的头标记为 \(m\),尾标记为 \(n\)

需要时,Mars 人就用吸盘夹住相邻的两颗珠子,通过聚合得到能量,直到项链上只剩下一颗珠子为止。显然,不同的聚合顺序得到的总能量是不同的,请你设计一个聚合顺序,使一串项链释放出的总能量最大。

例如:设 \(N=4\)\(4\) 颗珠子的头标记与尾标记依次为 \((2,3)(3,5)(5,10)(10,2)\)。我们用记号 \(\oplus\) 表示两颗珠子的聚合操作,\((j \oplus k)\) 表示第 \(j,k\) 两颗珠子聚合后所释放的能量。则第 \(4\)\(1\) 两颗珠子聚合后释放的能量为:

\((4 \oplus 1)=10 \times 2 \times 3=60\)

这一串项链可以得到最优值的一个聚合顺序所释放的总能量为:

\((((4 \oplus 1) \oplus 2) \oplus 3)=10 \times 2 \times 3+10 \times 3 \times 5+10 \times 5 \times 10=710\)

输入格式

第一行是一个正整数 \(N\)\(4 \le N \le 100\)),表示项链上珠子的个数。第二行是 \(N\) 个用空格隔开的正整数,所有的数均不超过 \(1000\)。第 \(i\) 个数为第 \(i\) 颗珠子的头标记(\(1 \le i \le N\)),当 \(i<N\) 时,第 \(i\) 颗珠子的尾标记应该等于第 \(i+1\) 颗珠子的头标记。第 \(N\) 颗珠子的尾标记应该等于第 \(1\) 颗珠子的头标记。

至于珠子的顺序,你可以这样确定:将项链放到桌面上,不要出现交叉,随意指定第一颗珠子,然后按顺时针方向确定其他珠子的顺序。

输出格式

一个正整数 \(E\)\(E\le 2.1 \times 10^9\)),为一个最优聚合顺序所释放的总能量。

样例输入

4
2 3 5 10

样例输出

710

思路

转移方程和上题类似,不同的是这道题在环上,把原数组复制一遍,变成 \(2n\) 个数,就能解决环的问题了。

时间复杂度:\(\text{O}(n^2)\)

空间复杂度:\(\text{O}(n^2)\)

代码

#include <bits/stdc++.h>
using namespace std;
int n, ans, a[205], f[205][205];
int main() {
	cin >> n;
	for (int i = 1; i <= n; i ++) // 处理环
		cin >> a[i], a[i + n] = a[i];
	for (int len = 1; len <= 2 * n; len ++) // 2n 
		for (int i = 1; i + len - 1 <= 2 * n; i ++) {
			int j = i + len - 1;
			for (int k = i; k < j; k ++)
				if (f[i][k] + f[k + 1][j] + a[i] * a[k + 1] * a[j + 1] > f[i][j])
					f[i][j] = f[i][k] + f[k + 1][j] + a[i] * a[k + 1] * a[j + 1]; // 状态转移
		}
	for (int i = 1; i <= n + 1; i ++) // 要考虑所有环的情况
		ans = max(ans, f[i][i + n - 1]);
	cout << ans;
	return 0;
}

4.树形动态规划

树形动态规划指在树上做动态规划。

A.没有上司的舞会

题目描述

某大学有 \(n\) 个职员,编号为 \(1\ldots n\)

他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。

现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 \(r_i\),但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。

所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

输入格式

输入的第一行是一个整数 \(n\)

\(2\) 到第 \((n + 1)\) 行,每行一个整数,第 \((i+1)\) 行的整数表示 \(i\) 号职员的快乐指数 \(r_i\)

\((n + 2)\) 到第 \(2n\) 行,每行输入一对整数 \(l, k\),代表 \(k\)\(l\) 的直接上司。

输出格式

输出一行一个整数代表最大的快乐指数。

样例输入

7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5

样例输出

5

数据规模与约定

对于 \(100\%\) 的数据,保证 \(1\leq n \leq 6 \times 10^3\)\(-128 \leq r_i\leq 127\)\(1 \leq l, k \leq n\),且给出的关系一定是一棵树。

思路

这是一道典型的树形动态规划。定义状态 \(dp_{i,0/1}\) 表示以 \(i\) 为根的子树中 \(i\) 选(\(0\))或者不选(\(1\)),最大的快乐指数,状态转移方程:

\[\left \{ \begin{array}{l} dp_{u,0} = \max(dp_{v,0},dp_{v,1}) \\ dp_{u,1} = dp_{v,0}\end{array}\right. \]

第一行表示上司不去的状态为下属去的状态和下属不去的状态的最大值。第二行表示如果上司去了,下属就只能不去。

实现方式是 \(\text{DFS}\) ,先处理孩子,在处理父亲。

时间复杂度:\(\text{O}(n)\)

空间复杂度:\(\text{O}(n)\)

代码

#include <bits/stdc++.h>
using namespace std;
const int N = 6005;
int ver[N << 1], nxt[N << 1], head[N], tot;
int n, ans, p[N], f[N][2], root;
bool rt[N];
void add(int x, int y) { // 前向星
	ver[++ tot] = y;
	nxt[tot] = head[x];
	head[x] = tot;
}
void dfs(int x) {
	for (int i = head[x]; i; i = nxt[i]) { // 枚举孩子
		int y = ver[i];
		dfs(y); // 处理孩子
		f[x][1] += f[y][0]; // 状态转移方程
		f[x][0] += max(f[y][0], f[y][1]);
	}
	f[x][1] += p[x]; // 如果去就加上自己的快乐指数
	ans = max(f[x][0], f[x][1]); // 统计答案
}
int main(){
	scanf("%d", &n);
	for (int i = 1; i <= n; i ++) 
		scanf("%d", &p[i]);
	for (int i = 1, l, k; i < n; i ++) {
		scanf("%d%d", &l, &k);
		add(k, l); // 加边
		rt[l] = 1;
	}
	for (int i = 1; i <= n; i ++) // 找根 
		if (!rt[i]) 
			root = i; 
	dfs(root); // dp
	printf("%d\n", ans);
	return 0;
}

B.战略游戏

题目背景

Bob 喜欢玩电脑游戏,特别是战略游戏。但是他经常无法找到快速玩过游戏的办法。现在他有个问题。

题目描述

他要建立一个古城堡,城堡中的路形成一棵无根树。他要在这棵树的结点上放置最少数目的士兵,使得这些士兵能瞭望到所有的路。

注意,某个士兵在一个结点上时,与该结点相连的所有边将都可以被瞭望到。

请你编一程序,给定一树,帮 Bob 计算出他需要放置最少的士兵。

输入格式

第一行一个整数 \(n\),表示树中结点的数目。

第二行至第 \(n+1\) 行,每行描述每个结点信息,依次为:一个整数 \(i\),代表该结点标号,一个自然数 \(k\),代表后面有 \(k\) 条无向边与结点 \(i\) 相连。接下来 \(k\) 个整数,分别是每条边的另一个结点标号 \(r_1,r_2,\cdots,r_k\),表示 \(i\) 与这些点间各有一条无向边相连。

对于一个\(n\) 个结点的树,结点标号在 \(0\)\(n-1\) 之间,在输入数据中每条边只出现一次。保证输入是一棵树。

输出格式

输出文件仅包含一个整数,为所求的最少的士兵数目。

样例输入

4
0 1 1
1 2 2 3
2 0
3 0

样例输出

1

数据规模与约定

对于全部的测试点,保证 \(1 \leq n \leq 1500\)

思路

定义状态 \(dp_{i,0/1}\) 表示以 \(i\) 为根的子树,\(i\) 放或不放的最少士兵数,状态转移方程:

\[\left \{ \begin{array}{l} dp_{u,1} = \min(dp_{v,0},dp_{v,1}) \\ dp_{u,0} = dp_{v,1}\end{array}\right. \]

第一行表示 \(u\) 放了,则 \(v\) 放与不放都行,取最小值。第二行表示 \(u\) 没放,则 \(v\) 必须放,否则将没人看管 \((u,v)\) 这条边。

时间复杂度:\(\text{O}(n)\)

空间复杂度:\(\text{O}(n)\)

代码

#include <bits/stdc++.h>
using namespace std;
const int N = 1505;
int ver[N << 1], nxt[N << 1], head[N], tot;
int n, f[N][2];
void add(int x, int y) { // 前向星
	ver[++ tot] = y;
	nxt[tot] = head[x];
	head[x] = tot;
}
void dfs(int x, int fa) {
	for (int i = head[x]; i; i = nxt[i]) { // 枚举孩子
		int y = ver[i];
		if (y == fa) continue; // 重边
		dfs(y, x); // 处理孩子
		f[x][0] += f[y][1]; //转移
		f[x][1] += min(f[y][1], f[y][0]);
	}
	f[x][1] ++; // 自己放了,加上
}
int main(){
	scanf("%d", &n);
	for (int i = 1, id, k; i <= n; i ++) {
		scanf("%d%d", &id, &k);
		for (int j = 1, x; j <= k; j ++) {
			scanf("%d", &x);
			add(id + 1, x + 1); // 加边
			add(x + 1, id + 1);
		}
	}
	dfs(1, 0);
	printf("%d\n", min(f[1][0], f[1][1])); // 答案
	return 0;
}

C.选课

题目描述

在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有 \(N\) 门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程 a 是课程 b 的先修课即只有学完了课程 a,才能学习课程 b)。一个学生要从这些课程里选择 \(M\) 门课程学习,问他能获得的最大学分是多少?

输入格式

第一行有两个整数 \(N\) , \(M\) 用空格隔开。( \(1 \leq N \leq 300\) , \(1 \leq M \leq 300\) )

接下来的 \(N\) 行,第 \(I+1\) 行包含两个整数 $k_i $和 \(s_i\), \(k_i\) 表示第I门课的直接先修课,\(s_i\) 表示第I门课的学分。若 \(k_i=0\) 表示没有直接先修课(\(1 \leq {k_i} \leq N\) , \(1 \leq {s_i} \leq 20\))。

输出格式

只有一行,选 \(M\) 门课程的最大得分。

样例输入

7  4
2  2
0  1
0  4
2  1
7  1
7  6
2  2

样例输出

13

思路

这是一个树上的背包问题,定义 \(dp_{i,j}\) 表示以 \(i\) 为根的子树选 \(j\) 个的最大得分,其实就是一个01背包,具体实现看代码注释。

代码

#include <bits/stdc++.h>
using namespace std;
const int N = 305;
int ver[N << 1], nxt[N << 1], head[N], tot;
int m, n, v[N], f[N][N], siz[N];
void add(int x, int y) { //前向星
	ver[++ tot] = y;
	nxt[tot] = head[x];
	head[x] = tot;
}
void dfs(int x) {
	f[x][1] = v[x]; // 初始值,子树中选一个就必选自己
	for (int i = head[x]; i; i = nxt[i]) { // 枚举孩子
		int y = ver[i]; // 获取孩子
		dfs(y); // 处理孩子
		for (int k = n; k >= 1; k --) // 倒序枚举,01背包
			for (int j = 0; j < k; j ++) // 分配给这个孩子的容量
				f[x][k] = max(f[x][k], f[x][k - j] + f[y][j]); // 取最优
	}
}
int main(){
	scanf("%d%d", &m, &n);
	n ++; // 加了一个源点0
	for (int i = 1, x; i <= m; i ++) {
		scanf("%d%d", &x, &v[i]);
		add(x, i); // 加边
	}
	dfs(0); // dp
	printf("%d\n", f[0][n]); // 输出答案
	return 0;
}

D.二叉苹果树

题目描述

有一棵苹果树,如果树枝有分叉,一定是分二叉(就是说没有只有一个儿子的结点)

这棵树共有 \(N\) 个结点(叶子点或者树枝分叉点),编号为 \(1 \sim N\),树根编号一定是 \(1\)

我们用一根树枝两端连接的结点的编号来描述一根树枝的位置。下面是一颗有 \(4\) 个树枝的树:

2   5
 \ / 
  3   4
   \ /
    1

现在这颗树枝条太多了,需要剪枝。但是一些树枝上长有苹果。

给定需要保留的树枝数量,求出最多能留住多少苹果。

输入格式

第一行 \(2\) 个整数 \(N\)\(Q\),分别表示表示树的结点数,和要保留的树枝数量。

接下来 \(N-1\) 行,每行 \(3\) 个整数,描述一根树枝的信息:前 \(2\) 个数是它连接的结点的编号,第 \(3\) 个数是这根树枝上苹果的数量。

输出格式

一个数,最多能留住的苹果的数量。

样例输入

5 2
1 3 1
1 4 10
2 3 20
3 5 20

样例输出

21

数据规模与约定

\(1 \leqslant Q < N \leqslant 100\),每根树枝上的苹果 \(\leqslant 3 \times 10^4\)

思路

和上道题类似,不过把点权换成了边权,定义 \(dp_{i,j}\) 表示以 \(i\) 为根的子树选 \(j\) 条边的最多苹果数,细节处理有差异,详情见代码注释。

代码

#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int ver[N << 1], nxt[N << 1], head[N << 1], edge[N << 1], tot;
int n, q, f[N][N];
void add(int x, int y, int z) { // 前向星
	ver[++ tot] = y;
	nxt[tot] = head[x];
	head[x] = tot;
	edge[tot] = z;
}
void dfs(int x, int fa) {
	for (int i = head[x]; i; i = nxt[i]) { // 枚举孩子
		int y = ver[i]; // 获取孩子
		if (y == fa) continue; // 重边
		dfs(y, x); // 处理孩子
		for (int k = q; k >= 1; k --) // 倒序枚举, 01背包
			for (int j = 0; j < k; j ++) // 分配给孩子的边
				f[x][k] = max(f[x][k], f[x][k - j - 1] + f[y][j] + edge[i]); // 注意这里的不同
        		// 为什么是 f[x][k - j - 1] 呢? 因为要保证(x,y)这条边被选
	}
}
int main(){
	scanf("%d%d", &n, &q);
	for (int i = 1, x, y, z; i <= n - 1; i ++) {
		scanf("%d%d%d", &x, &y, &z);
		add(x, y, z); // 加边
		add(y, x, z);	
	}
	dfs(1, 0); // dp
	printf("%d\n", f[1][q]); // 答案
	return 0;
}

未完成:5.图上的动态规划,6.状态压缩动态规划,7.数位动态规划。

posted @ 2023-08-07 22:10  maniubi  阅读(10)  评论(0编辑  收藏  举报