目录
动态规划的递归写法和递推写法
动态规划的递归写法
斐波那契数列
int dp[MAXN];
int F(int n) {
if(n == 0 || n == 1)
return 1;
if(dp[n] != -1)
return dp[n]; // 已经计算过, 直接返回结果, 不再重复计算
else {
dp[n] = F(n - 1) + F(n - 2); // 计算 F(n), 并保存至 dp[n]
return dp[n];
}
}
动态规划的递推写法
数塔问题
- 将一些数字排成数塔的形状, 其中第一层有一个数字, 第二层有两个数字… 第
n
n
n 层有
n
n
n 个数字。现在要从第一层走到第
n
n
n 层, 每次只能走向下一层连接的两个数字中的一个, 问: 最后将路径上所有数字相加后得到的和最大是多少?
- 首先开一个二维数组
f
, 其中f[i][j]
存放第 i i i 层的第 j j j 个数字. 令dp[i][j]
表示从第 i i i 行第 j j j 个数字出发的到达最底层的所有路径中能得到的最大和, 例如dp[3][2]
就是图中的 7 到最底层的路径最大和。在定义这个数组之后,dp[1][1]
就是最终想要的答案- 状态转移方程:
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1] + f[i][j]
- 边界 ( 1 ≤ j ≤ n 1\leq j\leq n 1≤j≤n):
dp[n][j] = f[n][j]
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 1000;
int f[maxn][maxn], dp[maxn][maxn];
int main() {
int n;
scanf("%d", &n);
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= i; j++) {
scanf("%d", &f[i][j]); // 输入数塔
}
}
// 边界
for (int j = 1; j <= n; j++) {
dp[n][j] = f[n][j];
}
// 从第 n - 1 层不断往上计算出 dp[i][j]
for(int i = n - 1; i >= 1; i--) {
for(int j = 1; j <= i; j++) {
// 状态转移方程
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + f[i][j];
}
}
printf("%d\n", dp[1][1]);
return 0;
常见的动态规划模型
- (1) 最大连续子序列和. 令 d p [ i ] dp[i] dp[i] 表示以 A [ i ] A[i] A[i] 作为末尾的连续序列的最大和
- (2) 最长不下降子序列 (LIS). 令 d p [ i ] dp[i] dp[i] 表示以 A [ i ] A[i] A[i] 结尾的最长不下降子序列长度
- (3) 最长公共子序列 (LCS). 令 d p [ i ] [ j ] dp[i] [j] dp[i][j] 表示字符串 A A A 的 i i i 号位和字符串 B B B 的 j j j 号位之前的 LCS 长度
- (4) 最长回文子串. 令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 S [ i ] S[i] S[i] 至 S [ j ] S[j] S[j] 所表示的子串是否是回文子串
- (5) 数塔 DP. 令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示从第 i i i 行第 j j j 个数字出发的到达最底层的所有路径上所能得到的最大和
- (6) DAG 最长路. 令 d p [ i ] dp[i] dp[i] 表示从 i i i 号顶点出发能获得的最长路径长度
- (7) 01 背包 / 完全背包. 令 d p [ i ] [ v ] dp[i][v] dp[i][v] 表示前 i i i 件物品恰好装入容量为 v v v 的背包中能获得的最大价值
- 总结:当题目与序列或字符串 (记为 A A A) 有关时,可以考虑把状态设计成下面两种形式,然后根据端点特点去考虑状态转移方程:(1) 令 d p [ i ] dp[i] dp[i] 表示以 A [ i ] A[i] A[i] 结尾 (或开头) 的 XXX,(2) 令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 A [ i ] A[i] A[i] 至 A [ j ] A[j] A[j] 区间的 XXX;再或者就是分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述:恰好为 i i i / 前 i i i,在每一维的含义设置完毕之后, dp 数组的含义就可以设置成 “令 dp 数组表示恰好为 i i i / 前 i i i、恰好为 j j j / 前 j j j …… 的 XXX"
最大连续子序列和
- 给定一个数字序列
A
1
,
A
2
,
…
,
A
n
A_1,A_2 ,…,A_n
A1,A2,…,An, 求
i
,
j
i, j
i,j (
1
≤
i
≤
j
≤
n
1\leq i\leq j\leq n
1≤i≤j≤n), 使得
A
i
+
⋅
⋅
⋅
+
A
j
A_i+···+ Aj
Ai+⋅⋅⋅+Aj 最大, 输出这个最大和
- 暴力求解: O ( n 2 ) O(n^2) O(n2)
- 分治算法: O ( n log n ) O(n\log n) O(nlogn)
- 动态规划算法: O ( n ) O(n) O(n)
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 10010;
int A[maxn], dp[maxn]; // A[i] 存放序列, dp[i] 存放以 A[i] 结尾的连续序列的最大和
int main() {
int n;
scanf("%d", &n);
for(int i = 0; i < n; i++){ // 读入序列
scanf("%d", &A[i]);
}
// 边界
dp[0] = A[0];
for(int i = 1; i < n; i++) {
// 状态转移方程
dp[i] = max(A[i], dp[i - 1] + A[i]);
}
int k = 0;
for(int i = 1; i < n; i++) {
if (dp[i] > dp[k]) {
k = i;
}
}
printf("%d\n", dp[k]);
return 0;
}
PAT (Advanced level) 1007 Maximum Subsequence Sum
- 唯一需要注意的是,如果输入序列中只有 0,则输出的首尾元素不应该为整个序列的首尾元素,应全为 0
- 注:更好的方法是将全负的情况放在输入时处理
- 注:我没有完整的记录以每个元素为尾的最大子段和以及对应子段和的首元素,只用了两个变量表示;更不容易写错的方法还是先把求出来的值存数组里,最后再遍历一次求最大值
#include <cstdio>
#include <algorithm>
using namespace std;
int first, last, max_sum = 0;
int sum = 0, seq_first;
int A[10000];
int main()
{
int k;
scanf("%d", &k);
for(int i = 0; i != k; ++i)
{
scanf("%d", &A[i]);
}
first = seq_first = A[0];
last = A[k - 1];
for (int i = 0; i != k; ++i)
{
if (sum < 0)
{
sum = A[i];
seq_first = A[i];
}
else {
sum += A[i];
}
if (sum > max_sum || (max_sum == sum && max_sum == 0))
{
max_sum = sum;
first = seq_first;
last = A[i];
}
}
printf("%d %d %d", max_sum, first, last);
return 0;
}
最长不下降子序列 (LIS)
Longest Increasing Sequence
- 在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减) 的
- 令
dp[i]
表示以A[i]
结尾的最长不下降子序列长度 (和最大连续子序列和问题一样,以A[i]
结尾是强制的要求)。这样对A[i]
来说就会有两种可能:- (1) 如果存在
A[i]
之前的元素A[j]
( j < i j < i j<i), 使得A[j] <= A[i]
且dp[j] + 1 > dp[i]
(即把A[i]
跟在以A[j]
结尾的 LIS 后面时能比当前以A[i]
结尾的 LIS 长度更长), 那么就把A[i]
跟在以A[j]
结尾的 LIS 后面, 形成一条更长的不下降子序列(令dp[i] = dp[j] + 1
) - (2) 如果
A[i]
之前的元素都比A[i]
大, 那么A[i]
就只好自已形成一条 LIS, 但是长度为 1, 即这个子序列里面只有一个A[i]
- (1) 如果存在
- 最后以
A[i]
结尾的 LIS 长度就是 (2)(3) 中能形成的最大长度
- 状态转移方程 (
j
=
1
,
2
,
…
,
i
−
1
j = 1,2, …,i-1
j=1,2,…,i−1 &&
A[j] < A[i]
):
dp[i] = max(1, dp[j] + 1)
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 100;
int A[N], dp[N];
int main() {
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &A[i]);
}
int ans = -1; // 记录最大的 dp[i]
for(int i = 1; i <= n; i++) { // 按顺序计算出 dp[i] 的值
dp[i] = 1; // 边界初始条件(即先假设每个元素自成一个子序列)
for(int j = 1; j < i; j++) {
if(A[i] >= A[j]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
printf ("%d", ans);
return 0;
}
最长公共子序列 (LCS)
Longest Common Subsequence
- 给定两个字符串(或数字序列) A A A 和 B B B, 求二个字符串, 使得这个字符串是 A A A 和 B B B 的最长公共部分(子序列可以不连续)
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 101;
char A[N], B[N];
int dp[N][N]; // dp[i][j] 表示字符串 A 的 i 号位和字符串 B 的 j 号位之前的 LCS 长度
int main() {
int n;
fgets(A + 1, N, stdin); // 从下标为 1 开始读入
fgets(B + 1, N, stdin);
int lenA = strlen(A + 1) - 1; // 由于读入时下标从 1 开始, 因此读取长度也从 +1 开始; 同时去掉末尾读入的换行符
int lenB = strlen(B + 1) - 1;
// 边界
for(int i = 0; i <= lenA; i++) {
dp[i][0] = 0;
}
for(int j = 0; j <= lenB; j++) {
dp[0][j] = 0;
}
// 状态转移方程
for(int i = 1; i <= lenA; i++) {
for (int j = 1; j <= lenB; j++) {
if (A[i] == B[j]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
printf("%d\n", dp[lenA][lenB]);
return 0;
}
最长回文子串
- 给出一个字符串 S S S, 求 S S S 的最长回文子串的长度
- 令
dp[i][j]
表示S[i]
至S[j]
所表示的子串是否是回文子串,是则为 1, 不是为 0。这样根据S[i]
是否等于S[j]
, 可以写出状态转移方程:
- 边界:
dp[i][i] = 1
,dp[i][i + 1] = (S[i] = S[i + 1]) ? 1 : 0
T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)
- 边界:
#include <cstdio>
#include <cstring>
const int maxn = 1010;
char S[maxn];
int dp[maxn][maxn];
int main() {
fgets(S, maxn, stdin);
int len = strlen(S) - 1, ans = 1;
memset(dp, 0, sizeof(dp));
// 边界
for (int i = 0; i < len; i++) {
dp[i][i] = 1;
if (i < len - 1) {
if (S[i] == S[i + 1]) {
dp[i][i + 1] = 1;
ans = 2; // 初始化时注意当前最长回文子串长度
}
}
}
// 状态转移方程
for(int L = 3; L <= len; L++) { // 枚举子串的长度
for(int i = 0; i + L - 1 < len; i++) { // 枚举子串的起始端点
int j = i + L - 1; // 子串的右端点
if (S[i] == S[j] && dp[i + 1][j - 1] == 1) {
dp[i][j] = 1;
ans = L; // 更新最长回文子串长度
}
}
}
printf("%d\n", ans);
return 0;
}
DAG 最长 / 短路
DAG: 有向无环图
- 由于 DAG 最长路和最短路的思想是一致的,因此下面以最长路为例
- 应用:求关键路径 (DAG 最长路)
- 应用:矩形嵌套问题:给出
n
n
n 个矩阵的长和宽, 定义矩形的嵌套关系为: 如果有两个矩形
A
A
A 和
B
B
B, 其中矩形
A
A
A 的长和宽分别为
a
a
a、
b
b
b, 矩形
B
B
B 的长和宽分别为
c
c
c、
d
d
d, 且满足
a
<
c
a<c
a<c、
b
<
d
b< d
b<d, 或
a
<
d
a<d
a<d、
b
<
c
b< c
b<c, 则称矩形
A
A
A 可以嵌套于矩形
B
B
B 内。现在要求一个矩形序列, 使得这个序列中任意两个相邻的矩形都满足前面的矩形可以嵌套于后一个矩形内, 且序列的长度最长。如果有多个这样的最长序列, 选择矩形编号序列的字典序最小的那个
- 这个例子就是典型的 DAG 最长路问题。将每个矩形都看成一个顶点, 并将嵌套关系视为顶点之间的有向边,边权均为 1, 于是就可以转换为 DAG 最长路问题
求整个 DAG 中的最长路径(即不固定起点跟终点)
- 如图 11-6 所示,B-D-F-I 就是该图的最长路径,长度为 9
- 针对这个问题,令
dp[i]
表示从 i i i 号顶点出发能获得的最长路径长度,这样所有dp[i]
的最大值就是整个 DAG 的最长路径长度- 那么怎样求解
dp
数组呢?注意到dp[i]
表示从 i i i 号顶点出发能获得的最长路径长度,如果从 i i i 号顶点出发能直接到达顶点 j 1 j_1 j1、 j 2 j_2 j2、… 、 j k j_k jk, 而dp[j1]
、dp[j2]
、…、dp[jk]
均已知,那么就有dp[i] = max {dp[j] + length[i -> j] | (i,j) 属于 E}
;显然,根据上面的思路,需要按照逆拓扑序列的顺序来求解dp
数组 - 但使用递归可以不显式地求出逆拓扑序列也能计算
dp
数组; 具体递归代码如下,其中边界情况为无出边的顶点,它们的dp
值为 0;具体实现中不妨对整个dp
数组初始化为 0
- 那么怎样求解
int DP (int i) {
if(dp(i] > 0)
return dp[i]; // dp[i]已计算得到
for (int j = 0; j < n; j++) { // 遍历 i 的所有出边
if(G[i][j] != INF) {
dp[i] = max(dp[i], DP(j) + G[i][j]);
}
}
return dp[i];
}
求具体的最长路
- 如果需要求具体的最长路,则可以开一个
int
型choice
数组记录最长路径上顶点的后继顶点。如果最终可能有多条最长路径,将choice
数组改为vector
类型的数组即可
int DP(int i) {
if (dp[i] > 0)
return dp[i];
for(int j = 0; j < n; j++) {
if(G(i][j] != INF) {
int temp = DP(j) + G[i][j]; // 单独计算,防止if中调用DP函数两次
if (temp > dp[i]) { // 可以获得更长的路径
dp[i] = tmep; // 覆盖 dp[i]
choice[i] = j; // i 号顶点的后继顶点是 j
}
}
}
return dp[i];
}
// 将 i 作为路径起点传入
void printPath(int i) {
printf("%d", i);
while(choice[i] != -1) { // choice 数组初始化为 -1
i = choice[i];
printf("->%d", i);
}
}
选取字典序最小的最长路
- 只需要让遍历 i i i 的邻接点的顺序从小到大即可(事实上, 上面的代码自动实现了这个功能)
- 如果令
dp[i]
表示以 i i i 号顶点结尾能获得的最长路径长度,则只要把求解公式变为dp[i] = max{dp[j] + length[j -> i] | (j, i) 属于 E}
(相应的求解顺序变为拓扑序), 就可以同样得到最长路径长度, 也可以设置choice
数组求出具体方案,但却不能直接得到字典序最小的方案, 这是为什么呢?- 举个很简单的例子,如图 11-8 所示,如果令
dp[i]
表示从 i i i 号顶点出发能获得的最长路径长度, 且dp[2]
和dp[3]
已经计算得到, 那么计算dp[1]
的时候只需要从 V 2 V_2 V2 和 V 3 V_3 V3 中选择字典序较小的 V 2 V_2 V2 即可;而如果令dp[i]
表示以 i i i 号顶点结尾能获得的最长路径长度, 且dp[4]
和dp[5]
已经计算得到, 那么计算dp[6]
时如果选择了字典序较小的 V 4 V_4 V4, 则会导致错误的选择结果: 理论上应当是 V 1 → V 2 → V 5 V_1\rightarrow V_2\rightarrow V_5 V1→V2→V5 的字典序最小,可是却选择了 V 1 → V 3 → V 4 V_1\rightarrow V_3\rightarrow V_4 V1→V3→V4
- 举个很简单的例子,如图 11-8 所示,如果令
- 显然, 由于字典序的大小总是先根据序列中较前的部分来判断, 因此序列中越靠前的顶点, 其
dp
值应当越后计算(对一般的序列型动态规划问题也是如此)
固定终点,求 DAG 的最长路径
- 假设规定的终点为
T
T
T, 那么可以令
dp[i]
表示从 i i i 号顶点出发到达终点 T T T 能获得的最长路径长度- 这里 DP 的状态转移方程与之前是一样的,也即如果从
i
i
i 号顶点出发能直接到达顶点
j
1
j_1
j1、
j
2
j_2
j2、… 、
j
k
j_k
jk, 而
dp[j1]
、dp[j2]
、…、dp[jk]
均已知,那么就有dp[i] = max {dp[j] + length[i -> j] | (i,j) 属于 E}
; - 不一样的是边界条件。需要初始化
dp
数组为-INF
,令dp[T] = 0
。然后设置一个vis
数组表示顶点是否已经被计算
- 这里 DP 的状态转移方程与之前是一样的,也即如果从
i
i
i 号顶点出发能直接到达顶点
j
1
j_1
j1、
j
2
j_2
j2、… 、
j
k
j_k
jk, 而
int DP(int i) {
if (vis[i])
return dp[i];
vis[i] = true;
for(int j = 0; j < n; j++) ( // 遍历 i 的所有出边
if(G[i][j] != INF) {
dp[i] = max(dp[i], DP(j) + G[i][j]);
}
}
return dp[i];
}
- 至于如何记录方案以及如何选择字典序最小的方案, 均与第一个问题相同
- 如果令
dp[i]
表示以 i i i 号顶点结尾能获得的最长路径长度,应当如何处理?事实上这样设置dp[i]
会变得更容易解决问题, 并且dp[T]
就是结果, 只不过仍然不方便处理字典序最小的情况
背包问题
01 背包问题
- 有 n n n 件物品,每件物品的重量为 w [ i ] w[i] w[i], 价值为 c [ i ] c[i] c[i]。现有一个容量为 V V V 的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有 1 件
- 令
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v] 表示前
i
i
i 件物品恰好装入容量为
v
v
v 的背包中所能获得的最大价值, 这样根据是否放入第
i
i
i 件物品可以写出状态转移方程,边界条件是
d
p
[
0
]
[
v
]
=
0
dp[0][v]=0
dp[0][v]=0:
for(int i = 1; i <= n; i++) {
for(int v = w[i]; v <= V; v++) {
dp[i][v] = max(dp[i - 1][v], dp[i - 1][v - w[i]] + c[i]);
}
}
- 上述算法的时间和空间复杂度均为
O
(
n
V
)
O(nV)
O(nV), 其中时间复杂度已经无法再优化,但是利用滚动数组技巧,空间复杂度还可以再优化到
O
(
V
)
O(V)
O(V)。注意到状态转移方程中计算
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v] 时总是只需要
d
p
[
i
−
1
]
[
v
]
dp[i - 1][v]
dp[i−1][v] 左侧部分的数据,且当计算
d
p
[
i
+
1
]
[
]
dp[i + 1][]
dp[i+1][] 的部分时,
d
p
[
i
−
1
]
dp[i- 1]
dp[i−1] 的数据又完全用不到了, 因此不妨可以直接开一个一维数组
d
p
[
v
]
dp[v]
dp[v],枚举方向改变为
i
i
i 从 1 到
n
n
n,
v
v
v 从
V
V
V 到 0 (逆序!), 这样状态转移方程改变为
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxp = 100; // 物品最大件数
const int maxv = 1000; // V 的上限
int w[maxn], c[maxn] , dp[maxv];
int main() {
int n, V;
scanf("%d%d", &n, &V);
for(int i = 0; i < n; i++) {
scanf("%d", &w[i]);
}
for(int i = 0; i < n; i++) {
scanf("%d", &c[i]);
}
// 边界
for(int v = 0; v <= V; v++) {
dp[v] = 0;
}
for(int i = 1; i <= n; i++) {
for(int v = V; v >= w[i]; v--) {
// 状态转移方程
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);
}
}
// 寻找 dp[0 ... V] 中最大的即为答案
int max = 0;
for(int v = 0; v <= V; v++) {
if(dp[v] > max) {
max = dp[v];
}
}
printf("%d\n", max);
return 0;
}
完全背包问题
- 有 n n n 种物品,每种物品的单件重量为 w [ i ] w[i] w[i], 价值为 c [ i ] c[i] c[i]。现有一个容量为 V V V 的背包, 问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件
- 令
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v] 表示前
i
i
i 件物品恰好装入容量为
v
v
v 的背包中所能获得的最大价值, 这样根据是否放入第
i
i
i 件物品可以写出状态转移方程 (和 01 背包问题唯一的不同点在于选择放入第
i
i
i 件物品转移到的状态是
d
p
[
i
]
[
v
−
w
[
i
]
]
dp[i][v-w[i]]
dp[i][v−w[i]] 而非
d
p
[
i
−
1
]
[
v
−
w
[
i
]
]
dp[i-1][v-w[i]]
dp[i−1][v−w[i]]),边界条件是
d
p
[
0
]
[
v
]
=
0
dp[0][v]=0
dp[0][v]=0:
这个状态转移方程同样可以改写成一维形式, 即状态转移方程:
写成一维形式之后和 01 背包完全相同, 唯一的区别在于这里 v v v 的枚举顺序是正向枚举,而 01 背包的一维形式中 v v v 必须是逆向枚举
for(int i = 1;i <= n; i++) {
for (int v = w[i]; v <= V; v++) { // 正向枚举 v
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);
}
}
参考文献
- 《算法笔记》