再讲解动态规划之前,先引入一个数字三角形的例题,借此来说明分治法、备忘录法和线性规划法的区别,之后再对动态规划进行讲解。
引例:数字三角形
问题描述
有一个非负整数组成的三角形,第一行只有一个数。除了最下面一行外,每个数的左下方和右下方都有一个数。如下图:
从第一行开始,每次可以往左下或者右下走一格,直至最下面一行,把沿途数字全部相加,如何才能使得相加和最大?
思路分析
先定义变量。D(r,j)
表示 r 行第 j 个数字;MaxSum(r,j)
表示从 D(r,j)
到底边的各条路径中最大数字和,其中 i,j 均从 1 开始取值。限定条件为:从 D(r,j)
出发,每次只能走 D(r+1,j)
和 D(r+1,j+1)
。则原问题的解就变成了求解 MaxSum(1,1)
。
解法一:分治法
此题可以利用分治法来解决,将原问题分解为若干子问题,代码实现如下(伪代码):
int D[MAXN][MAXN]; // 初始矩阵
int n; // 行号
int MaxSum(int i, int j) {
if (i == n) return D[i][j];
int x = MaxSum(i + 1, j);
int y = MaxSum(i + 1, j + 1);
return max(x, y) + D[i][j];
}
cout << MaxSum(1,1) // 所求结果即为 MaxSum(1,1)
但是,这种分治算法的复杂度为 O ( 2 n ) O(2^{n}) O(2n) 是指数级的,计算量及其恐怖。而这是为什么呢 ?
因为存在着大量重复计算!!其计算次数如下:
解法二:备忘录法(自顶而下)
备忘录法:对于每个子问题建立一个记录项,初始化时,存入一个特殊值(如 -1),表示该子问题未求解。求解过程中,对待每个待求子问题,先查看其相应的记录项。若是特殊值,表示该子问题未求解,需要计算其解并存入记录项中;若不是特殊值,表示该待求子问题已计算过,直接取出该解即可。
如果每算出一个 MaxSum(r,j)
就保存起来(建立备忘录),下次用到其值的时候直接取用,则可免去重复计算。那么,因为三角形的数字总数是
n
(
n
+
1
)
/
2
n(n+1)/2
n(n+1)/2,只需要计算
n
(
n
+
1
)
/
2
n(n+1)/2
n(n+1)/2 次,所以只使用
O
(
n
2
)
O(n^2)
O(n2) 时间便可完成计算。计算次数如下:
那么具体怎么实现呢?给定一个用于记录子问题最优值的数组 maxSum[][]
,初始为 -1,表示还未知该点的最优解。当 maxSum[i][j] != -1
时,说明已知该点最优解,直接返回该值即可,这样就避免了重复计算。
int D[MAXN][MAXN];
int n;
int maxSum[MAXN][MAXN]; // 存放最优值
// 初始化 maxSum[i][j] = -1
int MaxSum(int i, int j) {
if (maxSum[i][j] != -1)
return maxSum[i][j];
if (i == n)
maxSum[i][j] = D[i][j];
else {
int x = MaxSum(i + 1, j);
int y = MaxSum(i + 1, j + 1);
maxSum[i][j] = max(x, y) + D[i][j];
}
return maxSum[i][j];
}
解法三:动态规划法(自底而上)
动态规划法:依据其递推式,自底而上进行计算。在计算过程中,保存已解决的子问题答案。每个子问题只需计算一次,后面只需查看已保存的记录即可。
第二种解法中我们利用了递归来解决,此外,也可以利用循环递推来代替递归。从求解最底层最大和开始,依次向上递推。很显然,我们只需两重循环就可以解决这个问题。
int D[MAXN][MAXN];
int n;
int maxSum[MAXN][MAXN];
int main() {
int i, j;
cin >> n;
for (i = 1; i <= n; i++)
for (j = 1; j <= i; j++)
cin >> D[i][j];
// 为最后一行赋值
for (int i = 1; i <= n; ++i)
maxSum[n][i] = D[n][i];
// 自底而上
for (int i = n - 1; i >= 1; --i)
for (int j = 1; j <= i; ++j)
maxSum[i][j] = max(maxSum[i + j][j], maxSum[i + 1][j + 1]) + D[i][j];
cout << maxSum[1][1] << endl;
return 0;
}
通过解法二的备忘录法和解法三的线性规划法来看,可以发现两者有一个共性,那就是都利用了额外的一个表来保存已求解子问题的解,进而避免了重复计算优化了算法。这也是动态规划的基本思想,同时也是动态规划有别于分治法的地方。
此外,备忘录法也采用表来保存子问题的解,因此被认为是线性规划法的变形。
扩展:空间优化(只了解动态规划,该内容可以跳过)
实际上,为了减小空间复杂度,没有必要使用二维数组 maxSum 来存储每个值。
对此,我们可以进行改善,只需要一维数组 maxSum[] 即可解决问题。因为所采取的算法是自底而上递推,所以我们只需将更新的数据覆盖到最底层即可。例如,对倒数第二层的 2 来说,最大和为 7(2+5)。那么就可以将 7 覆盖到倒数第一层 4 的位置上(因为 4 以后不会再被用到)。以此类推,将倒数第二层的结果更新后一维数组显示如下:
更进一步来说,甚至连一维数组 maxSum[] 都可以不要,直接利用存储原始数据的 D 数组第 n 行来代替一维数组 maxSum[] 即可。实现代码如下:
int D[MAXN][MAXN];
int n;
int *maxSum;
int main()
{
int i, j;
cin >> n;
for (i = 1; i <= n; i++)
for (j = 1; j <= i; j++)
cin >> D[i][j];
// 将 maxSum 指向第 n 行
maxSum = D[n];
for (int i = n - 1; i >= 1; --i)
for (int j = 1; j <= i; ++j)
maxSum[j] = max(maxSum[j], maxSum[j + 1]) + D[i][j];
cout << maxSum[1] << endl;
return 0;
}
一、动态规划基本思想
通过引例,我们可以发现动态规划算法其实和分治法类似。其基本思想都是将待求解的问题分解成若干子问题,先求解子问题,再结合这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,有些子问题会被重复计算很多次,最终导致耗费时间是指数级的。我们的改进措施是用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。
一般思路
-
将原问题分解为若干子问题
子问题与原问题相似,规模变小。子问题一旦解决,原问题也随之解决。一旦子问题的解被求出就要保存,因此所有子问题只需求解一次。
-
确定状态
我们往往将与子问题相关的各个变量的一组取值称为一个 “ 状态 ”。如数字三角形问题中的行号 r 和列号 j,就是一个状态,而状态的值则表示这个子问题的解。我们常用多维数组来存放状态的值,如数字三角形问题中的
maxSum[][]
。 -
确定初始(边界)状态值
以数字三角形为例,初始状态就是底边数字,值就是底边数字值。
-
确定状态转移方程
如何从一个已知的 “ 状态 ” 去求出另一个未知 “ 状态 ” 呢,就是需要用到递推式,也被称为 “ 状态转移方程 ” 。如数字三角形中,状态转移方程如下:
二、动态规划算法基本要素
从求解数字三角形问题中,我们可以发现动态规划法的有效性,或者是说使用线性规划法的条件有以下两点:最优子结构性质 和 子问题重叠性质 。
- 最优子结构性质
当问题的最优解中包含了子问题的最优解时,称该问题具有最优子结构。利用问题的最优子结构性质,以自底而上的方式递归地从子问题最优解中逐步构造出整个问题的最优解。例如数字三角形中,先求最底层最大和,再利用已求最底层的最大和求解倒数第二层最大和,…,直到第一层。
- 重叠子问题
在用递归算法自顶而下解决问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算。动态规划利用这些子问题重叠的性质,对每个子问题只求解一次,将其保存在一张表中。
三、经典题目
题目一:最长上升子序列
问题描述
一个数的序列ai,当 a 1 a_1 a1 < a 2 a_2 a2 < … < a S a_S aS 的时候,我们称这个序列是上升的。对于给定的一个序列( a 1 a_1 a1, a 2 a_2 a2, …, a N a_N aN),我们可以得到一些上升的子序列( a i 1 a_{i1} ai1, a i 2 a_{i2} ai2, …, a i k a_{ik} aik),这里1 <= i 1 {i1} i1 < i 2 {i2} i2 < … < i k {ik} ik<= N。比如,对于序列(1, 7, 3, 5, 9, 4, 8),有它的一些上升子序列,如 (1, 7),(3, 4, 8) 等等。这些子序列中最长的长度是 4,比如子序列 (1, 3, 5, 8)。
你的任务,就是对于给定的序列,求出最长上升子序列的长度。
// 输入
7
1 7 3 5 9 4 8
// 输出
4
思路分析
① 找子问题
首先想到的是求前 n 个元素最长上升子序列的长度,但是很快就会发现,它不满足动态规划的条件,那就是不具有重叠子问题。前 n 个元素的最长上升序列不一定包含在前 n+1 个最长上升序列中。
经过思考,选定子问题为:求以 a k a_k ak ( k = 1 , 2 , . . . , N ) (k=1,2,...,N) (k=1,2,...,N) 为终点的最长上升子序列的长度。这 N 个子问题的解中,最大的那个解就是整个问题的解。
② 确定状态
子问题只与位置 k 有关,k 即为状态,而状态 k 所对应值,即为以 a k a_k ak 为终点的最大上升子序列长度。
③ 找出状态转移方程
假设 maxLen(k) 表示以 a k a_k ak 做为终点的最长上升子序列的长度,那么可以得到递推式:
maxLen(k) = max { maxLen (i):1 <= i < k && a i a_i ai < a k a_k ak && k≠1 } + 1
代码实现
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int MAXN = 1010;
int a[MAXN];
int maxLen[MAXN];
int main() {
int N;
cin >> N;
for (int i = 1; i <= N; ++i) {
cin >> a[i];
maxLen[i] = 1;
}
// 求出以第 i 个数为终点的最长上升子序列长度
for (int i = 2; i <= N; ++i)
// 查看以第 j 个数为终点的最长上升子序列长度
for (int j = 1; j < i; ++j)
if (a[i] > a[j])
maxLen[i] = max(maxLen[i], maxLen[j] + 1); // 因为 maxLen[i] 可能会比 maxLen[j] 大
cout << *max_element(maxLen + 1, maxLen + N + 1);
return 0;
}
max_element(begin,end)
是 C++ STL 中方法,begin
序列起始地址(迭代器),end
序列结束地址(迭代器)。用于找序列中的最值,返回第一个最大元素的地址。其速度远快于for循环遍历找最值。此外,还有查找最小值的min_element(begin,end)
方法。
题目二:最长公共子序列
问题描述
给出两个字符串,求出这样的一个最长的公共子序列的长度:子序列中的每个字符都能在两个原串中找到,而且每个字符的先后顺序和原串中的先后顺序一致。
// 输入
abcfbc abfcab
programming contest
abcd mnp
// 输出
4
2
0
思路分析
输入两个串 s1、s2,设 MaxLen(i,j) 表示:s1 的左边 i 个字符形成的子串,与 s2 左边的 j 个字符形成的子串的最长公共子序列的长度( i、j 从 0 开始算 )MaxLen(i,j) 就是本题的 “ 状态 ” 。
假定 len1 = strlen(s1)、len2 = strlen(s2),那么题目就是要求 MaxLen(len1,len2)。
那么,有递推公式:
if ( s1[i-1] == s2[j-1] ) // s1 的最左边字符是s1[0]
MaxLen(i,j) = MaxLen(i-1,j-1) + 1;
else
MaxLen(i,j) = Max(MaxLen(i,j-1),MaxLen(i-1,j) );
由于每个数组单元的计算耗费 O ( 1 ) O(1) O(1) 时间,因此该算法共耗时 O ( m n ) O(mn) O(mn)。
为什么在
s1[i-1] != s2[j-1]
时,MaxLen(i,j) = Max(MaxLen(i,j-1),MaxLen(i-1,j) )
呢?这是因为,MaxLen(i,j)
首先不会比MaxLen(i,j-1)
和MaxLen(i-1,j)
小;其次,如果MaxLen(i,j)
比MaxLen(i,j-1)
和MaxLen(i-1,j)
都大的话,那么就推出了s1[i-1] == s2[j-1]
与条件矛盾,所以只能取两者中较大的一方。
代码实现
#include <iostream>
#include <cstring>
using namespace std;
char s1[1005];
char s2[1005];
int maxLen[1005][1005];
int main() {
while (cin >> s1 >> s2) {
int len1 = strlen(s1);
int len2 = strlen(s2);
int i, j;
// 设置边界条件
for (i = 0; i <= len1; i++)
maxLen[i][0] = 0;
for (j = 0; j <= len2; j++)
maxLen[0][j] = 0;
// 递推方程
for (i = 1; i <= len1; i++)
for (j = 1; j <= len2; j++)
if (s1[i - 1] == s2[j - 1])
maxLen[i][j] = maxLen[i - 1][j - 1] + 1;
else
maxLen[i][j] = max(maxLen[i][j - 1], maxLen[i - 1][j]);
cout << maxLen[len1][len2] << endl;
}
return 0;
}
题目三:最佳加法表达式
问题描述
有一个由 1…9 组成的数字串。问如果将 m 个加号插入到这个数字串中,在各种可能形成的表达式中,值最小的那个表达式的值是多少?
// 输入
2
123456
1
123456
4
12345
// 输出
102
579
15
思路分析
设 V(m,n) 表示在 n 个数字中插入 m 个加号所能形成的表达式最小值,那么:
其中,Num(i,j) 表示从第 i 个数字到第 j 个数字所组成的数。数字编号从 1 开始算。此操作复杂度为
O
(
j
−
i
+
1
)
O(j-i+1)
O(j−i+1)(可理解为
O
(
n
)
O(n)
O(n)) ,可以先将此数组预处理后存起来,避免每次都要运算求解此值耗费时间。
此外,共有 m × n 种状态,故总时间复杂度为 O ( m , n ) O(m,n) O(m,n) 。
代码实现
#include <iostream>
#include <cstring>
#include <algorithm>
#define INF 0x3f3f3f3f
using namespace std;
int Val[100][100];
int Num[100][100];
string numList;
int main() {
int m = 0, n = 0;
while (cin >> m) {
cin >> numList;
n = numList.length();
// 预处理 Num[][]
for (int i = 1; i <= n; ++i) {
Num[i][i] = numList[i - 1] - '0';
for (int j = i + 1; j <= n; ++j)
Num[i][j] = Num[i][j - 1] * 10 + numList[j - 1] - '0';
}
for (int i = 0; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (i == 0)
Val[i][j] = Num[0][j];
else if (j < i + 1)
Val[i][j] = INF;
else
for (int k = i; k < j; ++k)
// 最后一个加号位置
Val[i][j] = min(Val[i][j], Val[i - 1][k] + Num[k + 1][j]);
}
}
cout << Val[m][n] << endl;
}
return 0;
}
题目四:0-1 背包问题
问题描述
有 N 件物品和一个容积为 M 的背包。每种物品只有一件,可以选择放或者不放(N <= 3500,M <= 13000)。
第 i 件物品的体积 w[i]
,价值是 d[i]
。求解将哪些物品装入背包可使价值总和最大。
// 输入
4 5 // 物品数量 背包容积
1 2 // 体积 价值
2 4
3 4
4 5
// 输出
8
思路分析
假设我们使用 F[i][j]
表示取前 i 种物品,使得它们总体积不超过 j 的最优取法取得的价值总和。
则求出 F[N][M]
即为本题题解。
先考虑边界条件:
if (w[1] <= j)
F[1][j] = d[1];
else
F[1][j] = 0;
下面考虑递推式。对于第 i 种物品,我们可以选择取或者不取两种方案,那么就划分出了两个子问题,
我们对其取优即可
F[i][j] = max(F[i-1][j], F[i-1][j-w[i]]+d[i])
代码实现
#include <iostream>
using namespace std;
int N, M;
int w[3505], d[3505];
int F[3505][13005];
int main() {
cin >> N >> M;
for (int i = 1; i <= N; ++i) {
cin >> w[i] >> d[i];
F[0][i] = 0;
}
F[0][0] = 0;
for (int i = 1; i <= N; ++i)
for (int j = 1; j <= M; j++) {
// 边界条件
if (w[1] <= j)
F[1][j] = d[1];
else
F[1][j] = 0;
// 递推公式
if (j - w[i] >= 0)
F[i][j] = max(F[i - 1][j], F[i - 1][j - w[i]] + d[i]);
else
F[i][j] = F[i - 1][j];
}
cout << F[N][M];
return 0;
}