动态规划入门详解 内含12道经典动态规划编程题

动态规划入门详解

一 什么是动态规划??

算法导论中介绍,动态规划和分治方法类似,都是听过子问题的解来解决原问题。下面说一下这2者之间的分别,分治方法将原问题划分为互不相交的子问题,而后将子问题组合之后求出原问题的解,而动态规划是相反的,动态规划利用子问题之间的重叠,写出原问题和子问题之间的状态转移方程,转化为更小的子问题的求解,直到到达边界条件为止,有点类似于递归。

①动态规划和分治的区别 分治的子问题是不重叠的 如归并排序 ,快排 动态规划是有重叠的
②贪心和动态规划的区别 贪心是自顶向下的 ,不会等子问题求解完毕后求原问题

二 动态规划问题求解的一般的步骤 (具体的步骤千变万化,但遵循的思路大体一致)

首先 要知道动态规划是用来解决最优解的(最大,最小,最长等问题)

1 将原问题转化为一个个小问题,而且每一个小问题之间是有重叠的,而且这些小问题满足最优的子结构 (就是这些小问题对后面的没有重叠的影响 且 这小问题的最优解的组合可以原问题的最优解)
2用数组dp存这些小问题的答案,确定存的内容是什么 可以比较直观的解题,dp中的状态必须有无后效性,意思就是已经记录的状态不会发生改变,而且而且未来的状态只能在现在的状态中组合产生。(就是转移方程)
3找到这些小问题的边界,注意保证,在这个边界下,大问题求解的每一步所要用到的小问题的结果都是已经存入数组dp里,否则无法保证最后问题求解的准确性,(2,3一般是一起完成的)
4找到原问题和小问题之间的联系,,建立状态转移方程,
5先写出赋值边界的代码,而后写出状态转移的代码
……

下面用题目来看动态规划
题目1 第一题较为简单和直观
斐波那契数列 f0=1,f1=1,f2=2,f3=2 f4=5…… fn=f(n-1)+f(n-2)
求fn的递归写法

int f(n)
{
	if(n==0 || n==1)  return 1;
	else return f(n-1)+f(n-2);
}

递归写法很简单1 但是效率很低很低,因为当n变大时,存在很多重复的计算 例如 求f10
求f10 那就要求f9 和f8 求f9 要求f8和f7 求f8要求f7和f6 …… 可以看出 f8已经求了2次了 易得越小的n求得重复的次数越多,因为每一个大的数,都是通过小的得到的

下面看一下 动态规划的解法 因为fn由相邻的2个数得到 那就用dp[n] 存所有的已经计算出来的f【n】的结果 dp【n】== -1表示没有计算

int f(n)
{
	if(n==0 || n==1) return 1;
	if(dp[n] != -1)  return dp[n];
	else{
		dp[n]=dp[n-1] +dp[n-2];  //关键语句
		return dp[n];
	}
	
}

乍一看这和递归没有什么区别;看关键语句,这里的求法,其实和递归是完全不一样的 因为这里用的是数组里面的值的相加就OK了
而递归用的调用函数去求解 ,在动态规划中,每一个fn的值求一次就被记录了 之后用到就直接用,而递归是每一次都要求一遍 时间复杂度是完全不一样的
相当于用一个On的空间,把指数级的时间复杂度降到了线性级 这是很合算的。。。。。。。。。。。。。

题目二 数塔问题
先看题目:如下图(图片来自百度图片)是一个数塔,从顶部出发在每一个节点可以选择向左或者向右走,一直走到底层, 求找出一条路径,使得路径上的数字之和最大

动态规划入门详解 内含12道经典动态规划编程题_第1张图片
显然 用f[i][j]表示i行 j个 ,从1开始
那么f【1】【1】的最大和 就是max(下面2个的最大和)+ f[1][1]; 显然可以划分为子问题 而且有重复存在
那么令dp【i】【j】记录到 f【i】【j】的最大值和
则边界条件为最下层的dp值 所有的上层可以由下层得到
状态转移方程: dp【i】【j】= max(dp【i+1】【j】 ,dp【i+1】【j+1】)+f【i】【j】
代码如下

#include 
#include 
#include 
using namespace std;

const int maxn =1010;
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层
    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;
}

这个问题中,求dp【1】【1】转化为求子问题的最优解 而且可以由子问题的最优解的得到原问题的解 所以可解

题目三 最大连续子序列和
题目描述:给定一个数字序列A1,A2,…,An,求i,j(1≤i≤j≤n),使得Ai+,…,+Aj最大,输出这个最大和。
例子
-2 11 -4 13 -5 -2

