目录
- 动态规划的递归写法和递推写法
- 典型例题
- 总结
1.动态规划的递归写法和递推写法
1.1 动态规划的递归算法
以斐波那契数列为例,用一般的递归写法写出如下代码:
int F(int n){
if(n==0||n==1) return 1;
else return F(n-1)+F(n-2);
}
但上面代码会产生许多的重复计算,为了避免重复计算,可以新开一个一维数组dp,用来保存已经计算过的结果,其中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];
}
}
得出一个结论:一个问题必须拥有重叠子问题,才能用动态规划解决。
1.2 动态规划的递推算法
以数塔问题为例。将一些数字排成数塔形状,其中第一层有1个数字,第二层有两个数字…第n层有n个数字。现在要从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个,问:最后路径上所有数字相加后得到的和最大是多少?
分析:令dp[i][j]表示从第i行第j个数字出发到达最底层的所有路径中能得到的最大和。要求出最大和,以第三行的7为例,要和最大,前面路径就必须最大,就是从第二行左边走到底层的路径(dp[2][1])或第二行右边走到底层的路径(dp[2][2])中选较大的一个加上第一行的数(f[1][1]),就是7这个数的最大路径,因此写出dp[3][2]=max(dp[2][1],dp[2][2])+f[1][1]。因此归纳出一个信息,要想求出dp[i][j],必须先求出他的子问题dp[i+1][j]和dp[i+1][j+1],意思就是走位置(i,j)的左下还是右下。得出状态转移方程dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[1][1];
。由观察得知,数塔的最后一层的dp值总是该元素本身,即dp[n][j]==f[n][j](1<=j<=n)
,为边界条件。
写出代码:
#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];
}
//递推,从下往上
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;
}
两种方法总结:使用递推算法计算方式是自底向上,即从边界开始,不断向上解决问题,直到解决了目标问题;而使用递归算法的计算方式是自顶向下,即从目标问题开始,把它分解成子问题的组合,直到分解至边界为止。
2. 典型例题
2.1 最大连续子序列和
题目:
分析:
步骤 1:令状态 dp[i] 表示以 A[i] 作为末尾的连续序列的最大和(这里是说 A[i] 必须作为连续序列的末尾)。
步骤 2:做如下考虑:因为 dp[i] 要求是必须以 A[i] 结尾的连续序列,那么只有两种情况:
- 这个最大和的连续序列只有一个元素,即以 A[i] 开始,以 A[i] 结尾。
- 这个最大和的连续序列有多个元素,即从前面某处 A[p] 开始 (p<i),一直到 A[i] 结尾。
对第一种情况,最大和就是 A[i] 本身。
对第二种情况,最大和是 dp[i-1]+A[i]。
于是得到状态转移方程:dp[i] = max(A[i], dp[i-1]+A[i]).
这个式子只和 i 与 i 之前的元素有关,且边界为 dp[0] = A[0],由此从小到大枚举 i,即可得到整个 dp 数组。接着输出 dp[0],dp[1],…,dp[n-1] 中的最大即为最大连续子序列的和。
代码:
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=100;
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[]
int k=0;
for(int i=1;i<n;i++){
if(dp[i]>dp[k]){
k=i;
}
}
printf("%d\n",dp[k]);
return 0;
}
2.2 最长不下降子序列
题目:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列不下降(非递减的)。
分析:令d[i]表示以a[i]结尾的最长不下降子序列长度,对a[i]来说有两种可能
- 如果存在a[i]之前的元素a[j]使得a[j]<a[i]且dp[j]+1>dp[i](意思是原长度加上dp[i]这个数之后长度可以加1,就加上这个数,就是更新dp[i]=dp[j]+1)
- 如果a[i]比之前的元素都比它大,a[i]就只好自己形成一条LIS,但是长度为1.
由上述分析可得:
状态转移方程为dp[i]=max(1,dp[j]+1) (j=1,2,…,i-1&a[j]<a[i])
边界条件是dp[i]=1,即假设每个元素自成一个子序列
代码:
#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn=100;
int a[maxn],dp[maxn];
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]=1; //边界条件
for(int j=1;j<i;j++){
if(a[i]>=a[j]&&dp[j]+1>dp[i]){
dp[i]=dp[j]+1;
}
}
ans=max(ans,dp[i]);
}
printf("%d",ans);
return 0;
}
2.3 最长公共子序列(可当做一个模板)
题目:给定两个字符串,求解这两个字符串的最长公共子序列(子序列可以不连续)。比如字符串1:sadstory;字符串2:adminsorry
则这两个字符串的最长公共子序列长度为6,最长公共子序列是:adsory
分析:令dp[i][j]表示字符串A的i号位和字符串B的j号位之前的最长公共子序列长度(下标从1开始)。如dp[4][5]是“sads”和"admin"的最长公共子序列长度。则有如下两种情况:
- 若a[i]==b[i],则字符串A和字符串B的最长公共子序列长度增加1位,即dp[i][j]=dp[i-1][j-1]+1.
- 若a[i]!=b[i],则dp[i][j]继承dp[i-1][j]和dp[i][j-1]中较大一个。比如dp[3][3]表示sad和adm的最长公共子序列长度,发现a[3]!=b[3],则继承sa与adm的最长公共子序列长度和sad和ad的最长公共子序列长度中较大的一个,即sad和ad的最长公共子序列长度=2.
可以得到状态转移方程:
边界条件是:dp[i][0]=dp[0][j]=0(0<=i<=n,0<=j<=m)
最终得到的dp[n][m]就是答案。
代码:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn=100;
char a[maxn],b[maxn];
int dp[maxn][maxn];
int main(){
int n;
gets(a+1); //gets用来读入字符串,从下标为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[i]){
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;
}
备注:cin不接受空格,TAB等键的输入,遇到这些键,字符串会终止,而gets()则接受连续的输入,包括空格,TAB。
2.4 最长回文子串
问题:给出一个字符串s,求s的最长回文子串的长度。
样例:字符串“PATZJUJZTACCBCC”的最长回文子串为“ATZJUJZTA”
分析:dp[i][j]表示 s[i] 到 s[j] 所表示的字串是否是回文字串,值只有0和1。
初始化长度1和2的值(边界条件):dp[i][i] = 1, dp[i][i+1] = (s[i] == s[i+1]) ? 1 : 0,然后长度L从3到len,满足的最大长度L即为所求的ans值。
递推方程:
当s[i] == s[j] : dp[i][j] = dp[i+1][j-1]
当s[i] != s[j] : dp[i][j] = 0
因为i、j如果从小到大的顺序来枚举的话,无法保证更新dp[i][j]的时候dp[i+1][j-1]已经被计算过(如先固定i0,枚举从j2开始,当求解dp[0][2]时,将会转换为dp[1][1],而dp[1][1]在初始化中可以得到;当求解dp[0][3]时,转换为dp[1][2],dp[1][2]也在初始化中得到;当求解dp[0][4]时,即求解dp[1][3],dp[1][3]未被记录过,导致无法求解)。因此不妨考虑按照字串的长度和子串的初始位置进行枚举,即第一遍将长度为3的子串的dp的值全部求出,第二遍通过第一遍结果计算出长度为4的子串的dp的值…这样就可以避免状态无法转移的问题。
代码:
#include<cstdio>
#include<cstring>
using namespace std;
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(s[i]==s[i+1]){
dp[i][i+1]=1;
ans=2; //ans记录最长回文字符串长度
}
}
//状态转移方程
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;
}
2.5 背包问题
2.5.1 0-1背包问题
问题:有n件物品,每件物品的重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每件物品只有一件。
样例:
5 8 //n==5 V=8
3 5 1 2 2 //w[i]
4 5 2 1 3 //c[i]
分析:
令dp[i][v]表示前i件物品(1<=i<=n,0<=v<=V)恰好装入容量为v的背包中所能获得的最大价值。考虑对第i件物品的选择策略,有两种情况:
- 不放入第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<=n<=n,w[i]<=v<=V)
边界条件为:dp[0][v]=0(0<=v<=V)(即前0件物品放入任何容量v的背包中只能获得价值0)
写出代码:
for(int i=1;i<=n;i++){
for(v=w[i];v<=V;v++){
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i]);
}
}
可以知道,时间复杂度和空间复杂度都是O(nv),空间复杂度还能再优化。
如图,注意到状态转移方程中计算dp[i][v]时总是只需要dp[i-1][v]左侧部分的数据(图中正上方和左上方),且当计算dp[i+1][]的部分时,dp[i-1]的数据又完全用不到了(只需用到dp[i][ ]),因此不妨直接开一个一维数组dp[v](即把第一维省去),枚举方向改变为i从1到n,v从V到0(逆序!!),这样状态转移方程就变成:
dp[v] = max(dp[v], dp[v-w[i]] + c[i])
写出代码:
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]);
}
}
这种技巧又称为滚动数组。特别说明:如果是用二维数组存放,v的枚举是顺序还是逆序斗舞所谓;如果使用一维数组存放,则v的枚举必须是逆序!
完整代码(使用滚动数组):
#include<cstdio>
#include<algorithm>
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=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[]最大即为答案
int ans=0;
for(int v=0;v<=V;v++){
if(dp[v]>ans){
ans=dp[v];
}
}
printf("%d\n",ans);
}
2.52 完全背包问题
问题:有n种物品,每种物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
分析:与0-1背包问题不同的地方在于每种物品都有无穷件。和0-1背包也分为两种情况讨论:
- 不放第i件物品,有dp[i][v]=dp[i-1][v];
- 放第i件物品,有dp[i][v]=dp**[i]**[v-w[i]]+c[i],这里和0-1的区别是dp[i],因为第i件物品可以无限放,直到v-w[i]<0.
可以写出状态转移方程dp[i][v]=max(dp[i-1][v],dp[i][v-w[i]]+c[i])
边界dp[0][v]=0;(0<=v<=V)
同样也可以写成一维
dp[v] = max(dp[v], dp[v-w[i]] + c[i])
边界dp[v]=0;(0<=v<=V)
和0-1背包不同的是,这里v要从左到右枚举,理解之前的0-1,这里看图可理解。