11.1 动态规划的递归写法和逆推写法
动态规划没有固定的写法、极其灵活,常常需要具体问题具体分析。
11.1.1 什么是动态规划
动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。
11.1.2 动态规划的递归写法
如下是斐波那契数列的常规写法
int F(int n){ if(n == 0 || n == 1) return 1; else return F(n-1) + F(n-2); }
但这种写法会涉及很多重复的计算,当n == 5时,可以得到F(5) = F(4) + F(3),接下来在计算F(4) 时又会有F(4) = F(3) + F(2).这时,F(3)被计算了两次。如果n很大,重复计算的次数将难以想象。
可以开一个一维数组dp[n]用以保存已经计算过的结果,其中dp[n]记录F(n)的结果,并用dp[n] = -1表示F(n)当前还没有被计算过。
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]; return dp[n]; } }
如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题。动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复计算。
11.1.3 动态规划的递推写法
求上图中路径上所有数字相加后得到的和最大是多少?
#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 ",dp[1][1]); return 0; }
通过例子再引申出一个概念:如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么称这个问题拥有最优子结构。
一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决。
11.2 最大连续子序列和
给定一个数字序列A1,A2,...,An,求i,j (1 <= i <= j <= n),使得Ai + ..... + Aj最大,输出这个最大和。
样例:
-2 11 -4 13 -5 -2
显然11+(-4) + 13 = 20为和最大的选取情况,因此最大和为20.
用动态规划求解:
步骤1:令状态dp[i]表示以A[i]作为末尾的连续序列的最大和。以样例为例:序列-2 11 -4 13 -5 -2,下标分别记为0,1,2,3,4,5,那么
dp[0] = -2;
dp[1] = 11;
dp[2] = 7 (11 + (-4) = 7);
dp[3] = 20 (11 + (-4) + 13 = 20)
dp[4] = 15
dp[5] = 13
步骤2: 因为dp[i]要求是必须以A[i]结尾的连续序列,那么只有两种情况:
①这个最大和的连续序列只有一个元素,即以A[i]开始,以A[i]结尾。
②这个最大和的连续序列有多个元素,即从前面某处A[p] 开始,一直到A[i]结尾。
可以得到状态转移方程:dp[i] = max{A[i] , dp[i-1] + A[i] }
因此,实现代码如下:
#include<cstdio> #include<algorithm> using namespace std; const int maxn = 10010; int A[maxn],dp[maxn]; 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]); } //dp[i]存放以A[i]结尾的连续序列的最大和,需要遍历i得到最大的才是结果 int k = 0; for(int i = 1;i < n;i++){ if(dp[i] > dp[k]){ k = i; } } printf("%d ",dp[k]); return 0; }
状态的无后效性:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或者若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
动态规划的核心:如何设计状态和状态转移方程
11.3 最长不下降子序列(LIS)
最长不下降子序列是这样一个问题:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
例如,现有序列A={1,2,3,-1,-2,7,9}(下标从1开始),它的最长不下降子序列是{1,2,3,7,9},长度为5.
令dp[i]表示以A[i]结尾的最长不下降子序列长度。
#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[j]+1 > dp[i])){ dp[i] = dp[j] + 1; //状态转移方程,用以更新dp[i] } } ans = max(ans,dp[i]); } printf("%d",ans); return 0; }
11.4 最长公共子序列(LCS)
最长公共子序列的问题描述为:给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)。
如样例所示,字符串"sadstory"与"adminstory"的最长公共子序列为“adsory”,长度为6.
令dp[i][j]表示字符串A的i号位和字符串B的j号位之前的LCS长度(下标从1开始)
状态转移方程:
#include<cstdio> #include<cstring> #include<algorithm> using namespace std; const int N = 100; char A[N],B[N]; int dp[N][N]; int main(){ int n; gets(A+1); gets(B+1); int lenA = strlen(A+1); int lenB = strlen(B+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 ",dp[lenA][lenB]); return 0; }
11.5 最长回文子串
最长回文子串的问题描述:给出一个字符串S,求S的最长回文子串的长度。
样例:字符串“PATZJUJZTACCBCC”的最长回文子串为"ATZJUJZTA",长度为9.
#include<cstdio> #include<cstring> const int maxn = 1010; char S[maxn]; int dp[maxn][maxn]; int main(){ gets(S); int len = strlen(S),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 ",ans); return 0; }
11.6 DAG最长路
问题:给定一个有向无环图,怎样求解整个图的所有路径中权值之和最大的那条。
令dp[i]表示从i号顶点出发能获得的最长路径长度,这样所有dp[i]的最大值就是整个DAG的最长路径长度。
11.7 背包问题
11.7.1 多阶段动态规划问题
多阶段动态规划问题:一个动态规划可解的问题,可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关。
对这种问题,只需要从第一个问题开始,按照阶段的顺序解决每个阶段中状态的计算,就可以得到最后一个阶段中的状态的解。01背包问题就是这样一个例子。
11.7.2 01背包问题
问题:有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件。
令dp[i][v]表示前i件物品恰好装入容量为v的背包中所能获得的最大价值。怎么求解dp[i][v]呢?
两种策略:
①不放第i件物品,问题转化为前 i-1 件物品恰好装入容量为v的背包中所能获得的最大价值,也即dp[i-1][v].
②放第i件物品,那么问题转化为前i-1件物品恰好装入v - w[i]的背包中所能获得的最大价值,也即dp[i-1][v-w[i]] + c[i]
因此状态转移方程为: dp[i][v] = max{dp[i-1][v] , dp[i-1][v-w[i]] + c[i] },(1 <= i <= n,w[i] <= v <= V)
#include<cstdio> #include<algorithm> using namespace std; const int maxn = 100; const int maxv = 1000; int w[maxn],c[maxn],dp[maxv]; int main(){ int n,V; scanf("%d%d",&n,&V); for(int i = 1; i <= n; i++){ scanf("%d",&w[i]); } for(int i = 1; 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]); } } int max = 0; for(int v = 0; v <= V; v++){ if(dp[v] > max){ max = dp[v]; } } printf("%d ",max); return 0; }
11.7.3 完全背包问题
完全背包问题的叙述如下:有n种物品,每种物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
一维形式的状态转移方程与01背包完全相同,唯一的区别在于这里的v的枚举顺序是正向枚举,而01背包的一维形式中v必须是逆向枚举。
完全背包的一维形式代码如下:
for(int i = 1; i <= n;i++){ for(int v = w[i]; v <= V;v++){
dp[v] = max(dp[v],dp[v - w[i]] + c[i]);
}
}
11.8总结
当题目与序列或字符串(记为A)有关时,可以考虑把状态设计成下面两种形式,然后根据端点特点去考虑状态转移方程。
① 令dp[i]表示以A[i]结尾(或开头)的XXX。
②令dp[i][j]表示A[i]至A[j]区间的XXX。
其中XXX均为原问题的表述。