一、 解题框架
判断一个问题是否拥有重叠子问题
和 最优子结构
,是则进行动态规划解题:
-
1. 定义
数组元素的含义
。比如在LIS问题中dp[i][j]的含义是以A[i]为结尾的LIS长度。 -
2. 阶段、状态和决策。
-
- I. 找出该形式
状态转移方程
:
d p [ i ] [ j ] = max ( v a l u e 1 , v a l u e 2 ) + v a l u e 3 dp[i][j] = \max(value_1 , value_2) + value_3 dp[i][j]=max(value1,value2)+value3
注意上式可能是一维数组
或者函数是min
。
- I. 找出该形式
-
- II. 找出数组元素的初始值和不同方程满足的条件,确定好i、j的取值范围。
-
3. 输出位置。确定输出位置是什么,即满足条件的i、j的值。
二、典型代表
一维数组代表
爬楼梯
楼梯共 n 阶,每次可以爬 1 或 2 个台阶。求多少种办法爬到楼梯。
——
Input: n;Output:办法数。
——
示例:
Input:3
Output:3
1.定义数组元素含义。
令 dp[i] 表示爬至第 n 阶共 dp[i] 种办法。
2.找出状态转移方程:
因为我可以走 1 阶或 2 阶,所以有两种走的方式:
- 从 n - 1 阶走上来
- 从 n - 2 阶走上来
所以,我们得出爬到第 n 阶的所有情况办法的状态转移方程
:
d
p
[
n
]
=
d
p
[
n
−
1
]
+
d
p
[
n
−
2
]
;
dp[n] = dp[n-1] + dp[n-2];
dp[n]=dp[n−1]+dp[n−2];
3.找出初始值和条件、边界
由于 n >= 2 时以上方程中的数组才有意义,现已知走 0 阶方法为 0 ,1 阶为 1 ,故进行以下初始化:
d
p
[
0
]
=
0
;
d
p
[
1
]
=
1
;
dp[0]=0;\\dp[1]=1;
dp[0]=0;dp[1]=1;
同样的,当 n = 2 时dp[2] != dp[1] + dp[0],故定义:
d
p
[
2
]
=
2
dp[2]=2
dp[2]=2
4.于是可以写代码了:
dp[0] = 0; dp[1] = 1; dp[2] = 2;//初始值
for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];//计算
cout << dp[n] << endl;//输出第 n 个 dp 数组值
最长不下降子序列(LIS)
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
例如,现有序列 A = { 1 , 2 , 3 , − 1 , − 2 , 7 , 9 } A=\begin{Bmatrix}1, 2, 3, -1, -2, 7, 9\end{Bmatrix} A={1,2,3,−1,−2,7,9}长度为5。另外,还有一些子序列是不下降子序列,比如 { 1 , 2 , 3 } \begin{Bmatrix}1, 2, 3\end{Bmatrix} {1,2,3}、 { − 2 , 7 , 9 } \begin{Bmatrix}-2, 7, 9\end{Bmatrix} {−2,7,9} 等,但都不是最长的。
如果采用暴力枚举。。。我的天咧,时间复杂度足足有
O
(
2
n
)
O(2^n)
O(2n) 。。。这么大 100% LE。。。
所以咧,我们采用动态规划的简单方法。
1.定义数组元素含义
令 dp[i] 表示以 A[i] 结尾的最长的不下降子序列长度
(以 A[i] 结尾为强制性要求
)。
2.找出状态转移方程
这样会出现两种情况:
- 如果存在 A[i] 之前的元素 A[j] ( j < i ) ,使得 A [ j ] ⩽ A [ i ] A[j]\leqslant A[i] A[j]⩽A[i] d p [ j ] + 1 > d p [ i ] dp[j]+1>dp[i] dp[j]+1>dp[i](即把 A[i] 跟在以 A[j] 结尾的 LIS 后面时能比当前以 A[i] 结尾的 LIS 长度更长),那么就把 A[i] 跟在以 A[j] 为结尾的 LIS 后面,形成一条更长的 IS 。即令 d p [ i ] = d p [ j ] + 1 dp[i]=dp[j]+1 dp[i]=dp[j]+1
- 如果 A[i] 之前的元素都比它大,那么它就成为独立的一条 LIS,但是长度为 1,即这个子序列里面只有一个 A[i]。
- 最后以 A[i] 结尾的 LIS 长度就是1、2中能形成的最大长度。于是,有
状态转移方程
: d p [ i ] = max { 1 , d p [ j ] + 1 } dp[i]=\max \begin{Bmatrix}1,dp[j]+1\end{Bmatrix} dp[i]=max{1,dp[j]+1} ( j = 1 , 2 , . . . , i − 1 (j=1,2,...,i-1 (j=1,2,...,i−1 & & \&\& && A [ j ] < A [ i ] ) A[j]<A[i]) A[j]<A[i])
3.找边界
其实上面的状态转移方程已经包含了边界: d p [ i ] = 1 ( 1 ⩽ i ⩽ n ) dp[i]=1(1\leqslant i\leqslant n) dp[i]=1(1⩽i⩽n)。
4.输出位置
让 i 从小到大遍历即可求出整个 dp 数组,即找出那个最大的 dp 数组。
5.代码实现
#include <bits/stdc++.h>
using namespace std;
int n;
int str[10010];
int dp[10010];
int LIS(){
memset(dp, 0, sizeof(dp));
int ans = -1;
for(int i = 1; i <= n; i++){
dp[i] = 1; // 边界初始条件,即认为 str[i] 自成一个 LIS
for(int j = 1; j < i; j++){
if((str[i] >= str[j]) && (dp[i] < dp[j] + 1)){
dp[i] = dp[j] + 1;
}
}
ans = max(ans, dp[i]);
}
return ans;
}
void print_ans(){
int j = 1;
dp[n + 1] = -1;
for(int i = 1; i <= n; i++){
if(dp[i] == j && dp[i] != dp[i + 1]){
// dp[i] != dp[i + 1] 保证取 str 中每个 LIS 中的 LDS (Longest Decreasing Subsequence,最长不上升子序列)最后一位(即这个 LDS 最小的那个数),使得 LIS 满足不下降
cout << str[i] << " ";
j++;
}
}
cout << endl;
}
int main(){
cin >> n;
for(int i = 1; i <= n; i++) cin >> str[i];
cout << LIS() << endl;
for(int i = 1; i <= n; i++) cout << dp[i] << " ";
cout << endl;
print_ans();
return 0;
}
二维数组典型代表:最长公共子序列(LCS)
给定两个字符串 A 和 B ,求一个字符串,使得这个字符串是 A 和 B 的最长公共部分(子序列可以不连续)。
例如:
如图所示,字符串 “sad story” 与 “admin sorry”的最长公共子序列为 “adsory”,长度为 6。
1.定义数组元素含义
令 dp[i][j] 表示字符串 A[i] 位 和 B[j] 位之前的LCS长度(下标从 1 开始),如 dp[4][5] 表示“sads”和“admin”的 LCS 长度。
2.找出状态转移方程和边界
- 若 A[i] = B[j],则字符串 A 与字符串 B 的 LCS 长度增加了 1 位,即有: d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j]=dp[i-1][j-1]+1 dp[i][j]=dp[i−1][j−1]+1例如 dp[4][6] 表示 “sads” 与 “admins” 的 LCS 长度,比较 A[4] 与 B[6],发现两者都是 ‘s’,因此 dp[4][6] 就等于 dp[3][5] 加 1,即为 3。
- 若 A[i] != B[j],则字符串 A 的 i 号位和字符串 B 的 j 号位之前的 LCS 无法延长,因此 dp[i][j] 会将会继承 dp[i - 1][j] 与 dp[i][j - 1] 中的较大值,即有 d p [ i ] [ j ] = max { d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] } dp[i][j]=\max\begin{Bmatrix}dp[i-1][j],dp[i][j-1]\end{Bmatrix} dp[i][j]=max{dp[i−1][j],dp[i][j−1]}例如,dp[3][3] 表示“sad”与“adm”的 LCS 长度,比较 A[3] 与 B[3],发现 ‘d’ 不等于 ’m‘,这样 dp[3][3]无法在原先的基础上延长,因此继承“sa”与“adm”的 LCS、“sad”与“ad”的 LCS 中的较大值,即“sad”与“ad”的 LCS 长度 —— 2。
- 因此可得状态转移方程和边界: d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] + 1 , A [ i ] = = B [ j ] max { d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] } , A [ i ] ! = B [ j ] dp[i][j]=\begin{cases}dp[i-1][j-1]+1,A[i]==B[j]\\\max\begin{Bmatrix}dp[i-1][j],dp[i][j-1]\end{Bmatrix},A[i]!=B[j]\end{cases} dp[i][j]={dp[i−1][j−1]+1,A[i]==B[j]max{dp[i−1][j],dp[i][j−1]},A[i]!=B[j] d p [ i ] [ 0 ] = d p [ 0 ] [ j ] = 0 ( 0 ⩽ i ⩽ n , 0 ⩽ j ⩽ m ) dp[i][0]=dp[0][j]=0(0\leqslant i\leqslant n,0\leqslant j\leqslant m) dp[i][0]=dp[0][j]=0(0⩽i⩽n,0⩽j⩽m)
- 最终答案即是 dp[n][m]。
实现代码
#include <bits/stdc++.h>
using namespace std;
string str1, str2;
int dp[10010][10010];
int dp2[10010];
int LCS(){ // print LCS length
memset(dp, 0, sizeof(dp));
for(int i = 1; i <= str1.length(); i++){
for(int j = 1; j <= str2.length(); j++){
if(str1[i - 1] == str2[j - 1]){
dp[i][j] = dp[i - 1][j - 1] + 1;
}else{
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[str1.length()][str2.length()];
}
void print_ans(){ // print LCS string
memset(dp2, 0, sizeof(dp2));
int i = str1.length(), j = str2.length();
while(i > 0 && j > 0){
if(str1[i - 1] == str2[j - 1]){
dp2[i] = 1;
i--; j--;
}else if(dp[i - 1][j] == dp[i][j - 1]){
i--; // j--; 也可以,但输出结果可能有所不同,一个是从前向后数的 LCS,另一个是从后往前数的 LCS。
}else if(dp[i - 1][j] > dp[i][j - 1]){
i--;
}else{
j--;
}
}
for(i = 1; i <= str1.length(); i++){
if(dp2[i] == 1) cout << str1[i - 1] << " ";
}
}
int main(){
while(cin >> str1 >> str2){
cout << LCS() << endl;
print_ans();
}
return 0;
}
区间DP
区间DP三要素:
1.区间长度
2.区间起点、终点,注意最后一个起点位置
3.分割
模板:
int n;
int sum[300];
const int INF = 1 << 30;
int ans{
int dp[n][n];
for(int i = 1; i <= n; i++){
dp[i][i] = 0;
}
for(int len = 1; len < n; len++){ // 枚举区间长度
for(int i = 1; i <= n - len; i++){ // 枚举起点
int j = i + len; // 由起点推导终点
dp[i][j] = INF; // 初始化区间 i ~ j 的 dp
for(int k = i; k < j; k++){ // 枚举 i ~ j 内分割点
// 状态转移方程
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1]);
}
}
}
return dp[1][n];
}
此时复杂度为 O ( n 3 ) O(n^3) O(n3) 。在特定条件下,我们可以使用平行四边形优化使复杂度降到 O ( n 2 ) O(n^2) O(n2) 。
平行四边形优化
以下内容来自 (详解)区间DP —— 平行四边形优化_一个很懒的人_优快云。
防丢失在此备份(或补档)。
“
”
平行四边形优化模板:
int n;
int sum[300];
const int INF = 1 << 30;
int ans(){
int dp[n][n], s[n][n];
for(int i = 1; i <= n; i++){
dp[i][i] = 0;
s[i][i] = i;
}
for(int len = 1; len < n; len++){ // 枚举区间长度
for(int i = 1; i < n - len; i++){ // 枚举起点
int j = i + len; // 由起点推导终点
dp[i][j] = INF; // 初始化区间 i ~ j 的 dp
for(int k = s[i][j - 1]; k <= s[i + 1][j]; k++){
if(dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1] < dp[i][j]){
dp[i][j] = dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1];
s[i][j] = k; // 记录最优分割点
}
}
}
}
return dp[1][n];
}
三、总结
首先,动态规划问题相比较之前有一套固定套路的贪心,更加的发散和灵活。因此,动态规划的难度比贪心高出一级。但是,动态规划在处理相对复杂的问题时比贪心更加的简洁,比如从矩阵的左上角爬到右下角求最大和的问题,从这一点上来看,动态规划比思路较之单一的贪心算法有趣的多。
其次,动态规划其实并不是无迹可寻。动态规划有着自己一套完整的 解题框架。
通过这两周的学习,我也发现了 ACM 并不枯燥的地方。动态规划本来是上个世纪美国数学家提出来用于解决实际生活问题的一种方法,所以并不拘泥于编程——也许今天做到的一个动态规划题(比如金币最大数问题)在以后的生活中真的会带来极大的便利。懂得这一点,就会发现其实越难的题却是越有趣的。