动态规划-从爬楼梯问题谈起

问题引出

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

基础解决办法-递归

拿到这个问题的第一眼,如果你没有学过动态规划,那么我相信你第一时间想到的办法是递归,既然要求n阶楼梯的所有方法,那么我们从最后一次爬楼来反推,最后一次爬楼要么爬两阶,要么爬一阶,各是一种方法,如果爬一阶,那么还剩n-阶要爬,如果爬两阶,那么还剩n-2阶要爬,以此类推,对于剩下的台阶,我们依旧可以进行这样的选择,由此我们可以得出n阶台阶的方法计算公式:
c l i m b S t a i r s ( n ) = c l i m b S t a i r s ( n − 1 ) + c l i m b S t a i r s ( n − 2 ) climbStairs(n) = climbStairs(n-1) + climbStairs(n-2) climbStairs(n)=climbStairs(n1)+climbStairs(n2)
我们发现这个公式就是斐波那契数列,对的,这个问题得本质就是斐波那契数列问题,如何求斐波那契数列呢?我们一开始学的计算斐波那契数列的方法就是递归,代码实现如下:

class Solution {
public:
    int climbStairs(int n) {
        if(n>2){
            return climbStairs(n-1)+climbStairs(n-2);
        }else{
            return n;
        }
    }
};

perfect! 代码非常简洁,但是问题解决了吗?我们来分析一下这个代码的复杂度,每一个函数中,需要递归调用两次自己,这有点像一个金字塔,

              f(n) 
        f(n-1)    f(n-2) 
    f(n-2)   f(n-3)   f(n-4) 
 f(n-3)  f(n-4)  f(n-5)   f(n-6) 
····································

我们需要计算出这个“金字塔”中的每一个元素,所以元素数量就是我们的复杂度,这个复杂度到底多少网上存在一定的争议,一部分人认为是 O ( 2 n ) O(2^n) O(2n),其实这个底数2是一个大致的值,因为另一部分人则认认真真计算出了具体的这个底数为 O ( ( 1 + 5 2 ) n ) O((\frac{1+\sqrt 5}{2})^n) O((21+5 )n),这里我们不对此进行辨别,究竟哪个对不重要,无论哪个,都是指数级的复杂度,对于小型问题确实不会有太大的区别,但是如果问题规模变大呢?例如n=100时,这个指数级复杂度的程序需要多久才能计算出来?

改进-动态规划

我们观察这个金字塔可以发现,其中有很多重复的地方,而且随着问题规模的变大,重复的计算也是指数级增加,如果把这个重复的计算过程优化掉,就能大大减小程序的复杂度,如何去掉呢?想要减少重复计算,我们就要把之前算过的储存起来,这种思路就是动态规划的第一种方法:备忘录法,也叫做自顶向下的动态规划。

动态规划

定义

动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
wiki百科动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。与分治法最大的差别是: 适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。

动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。