显然11+(-4)+3=20为和最大的选值情况,因此最大和为20
解题思路:用dp【i】存以i为结尾的最大的的子序列的和 则每一个dp【i】和dp【i-1】有关 dp【i】=max(dp【i-1】+A【i】,A【i】)
因为是连续的 要么就是加上前一个之后更大,要么就是自己单独一个 那么dp中最大的那个就是答案,因为每一个都和前一个
相关,所以边界就是第一个 即dp【0】。到此,边界和状态转移方程都有了
代码如下

#include 
#include 
#include 
using namespace std;

const int maxn=10010;
int A[maxn],dp[maxn];
int main()
{
    int n;
    cin>>n;
    for(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]);
    }
    int k=0;
    for(int i=0;i<n;i++)
    {
        if(dp[i]>dp[k])
            k=i;
    }
    printf("%d",dp[k]);
    return 0;
}

对于本题 ,我们可以看出如何设置状态和状态转移方程才是动态规划最难的地方,在不同的题目中是千变万化的,只有不停的积累的练习才能掌握,至少现在我是这么理解的。

题目四 最长不下降子序列

  1. 问题描述:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降的(非递减的)

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

本题算是上一题的进化版 因为可以不连续,所以dp【i】和之前的所有的dp都有关系 且每一个dp确定之后是不会变的
所以可以用动态规划求解
所以边界是每一个自己是一个序列 dp【i】=1;
状转移方程:dp【i】=max(1,dp【j】+1) j是在i之前的所有值的枚举
代码如下

#include 
#include 
#include 
using namespace std;

const int N=100;
int A[N],dp[N];
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]=1;//初始条件
        for(int j=1;j<i;j++)
        {
            if(A[j]<=A[i] && dp[j]+1 > dp[i])  //选最大的那个
            {
                dp[i]=dp[j]+1;
            }
        }
        ans=max(ans,dp[i]);
    }
    printf("%d",ans);
    return 0;
}

题目五 最长公共子序列
题目描述:给定两个字符串,求解这两个字符串的最长公共子序列(可以不连续)。比如字符串1:BDCABA;字符串2:ABCBDAB
则这两个字符串的最长公共子序列长度为4,最长公共子序列是:BCBA
就是求2个序列中 公共最多的子序列的长度 输出长度即可
这一题的难度又比前面2题要难 因为在本题中的 数组有2个,是公共的解 ,而且比较难想到用动态规划求解
步骤一 假设i,j分别表示数组1和数组2现在的位置 在求dp数组时,相同的i不同的j是可能会变化的 ,所以dp应该是二维的 设 dp【i】【j】,i是第一个数组的位置 j是第二个数组的位置
步骤二 本题很自然想到dp’中放的就是最长长度
步三 :易得 dp【i】【j】可dp【i-1】【j-1】得到 若A【i】==B【j】 那直接长度加一就好
若不相等,那是无法延长的 那就是dp【i】【j-1】 和dp【i-1】【j】中大的那一个
那边界就是dp【i】【0】和dp【0】【j】 0< i

代码如下

#include 
#include 
#include
#include 
using namespace std;

const int N=100;
char A[N],B[N];
int dp[N][N];
int main()
{
    int n;
    gets(A+1);//从下标1开始读入
    gets(B+1);
    int lena=strlen(A+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-1][j],dp[i][j-1]);
            }
        }
    }
    printf("%d",dp[lena][lenb]);//最后的就是答案
    return 0;
}

题目六 最长回文子串
给定一个字符串 s,找到 s 中最长的回文子串。 你可以假设 s 的最大长度为1000。
回文的主要特点就是 如果一个大字符串是回文 那么这个大字符串包含的小字符串也是回文 直到最后只有一个字符或2个一样的字符

对于字符串str,假设dp[i,j]=1表示str[i…j]是回文子串,那个必定存在dp[i+1][j-1]=1。这样最长回文子串就能分解成一系列子问题,可以利用动态规划求解了,需要一层层分解子问题 ,那么数组和数组中存的内容就确定了。接下来构造状态转移方程,
动态规划入门详解 内含12道经典动态规划编程题_第2张图片
因为边界是长度为1 和2的子串 所以按照长度递推(这就是动态规划的递推写法 从边界出发的原理)
代码如下

#include 
#include 
#include
using namespace std;
const int maxn=1010;

char S[maxn];
int dp[maxn][maxn];
int main()
{
    gets(S);
    int len = strlen(S),ans=1;//ans表示最长的长度
    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;
        }
    }
    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;
            }
        }
    }
    cout<<ans;
    return 0;
}

对于最长的回文串的问题 最好的求解算法是manacher算法 有兴趣可以自己查阅一下 (On级别的时复)

