Bootstrap

动态规划经典例题之详细解说

一 什么是动态规划

动态规划(Dynamic Programming,DP)是一种解决一类最优问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划会将每个求解过的子问题的解记录下来,这样但下一次碰到同样的子问题时,既可以直接使用之前纪录的结果,而不是重复计算。注意:虽然动态规划采用这种方式提高计算效率,但是不能说这种做法就是动态规划的核心。
一般可以使用递归或者递推的写法来实现动态规划,其中递归写法在此处有称作记忆化搜索

二 动态规划的递归写法

以斐波那契数列为例
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]={-1};
int F(int n)
{
if(n==0||n==1)return 1;//递归边界
if(dp[n]!=-1)return dp[n];//dp[n]已经计算过,直接返回结果,不用重复计算。
else
{
dp[n]=F(n-1)+F(n-2);//计算F(n),并保存至dp[n]
return dp[n];//返回F(n)结果
}
}
这样通过记忆化搜索,就把复杂度从O(2n)降到了O(n),让复杂度从指数级别降低到了线性级别

三 动态规划的递归写法之经典例题(关键是推出移动状态方程

1 树塔问题
1 树塔问题

​ 以经典的树塔问题为例,将一些数字排成树塔的形状,其中第一层有一个数字,第二层有两个数字……第n层有n个数字。现在要从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个,问:最后将路径上的所有数字相加后得到的和最大是多少?

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1000;
int f[maxn][maxn],dp[maxn][maxn];
int main()
{
  int n;
  cin>>n;
  for(int i=1;i<=n;i++)
    for(int j=1;j<=i;j++)
      cin>>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];//移动状态方程
  cout<<dp[1][1]<<endl;//dp[1][1]为所需的答案
  return 0;
}

输入图中数据:

5
5
8 3
12 7 16
4 10 11 6
9 5 3 9 4

输出数据:

44

​ 通过上面的例子引申出一个概念:如果一个问题的最优解可以由其子问题的最优解有效的构造出来,那么称这个问题拥有最优子结构,需要指出,一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决

1 最大连续子序列和

  给定一个数字序列A1,A2,…An,求i,j(1<=i<=j<=n),使Ai+…+Aj最大,输出这个最大和。

样例:

-2 11 -4 13 -5 -2
显然11+(-4)+13=20为和最大的选取情况,因此最大和为20

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=10010;
int A[maxn],dp[maxn];//A[i]存放序列,dp[i]存放以A[i]结尾的连续序列的最大和
int main()
{
  int n;
  cin>>n;
  fo人(int i=0;i<n;i++)
    cin>>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;
  cout<<dp[k]<<endl;
  return 0;
}

输入数据:

-2 11 4 13 -5 -2

输出结果:

20

如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划中最难的地方

3 最长不下降子序列

在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。

​ 例如,现有序列A={1,2,3,-1,-2,7,9}(从下标1开始),它的最长不下降子序列是{1,2,3,7,9},长度为5。另外,还有一些子序列是不下降子序列,比如{1,2,3}、{-2,7,9}等,但都不是最长的。

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=100;
int A[maxn],dp[maxn];
int main()
{
  int n;
  cin>>n;
  for(int i=1;i<=n;i++)
    cin>>A[i];
  int ans=-1;//记录最大值
  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[i]=dp[j]+1;
    ans=max(ans,dp[i]);
  }
  cout<<ans<<endl;
  return 0;
}

4 最长公共子序列(LCS)

​ 给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)。

​ 如字符串“sadstory”与“adminsorry”的最长公共子序列为“adsory”,长度为6。

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=100;
char A[maxn],B[maxn];
int dp[maxn][maxn];
int main()
{
  int n;
  gets(A+1);//下标从1开始读入
  gets(B+1);
  int lenA=strlen(A+1);//由于读入时下标是从1开始,因此长度也是从+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][j-1],dp[i-1][j]);
  cout<<dp[lenA][lenB]<<endl;//为结果
  return 0;
}

输入数据:

sadstory
adminsorry

输出结果:

6

5 最长回文数(难度较大)

最长回文子串的问题描述:

给出一个字符串S,求S的最长回文子串的长度。

样例:

字符串“PATZJUJZTACCBCC"的最长回文子串为"ATZJUJZTA",长度为9。