一般步骤:
(1)分析最优解的性质,并刻画其结构特征。
(2)递归的定义最优解。
(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值
(4)根据计算最优值时得到的信息,构造问题的最优解

通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

回到问题本身

既然有许多重复计算,我们可以使用一个备忘录,记录下我们计算过的值,方便后面使用,代码实现如下:

class Solution {
public:
    int climbStairs(int n) {
        if(n==2) return 2;
        else if(n==1) return 1;
        vector<int> ways(n+1);
        ways[1] = 1;
        ways[2] = 2;
        for(int i=3;i<=n;i++){
            ways[i] = ways[i-1]+ways[i-2];
        }
        return ways[n];
    }
};

这样一简化,我们的计算复杂度大大降低,但是其实这种并非“正宗”的动态规划,我们上面谈到动态规划的方法是什么?使用一个备忘录,记录下已经计算过的子问题,避免重复计算,我们上面写的代码的本质是我们推导出了整个过程中需要用到的所有子问题解,并全部求出来,但是实际工作中我们很难在问题求解开始前就知道我们的子问题的通式或者复用部分,那么动态规划的代码应该怎么写呢?**按照正常递归或者分治法逻辑进行代码编写,在求解每个子问题之前先在备忘录中进行查询,如果已经有了子问题的解,那么就直接使用,如果没有,那么计算出子问题的结果后把该子问题的解加入到备忘录中。**代码实现如下:

#include<unordered_map>
class Solution {
public:
    unordered_map<int,int> notemap;
    int climbStairs(int n) {
        if(notemap.find(n-1)==notemap.end()){
            notemap[n-1] = climbStairs(n-1);
        }
        if(notemap.find(n-2)==notemap.end()){
            notemap[n-2] = climbStairs(n-2);
        }
        return notemap[n-1]+notemap[n-2];
    }
};

这样更能直观体现出备忘录这种思想,但是这个代码的复杂度是高于上面的代码的,实际工程中,如果我们能描绘出子问题的通式或者子问题的规律,我们还是写上面的代码为好。

更进一步

既然我们能找到子问题的通式,那么我们能否进一步简化呢?备忘录固然已经很快了,但是备忘录里面的东西依旧只是子问题的解,我们能否绕过备忘录,把子问题的求解顺序改变一下,在一个子问题计算出结果后直接就直接使用掉,这样我们就不用存储这个子问题的解,怎么做呢?这种方法在动态规划中叫做状态转移方程法,因为使用状态转移方程重组了子问题的结构,从小子问题一步步推导出最后的结果,也叫做自底向上的动态规划
在本问题中,状态转移方程就是我们上面提到的
c l i m b S t a i r s ( n ) = c l i m b S t a i r s ( n − 1 ) + c l i m b S t a i r s ( n − 2 ) climbStairs(n) = climbStairs(n-1) + climbStairs(n-2) climbStairs(n)=climbStairs(n1)+climbStairs(n2)
既然求n要求n-1,求n-1又要用到n-2,那么我们不妨从1开始求,一直求到n,这样我们在一个时刻就只需要保存上一个子问题结果和上上一个子问题结果这两个int变量,减小了空间复杂度,代码实现如下:

class Solution {
public:
    int climbStairs(int n) {
        if(n<3) return n;
        int lastways = 1;
        int beforelastways = 2;
        int nowways = 3;
        for(size_t i=3;i<=n;++i){
            nowways = lastways + beforelastways;
            lastways = beforelastways;
            beforelastways = nowways;
        }
        return nowways;
    }
};

总结

动态规划 (dp) 是一种思想,并不一定要用来求解最优值(最大值或最小值),而且能够用来求解满足某种约束的答案。甚至高中阶段用到的排列组合其实就是动态规划一种。斐波那契数列是一个典型的 线性dp 问题。动态规划所说的 「最优子结构」就是问题能够分解为若干子问题,这些子问题可能存在重叠,然后就是子问题最优的时候原问题也一定最优。「后无效性」是说,动态规划的状态顶点之间,在逻辑上面是一个 『DAG 结构』,这是一个图,一个有向无环图(如果你觉得把逻辑结构想象为一个图有点抽象,可以尝试想想一些二分查找的过程逻辑上面等价于一个树),如果逻辑上面有环则将涉及一系列的破环技巧,比如插头 DP 等等。
有的网上博主提到 动态规划是一个带缓存的分治算法,这个观点是不对的,至少是不全面的。动态规划确实用到分治思想,但它与我们通过所说的『分治算法』有一点不一样,分治算法逻辑上面是一棵树,同一层的子问题是没有交集的,但是动态规划的子问题是一个有向无环图,是存在大量重叠的子问题的。简而言之,分治算法的目的是将大问题化为小问题逐个解决,动态规划是消除掉重复的子问题以达到优化计算的目的,
有些博主把我们最后提到的「这种方法叫做状态压缩」也是不对的,「状态压缩」是一种位运算技巧,此处用到的优化技巧叫做 「滚动数组」,即:使用一个滚动数组保存需要的历史数据,这个数组随着函数的计算不断丢弃以往的数据,保存新的数据,这个过程像是一个不断向前滚动的过程,因此叫做滚动数组。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值