题目七 爬楼梯问题
题目来源LeetCode
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
易得 dp【n】=dp【n-1】+dp【n-2】‘’
代码如下

    int climbStairs(int n) {
        int dp[100];
        dp[1]=1;  //边界
        dp[0]=1;
        if(n<=1) return dp[n];
        else{
            for(int i=2;i<=n;i++)
            {
                dp[i]=dp[i-1]+dp[i-2];
            }
        }
        return dp[n];

题目八 爬楼梯问题进阶版
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 或3……或n 个台阶。你有多少种不同的方法可以爬到楼顶呢?
假设dp【i】表示总共为i阶的跳发
那么 dp【i】=dp【i-1】+dp【i-2】+……dp【1】+dp【0】;
dp【i-1】=dp【i-2】+… dp【1】+dp【0】;
所以 dp【i】=2dp【i-1】;这就是状态转移方程

代码如下

int climbStairs(int n) {
        int dp[100];
        int total=1;
        for(int i=1;i<n;i++)
        {
        	total=2total;
        }
        return total;

题目九
我们可以用21的小矩形横着或者竖着去覆盖更大的矩形。请问用n个21的小矩形无重叠地覆盖一个2*n的大矩形,总共有多少种方法?
思路
求f(i) 有2种情况 要么竖着放第一个 要么横着放第一个
竖着的话 f(i)=f(i-1) 横着放 f(i)=f(i-2)
所以 f(i)=f(i-1)+f(i-2);这就是斐波那契数列 代码就不写了哈哈哈哈哈

题目十
最长递增子串的扩展 众所周知,牛妹是一个offer收割姬,这次面试她遇到了这样的一个问题。 给了一个序列,让找出最长的“凸子序列” 何为“凸子序列”:数列中有一个xi,使得所有x0xi+1>xi+1>….>xn eg:12345431,是山峰序列,12345234不是山峰序列 注:单调递增或单调递减序列也算山峰序列;单独一个数是长度为1的山峰序列
思路:用两个dp数组分别从前往后求最长的递增子串与从后往前求。最后将两个dp数组相加减一取得的最大值就是结果。

#include
#include
#include
#include
#include
using namespace std;
const int Max = 1110;
int dp[Max],dp2[Max];
int A[Max];
int main(){
    fill(dp,dp+Max,1);
    fill(dp2,dp2+Max,1);
    int n;
    scanf("%d",&n);
    for(int i = 0;i<n;++i){
        scanf("%d",&A[i]);
    }
    for(int i = 0;i<n;++i){
        for(int j = 0;j<i;++j){
            if(A[i]>A[j]){
                dp[i] = max(dp[i],dp[j]+1);
            }
        }
    }
    for(int i = n-1;i>=0;--i){
        for(int j = n-1;j>i;--j){
            if(A[i]>A[j]){
                dp2[i] = max(dp2[i],dp2[j]+1);
            }
        }
    }
    int ans = 1;
    for(int i = 0;i<n;++i){
        ans = max(ans,dp[i]+dp2[i]-1);

    }
    printf("%d",ans);
    return 0;
}

背包问题

背包问题是动态规划中一个比较常见的问题 ,比较灵活多变 ,这里介绍比较简单的2种 01背包问题和完全背包问题
题目十一
01背包问题
有n个物品,它们有各自的体积w【】和价值c【】,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?其中每一种物品只有一件

用dp【i】【v】表示前i件物品 恰好放入容量为v的背包中所获得的最大的价值;
那么考虑 i时,有2中 ,c表示价值 w表示体积
①放入第i物品放入 那么转化为前 i-1 件物品 恰好放入容量为v-w【i】的背包的最优解,所以等于 dp【i-1】【v-w【i】】+c【i】
②不放入 那就是dp【i-1】【v】
所以转移方程 dp【i】【v】= {以上2种情况里大的那一个};
因此求解是 一直递推i-1 直到i=0,那边界就是dp【0】【v】=0;0<= v <=V;
因为dp【i】【v】表示的是恰好为v的情况,所以要枚举dp【n】【v】 0<= v <=V,才可以得到最优解 因为背包的体积不一定要全部用完
代码如下

//边界
for(int i=0;i<=v;i++)
{
    dp[0][i]=0;
}
for(int i=0;i<=n;i++){  //状态方程
    for(int v=w[i];v<=V;v++){ //注意v要从w【i】开始  不然后面-w【i】的那个数组会越界了
        dp[i][v]=max(dp[i-1][v],dp[i-1][v-w[i]] + c[i]);
    }
}

滚动数组优化背包问题

时间复杂度和空间复杂度都是是Onv,下面对空间复杂度进行优化;
由于在计算时dp【i】【v】时 只需要dp【i-1】【v】和dp【i-1】【v-w【i】】的数据 ,但是在计算dp【i+1】时所有dp【i-1】【】的数据就不用了 这样就可以开一个一维数组dp【v】,就是省掉【i】。但是在枚举v时 是要逆序的
为什么计算时要逆序枚举?计算顺序所决定!请看下图:
动态规划入门详解 内含12道经典动态规划编程题_第3张图片
如当i=1时,f(10)=max{f(10),f(10-v[1])+w[1]}=max{f(10),f(7)+w[1]}=max{0,4}=4

即f(10)依赖的是f(10)和f(7)的值,需要注意的是,此时的f(10)和f(7)是i=0时的f(10)和f(7),如果使用二维数组存储,无需担心覆盖问题,对体积的枚举逆序或正序都可,但是如果使用一维数组,若正序枚举会先计算f(7),那么,再计算f(10)时,i=0时的f(7)已被覆盖矣!

使用这样的以为数组的方法被叫做滚动数组,就是在求解的过程中,计算出一个的dp[i][v] 就把之前的dp[i-1][v]给覆盖了 因为求出i v之后,i-1的那个数组时不会再用掉了 因为求解时,要用到【i-1】【v】之前的数组 所以只能从右往左遍历 才能保证需要的左边的那个数组不被提前覆盖
优化后的代码如下

//边界
for(int i=0;i<=v;i++)
{
    dp[0][i]=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【v】表示的就是未更新的i-1
    }
}
int max=0;
for(int v= 0;v<=v;v++){  //找最大值  以为背包面积体积可能不会用完
	if(dp[v] >max)
	{ max=dp[v];}
}

以上就是关键的代码 完整的代码就不写了 加上输入输出和初始化而已。。。。。。。。。。。

注意:滚动数组的使用条件是每一层只和上一层有关 ,使用滚动数组的时候,确定i时,枚举v的时候,一定要逆序 ,原因如上。

题目十二 完全背包问题

有n个物品,它们有各自的体积w【】和价值c【】,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?其中每一种物品有无穷件。。。
和上一题的区别在于 其中的每种物品有无穷建
思想如下
用dp【i】【v】表示前i件物品 恰好放入容量为v的背包中所获得的最大的价值;
那么考虑 i时,有2中 ,c表示价值 w表示体积
①放入第i物品,这里的处理和01问题是不一样的 因为在01问题中 放入就代表必须转移到i-1中
但是在这里,放入之后还是可以继续放,那就不会到i-1 依旧停留在i 因为i是可以再放的,由容量限制 最终还是有限的
所以dp【i】【v】= dp【i】【v-w【i】】+c【i】
②不放入,和01问题显然是一样的 那就是dp【i-1】【v】
所以转移方程 dp【i】【v】= {以上2种情况里大的那一个};
边界就是dp【0】【v】=0;0<= v <=V;

同样用滚动数组优化

for(int i=0;i<=n;i++){  //状态方程
    for(int v=w[i];v<=V;v++){ 
        dp[v]=max(dp[v],dp[v-w[i]] + c[i]);  //后面的dp【v】表示的就是未更新的i-1
    }
}

优化之后你会发现 状态方程和01问题是一样的 ,这个很容易理解 但是存在的不同之处是对v的遍历必须是正序的,而01问题必须是逆序的。那么问题就来了 这是为什么呢????
其实这很好理解**,因为完全背包问题 用的是 dp[v-w[i]],;和dp[i-1][v] 是在dp【i】【v】的左边的数组 所以从左往右遍历 才能保证左边的数组存在。所以必须从左到右**、

**

总结

**:动态规划的问题其实和递归很像 一般情况下 自顶向下的递归的问题 都可以转化为自低向上的动态规划问题,一层层叠出最后的结果
边界就是递归是的出口条件 反过来递推而已 ,在此时,看求dp是用到哪些量 依据这个来进行空间复杂度的优化

若有2个字符串或者数组之间的问题 那一搬就用dp[i] [j]来表示A【i】到B【j】区间之间的问题 就是一个区间dp 公共子序列和回文串就是这样的,回文串的求解方式比较特别 这是需要记住的方法
最后,在做题时,一般用dp要考虑题目中的状态需要用几维来表示 每一维就是一个含义先定义弄清楚
之后就是对每一维采取恰好为i,恰好为j 或者前i 前j 然后找到边界 (一般是端点)

你可能感兴趣的:(动态规划入门详解 内含12道经典动态规划编程题)