可能有些读者把这个问题这样求解:把字符串S倒过来变成字符串T,然后对S和T进行LCS模型求解,而事实上这种做法是错误的,因为一旦S中同时存在一个子串和它的倒序,那么答案就会出错。例如字符串S=“ABCDZJUDCBA",将其倒过来后就会变成T=“ABCDUJZDCBA",这样得到的最长公共子串为“ABCD”,长度为4,而事实上S的最长公共子串长度为1。因此这样的做法是不行的。

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1010;
char S[manx];
int dp[maxn][maxn];
int main()
{
  gets(S);
  int len=strlen(S),ans=1;
  memset(dp,0,sizeof(dp));//dp初始化为0
  //边界
  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]dp[j-1]==1)
      {
        dp[i][j]=1;
        ans=l;//更新最长回文子串长度
      }
    }
  }
  cout<<ans<<endl;
  return 0;
}

6 背包问题(多阶段动态规划问题)

A 01背包问题
  有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件。

样例:

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的背包中所能获得的最大价值。怎么求解dp[i-1[v呢?

考虑对第i件物品的选择策略,有两种策略:

1,不放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,也即dp[i-1][v。

2,放第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)
for(int i=1;i<=n;i++)
for(int v=w[i];v<=V;v++)
dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]]+c[i]);

//或者化成一维的数组
dp[v]=max{dp[v],dp[v-w[i]]+c[i]}
(1<=i<=n,w[i]<=v<=V)
for(int i=1;i<=n;i++)
for(int v=V;v>=w[i];v--)//逆序枚举v
dp[v]=max(dp[v],dp[v-w[i]]+c[i]);

特别说明:如果是用二维数组存放,v的枚举是顺序还是逆序都无所谓;如果使用一维数组存放,则v的枚举必须是逆序!

完整的求解01背包问题的代码如下:

#include<bits/stdc++.h>
using namespace std;
const int maxn=100;//物品最大件数
const int maxv=1000;//v的上限
int w[maxn],c[maxn],dp[maxn];
int main()
{
  int n,V;
  cin>>n>>V;
  for(int i=1;i<=n;i++)
    cin>>w[i];
  for(int i=1;i<=n;i++)
    cin>>c[i];
  //边界
  for(int v=0;v<=V;v++)
    dp[v]=0;
  for(int i=0;i<=n;i++)
    for(int v=V;v>=w[i];v++)
      //状态转移方程
      dp[v]=max(dp[v],dp[v-w[i]]+c[i]);
  //寻找dp[0……V]中最大的即为答案
  int max=0;
  for(int v=1;v<=V;v++)
    if(dp[v]>max)
      max=dp[v];
  cout<<max<<endl;
  return 0;
}

输入样例数据:

5 8
3 5 1 2 2 
4 5 2 1 3

输出结果:

10
B 完全背包问题
  有n种物品,每种物品的单件重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内的总价值最大。其中每种物品都有无穷件。

​ 可以看出,完全背包问题和01背包问题的唯一区别就在于:完全背包的物品数量每种都有无穷件,选取物品时对同一件物品可以选1件、2件……只要不超过容量V即可,而01背包的物品件数每种只有1件。

​ 同样令dp[i][v表示前i件物品恰好放入容量为v的背包中能获得的最大值。

​ 和01背包一样,完全背包问题的每件物品都有两种策略,但是也有不同点。对第i件物品来说:

1 不放第i件物品,那么dp[i][v=dp[i-1[v,这步骤跟01背包是一样的。
2 放第i件物品,这里的处理和01背包有所不同,因为01背包的每个物品只能选择一个,因此选择第i件物品就意味着必须转移到dp[i-1][v-w[i))这个状态,而完全背包是转移到dp[i)[v-w[i)),这是因为每种物品可以放任意件(但是由于容量的限制,因此还是有限的),放了第i件物品后还可以继续放第i件物品,直到第二维的v-w[i)无法保持大于等于0为止。

状态转移方程差别如下:

//二维形式
for(int i=1;i<=n;i++)
for(int v=w[i];v<=V;v++)
dp[i][v]=max(dp[i-1][v],dp[i][v-w[i]]+c[i])
边界:dp[0][v]=0(0<=v<=V)

//写成一维形式与01背包完全相同,唯一的区别在于这里v的枚举顺序是正向枚举,而01背包必须是逆向枚举。
for(int i=1;i<=n;i++)//正向枚举v
for(int v=w[i];v<=V;v++)
dp[v]=max(dp[v],dp[v-w[i]]+c[i]);
;