[算法总结] 动态规划
本文组织结构如下:
- 前言
- 最长公共子序列(LCS)
- 最长不下降子序列(LIS)
- 最大连续子序列之和
- 最长回文子串
- 数塔问题
- 背包问题(Knapsack-Problem)
- 矩阵链相乘
- 总结
前言
在学过的算法当中,DP给我的感觉是最难的了。借着本次写blog好好复习一下这个算法。
众所周知,DP算法的关键点:
- 抽象出问题的状态表示
- 定义状态转移方程
- 填表顺序
最长公共子序列
最长公共子序列(Longest Common Subsequence,LCS),顾名思义,是指在所有的子序列中最长的那一个,子序列要求都出现过并且出现顺序与母串保持一致。
例如,给定字符串 a
和 b
:
string a = "cnblog" string b = "belong";
blog
都出现过,且字母顺序一致,那就一个公共子序列(在这里也是最长的公共子序列)。
状态定义:
dp[i, j] 表示 a[0,...,i] 与 b[0,...j] 的最长公共子序列的长度
那么现在的目的就是求出:dp[alen, blen]
状态转移方程:
= 0 if i=0 or j=0 dp[i,j] = dp[i-1,j-1]+1 if a[i]=b[j] = max(dp[i-1,j], dp[i, j-1]) if a[i]!=b[j]
观察可知,每一个 dp[i,j]
都是依赖于 “左边、上边、左上角” 的三个元素之一,所以对于数组 dp
,可以从前往后填写。
DP算法一个关键的地方就是需要初始化边界。在此处,就是需要初始化 dp
数组的第 0 列,以及第 0 行。
代码如下,其中 plat
数组用于求解最大的公共子序列是什么(回溯法+DFS,具体不展开细讲,其他blog写了很多)。
int lcs(string &a, string &b) { string result = ""; int alen = a.length(); int blen = b.length(); vector<vector<int>> dp(alen + 1, vector<int>(blen + 1, 0)); vector<vector<char>> plat(alen + 1, vector<char>(blen + 1, 0)); for (int i = 1; i <= alen; i++) { for (int j = 1; j <= blen; j++) { if (a[i - 1] == b[j - 1]) //此处为什么是 a[i-1] 和 b[j-1] 呢? dp[i][j] = dp[i - 1][j - 1] + 1, plat[i][j] = '\\'; else { if (dp[i - 1][j] >= dp[i][j - 1]) dp[i][j] = dp[i - 1][j], plat[i][j] = '|'; else dp[i][j] = dp[i][j - 1], plat[i][j] = '-'; } } } print(plat, alen, blen); return dp.back().back(); }
上面的代码中,需要特别留意和加以理解的地方就是内层循环中的 if 语句。
可以使用下图来理解,实际上当 i=0
or j=0
时,表示的是字符串为空串,即 s=""
。但是在代码当中,字符串的下标是从 0
开始计数的。在上面的状态转移方程当中,dp[1,1]
实际上需要判断两个字符串的第一个字符是否相等,即 a[0] == b[0]
。
B D C A B A 0 1 2 3 4 5 6 0 0 0 0 0 0 0 A 1 0 B 2 0 C 3 0 B 4 0 D 5 0 A 6 0 B 7 0
完整代码:
#include <iostream> #include <cassert> #include <string> #include "leetcode.h" #include <vector> using namespace std; string a = "ABCBDAB", b = "BDCABA"; // string a = "xyxxzxyzxy", b = "zxzyyzxxyxxz"; void print(vector<vector<char>> &plat, int i, int j) { if (i <= 0 || j <= 0) { return; } if (plat[i][j] == '-') { print(plat, i, j - 1); } else if (plat[i][j] == '|') { print(plat, i - 1, j); } else if (plat[i][j] == '\\') { print(plat, i - 1, j - 1); cout << a[i - 1]; } else { } } int lcs(string &a, string &b) { string result = ""; int alen = a.length(); int blen = b.length(); vector<vector<int>> dp(alen + 1, vector<int>(blen + 1, 0)); vector<vector<char>> plat(alen + 1, vector<char>(blen + 1, 0)); for (int i = 1; i <= alen; i++) { for (int j = 1; j <= blen; j++) { if (a[i - 1] == b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1, plat[i][j] = '\\'; else { if (dp[i - 1][j] >= dp[i][j - 1]) dp[i][j] = dp[i - 1][j], plat[i][j] = '|'; else dp[i][j] = dp[i][j - 1], plat[i][j] = '-'; } } } print(plat, alen, blen); return dp.back().back(); } int main() { cout << lcs(a, b); } /* = 0 if i=0 or j=0 dp[i,j] = dp[i-1,j-1]+1 if a[i]=b[j] = max(dp[i-1,j], dp[i, j-1]) if a[i]!=b[j] */
最长不下降子序列
不下降子序列就是说:从一个数组当中选出若干个元素,按照下标顺序排列,该序列要求非降序排列。
例如有数组:
int a[] = {1, 2, 3, -9, 3, 9, 0, 11};
那么不下降子序列可以是:
1 2 3 3 9 11 1 2 3 ...
求这些序列的最大长度。
我们令 dp[i]
表示:以 a[i]
结尾的,最长不下降子序列的长度。
那么对于 dp[i]
,可以有以下状态转移方程:
dp[0] = 1 dp[i] = 1 = max(1, max(dp[j]+1)), 0<=j<=(i-1) and a[i]>=a[j]
(其实这里的状态转移方程比代码还难理解,还是看代码好 = =)
代码:
int solve(int a[], int len) { vector<int> dp(len, 0); dp[0]=1; for (int i=1;i<len;i++) { dp[i] = 1; for (int j=0;j<i;j++) { if (a[i]>=a[j]) dp[i]=max(dp[i], dp[j]+1); } } int val = -1; for (auto x:dp) val=max(val,x); return val; } int main() { int a[] = {1, 2, 3, -9, 3, 9, 0, 11}; cout << solve(a, 8); }
最大连续子序列之和
这又是一道最XX的题目。
给定 A[1,...,n]
,求 i
和 j
, 1<=i<=j<=n
,使得 sum(A[i,...,j])
最大,输出这个最大和。
比如
-2 11 -4 13 -5 2
最大的、连续的子序列就是:
11 -4 13
最大和是 20 。
正常的思路是穷举每一个左端点和右端点,但是这样的复杂度是 O(n*n)
,其次每次对区间求和又需要 O(n)
的复杂度,只要数据量大,这种方法是不明智的。
来看一下DP的解法。
状态定义:
dp[i]: 以 a[i] 结尾的,最大连续子序列的和。
在这种情况下,在 i
位置,要么只取 a[i]
,要么取 a[i]
加上“前面”的。
转移方程:
dp[0] = a[0] dp[i] = max(a[i], dp[i-1]+a[i])
代码如下:
int solve(int a[], int len) { vector<int> dp(len, 0); dp[0] = a[0]; for (int i=1;i<len;i++) { dp[i]=max(a[i], dp[i-1]+a[i]); } int val = -1; for (auto x:dp) val = max(val, x); return val; } int main() { int a[] = {-2, 11, -4, 13, -5, -2}; cout << solve(a, 6) << endl; } /* 常见错误: dp[i]是[0,i]的最大连续子序列之和 dp[i+1] = max(dp[i], dp[i]+a[i]) (这么定义没法保证连续) 正解: dp[i]表示必须以a[i]结尾的连续序列最大和,为什么“必须”?(因为要求连续) 那么: dp[i] = max(a[i], dp[i-1]+a[i]) */
最长回文子串
子串要求是连续的。
求出字符串S的所有子串中,最长的子串。
还是直接说怎么用DP求解。
定义状态:
dp[i,j]表示:S[i,...,j] 是否为回文串。
边界条件:
dp[i,i] = true //只有一个字符的字符串 dp[i,i+1] = (s[i]==s[i+1])
状态转移方程:
dp[i,j] = dp[i+1,j-1] && (s[i]==s[j])
细心的你可能会发现,这次的 “填表” 不能从前往后填了,因为 dp[i, j]
依赖于它的左下角的元素。这次需要从 “左上” 到 “右下” 这样斜着填表。(先算左下的,后算右上的)
图解说明一下为什么斜着填,假设 s = "abba", len = 4
dp数组初始状态: a b b a a 1 b 0 1 b 0 0 1 a 0 0 0 1 执行s[i]==s[i+1]: a b b a a 1 0 b 0 1 1 b 0 0 1 0 a 0 0 0 1 ==> a b b a a 1 0 0 b 0 1 1 0 b 0 0 1 0 a 0 0 0 1 ==> a b b a a 1 0 0 1 b 0 1 1 0 b 0 0 1 0 a 0 0 0 1
代码:
int solve(string &s) { int len = s.length(); if (len == 0 || len == 1) return len; if (len == 2) return (s[0] == s[1]) + 1; vector<vector<bool>> dp(len, vector<bool>(len, 0)); int maxlen = 1; for (int i = 0; i <= len - 2; i++) { dp[i][i] = 1, dp[i][i + 1] = (s[i] == s[i + 1]); if (dp[i][i + 1]) maxlen = 2; } dp[len - 1][len - 1] = 1; int i = 0; int j = 2; int ti, tj; do { ti = i, tj = j; while (i < len && j < len) { dp[i][j] = dp[i + 1][j - 1] && (s[i] == s[j]); if (dp[i][j] == true) maxlen = max(maxlen, j - i + 1); i++, j++; } i = 0, j = tj + 1; } while (!(j == len)); return maxlen; } int main() { string s[] = {"PATZJUJZTACCBCC", "", "1", "12", "22", "232", "2332"}; for (int i = 0; i < 7; i++) cout << solve(s[i]) << endl; } /* dp[i][j]表示str[i,j]是否为一个回文串,若是则1,若否则0 那么: = 1 if i=j dp[i,j] = (s[i]==s[j]) if i+1=j = dp[i+1,j-1]&&s[i]==s[j] other */
数塔问题
给定如下的树塔 :
level = 5 5 / \ 8 3 / \ / \ 12 7 16 / \/ \/ \ 4 10 11 6 / \ / \ / \ / \ 9 5 3 9 4
第 i
层有 i
个数字, 从第 1
层到第 n
层,每次只能向下走一个数字, 求解所有路径中, 和最大是多少?
首先,我们使用一个二维数组去存储这个树塔,那么对于某个节点 a[i,j]
,它的左右子树如下:
a[i][j] | \ a[i+1][j] a[i+1][j+1]
定义状态:
dp[i,j] 表示从(i,j)出发,到达底层的所有路径中得到的最大和
显然,边界条件为:
dp[level-1][j] = a[level-1][j], 0<=j<level
状态转移函数:
dp[i,j] = a[i,j] + max(dp[i+1,j], dp[i+1,j+1]), 0<=i<levl-1
显然,填表的顺序是自底向上。
完整代码:
#include <iostream> #include <algorithm> #include <iomanip> #include <vector> #define MAXR 100 #define MAXC 100 using namespace std; int a[MAXR][MAXC] = {{0}}; int solve(int level) { vector<vector<int>> dp(level + 1, vector<int>(level + 1, 0)); auto &v = dp.back(); for (int j = 1; j <= level; j++) v[j] = a[level][j]; for (int i = level - 1; i >= 1; i--) { for (int j = 1; j <= i; j++) { dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j]; } } for (int i = 1; i <= level; i++) { for (int j = 1; j <= i; j++) { cout << setw(3) << dp[i][j]; } cout << endl; } return dp[1][1]; } void print(int level) { for (int i = 1; i <= level; i++) { for (int j = 1; j <= i; j++) cout << setw(4) << a[i][j]; cout << endl; } } int main() { int level; cin >> level; for (int i = 1; i <= level; i++) { for (int j = 1; j <= i; j++) cin >> a[i][j]; } cout << solve(level); } /* a[i][j] / \ a[i+1][j] a[i+1][j+1] dp[i,j] 表示从(i,j)出发,到达底层的所有路径中得到的最大和 dp[1,1] = max(dp[2,1], dp[2,2])+a[1,1] dp[i,j] = max(dp[i+1,j], dp[i+1,i+1])+a[i,j] ... dp[r,c] = a[r,c] */ /* Sample1: 5 5 8 3 12 7 16 4 10 11 6 9 5 3 9 4 */
0/1背包问题
给定背包容量 C
,物品体积 volume[n]
,物品价值 value[n]
,每个物品只有一个。
求:在背包容量允许的情况下,装入背包物品的最大价值。
定义状态:
dp[i,j]
表示: 在背包容量为j
的, 供选择物品为前i
项, 装入背包的最大价值。
状态转移方程:
dp[i,j] = 0 if i=0 or j=0 = dp[i-1,j] if j < volume[i] = max(dp[i-1,j], dp[i-1,j-volume[i]] + value[i]) if j > volume[i]
完整代码:
#include <iostream> #include <vector> #include "leetcode.h" using namespace std; const int items = 4; const int C = 9; int volume[items + 1] = {-1, 2, 3, 4, 5}; int values[items + 1] = {-1, 3, 4, 5, 7}; vector<vector<int>> dp(items + 1, vector<int>(C + 1, 0)); int solve() { for (int i = 1; i <= items; i++) { for (int j = 1; j <= C; j++) { if (j < volume[i]) dp[i][j] = dp[i - 1][j]; else { dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - volume[i]] + values[i]); } } } printvv(dp); return dp.back().back(); } int main() { cout << solve() << endl; }
矩阵链相乘
如果两个矩阵可以相乘,那么必然满足:
M1[a,b] M2[b,c]
此外, M1与M2相乘, 所计算的乘法次数为: a*b*c
。所得到的矩阵为 M3[a,c]
。
给定矩阵:
M1 2*10 M2 10*2 M3 2*10
计算:(M1M2)M3,乘法次数为:2*2*10+2*10*2 = 80
。
计算:M1(M2M3),乘法次数为:10*10*2+2*10*10 = 400
。
在这里,我们想找到一种计算顺序,使得计算:
M1 M2 M3 ... Mn
的乘法次数达到最小。
首先,由于矩阵相乘必须满足:M1的列数等于M2的行数。
我们采用下面的数据结构去存储 n
个矩阵的行数与列数。
int nums[N+1] nums[i] 表示 Mi 的行数, nums[i+1] 表示 Mi 的列数. nums[n] 表示 M(n-1) 的列数
然后,定义问题的状态:
dp[i,j] 表示 Mi ... Mj 的最小乘法次数
边界条件:
dp[i,i] = 0
下面推导状态转移方程,考虑整数 k (i<k<=j)
,将 M[i,j]
的乘法拆分为三步:
- 计算
A = M[i,k-1]
,A的行列分别为:nums[i], nums[k]
- 计算
B = M[k,j]
,B的行列分别为:nums[k], nums[j+1]
- 计算
A*B
由此可知,M[i,j]
的乘法次数为:
[i,k-1] + [k,j] + nums[i] * nums[j+1] * nums[k]
因此 ,状态转移方程为:
dp[i,j] = min(dp[i,k] + dp[k,j] + nums[i] * nums[j+1] * nums[k]), i<k<=j
看到这里,可能有点头大,不知道怎么去填 dp
表。其实是按“斜线”的顺序填入,与 最长回文子串 类似。
下面来简单看一下过程,假设有五个矩阵( M0 M1 M2 M3 M4
)相乘,其行列存储如下:
int nums[N + 1] = {5, 10, 4, 6, 10, 2};
边界条件初始化:
M 0 1 2 3 4 0 0 1 * 0 2 * * 0 3 * * * 0 4 * * * * 0 ==> M 0 1 2 3 4 0 0 200 320 ? 1 * # ### 640 2 * * # 240 ### 3 * * * 0 ### 4 * * * * #
以上述 dp[0,3]
为例,说明递推过程:
dp[0,3] = min ( dp[0,0] + dp[1,3] + ... dp[0,1] + dp[2,3] + ... dp[0,2] + dp[3,3] + ... )
其中,...
代表三个 nums[i]
相乘的常数项,可自行对照递推式代入。在这里,需要特别注意 dp[i,j]
的依赖项(实际上是“同行”与“同列” 的所有元素)。
完整代码:
#include "leetcode.h" #include <cmath> #define N 5 #define MAXINT 9999 int nums[N + 1] = {5, 10, 4, 6, 10, 2}; vector<vector<int>> dp(N, vector<int>(N, MAXINT)); int solve() { for (int i = 0; i < N; i++) dp[i][i] = 0; for (int d = 1; d < N; d++) { for (int i = 0; i < (N - d); i++) { int j = i + d; for (int k = i + 1; k <= j; k++) { dp[i][j] = min(dp[i][j], dp[i][k - 1] + dp[k][j] + nums[i] * nums[k] * nums[j + 1]); } } } for (auto &v : dp) { for (auto x : v) { cout << setw(4); if (x == MAXINT) cout << '*'; else { cout << x; } } cout << endl; } return dp[0][N - 1]; } int main() { cout << solve() << endl; } /* M0...M(n-1): nums[i] 表示 Mi 的行数, nums[i+1] 表示 Mi 的列数. nums[n] 表示 M(n-1) 的列数 */ /* dp[i,j] 表示 Mi ... Mj 的最小乘法次数 边界: dp[i,i] = 0; 递推: dp[i,j] = min(dp[i,k-1]+dp[k,j] + a[i]*a[k]*a[j+1]), i<k<=j ==> dp[0,n-1] = min(dp[0, k-1] + dp[k,n-1] + a[0]*a[k]*a[n]), 0<k<=n-1 */
总结
没总结,有空再补充。