动态规划法

爬楼梯问题

在介绍动态规划算法之前,我们不妨先看一下小例子。相信学计算机的在读大学期间都遇到过这么一道题:青蛙一次只能蹦上1个或2个台阶,现在有10个台阶,请问青蛙上这10个台阶有多少种蹦法?当然不一定是青蛙,题目大致就是这个意思。
   我们来分析一下,假设青蛙现在还差一次就能到达第10个台阶,那么青蛙现在只能在第8个台阶上,或者第9个台阶上,也就是说,青蛙在第8个台阶上蹦2个台阶,或者在第9个台阶上蹦1个台阶。至于在第8个台阶上蹦1个台阶之后再蹦1个台阶,是考虑在后一种情况中。那么青蛙蹦上第10个台阶的蹦法即为:F(10) = F(8) + F(9)。依次类推,F(9) = F(7) + F(8), F(8) = F(6)+F(7)。那么我们可以得到一个通用公式:

                   F(N) = F(N-2) + F(N-1);   (N>2)
                   F(1) = 1;
                   F(2) = 2;

这是一个完全符合递归思想的问题,那么,接下来我们就可以编码了。递归解决爬楼梯问题的代码如下:

public class UpStairsDemo1 {
    public static int numsOfUpStairs(int n){
        if(n == 1){
            return 1;
        }else if(n ==2){
            return 2;
        }else if(n > 2){
            //F(N) = F(N-1) + F(N-2)
            return numsOfUpStairs(n - 1) + numsOfUpStairs(n - 2);
        }else return 0;
        
    }
    
    public static void main(String[] args) {
        System.out.println("nums of up the stairs: " + numsOfUpStairs(1));
        System.out.println("nums of up the stairs: " + numsOfUpStairs(2));
        System.out.println("nums of up the stairs: " + numsOfUpStairs(3));
        System.out.println("nums of up the stairs: " + numsOfUpStairs(10));
    }

}

结果如下:

nums of up the stairs: 1
nums of up the stairs: 2
nums of up the stairs: 3
nums of up the stairs: 89

结果分析

通过上述代码,表面上看起来我们的问题是解决了,但是不够完美。为什么呢?如果此时楼梯数,从10变成了100,那么以上代码就很难hold住了。
  为什么呢?本质上是由递归的缺点决定的:递归太深容易造成堆栈的溢出。递归写起来虽然很方便,代码结构层次清晰,而且可读性高,但是这些都不能遮盖住递归最大的缺点:太占资源。因为递归需要保护现场,由于递归需要系统堆栈,所以空间消耗要比非递归代码要大很多。而且,如果递归深度太大,系统很有可能是撑不住的。
我们来分析以上上述递归的执行过程:

                    F(10) = F(8) + F(9);
                    F(10) = F(6) + F(7) + F(7) + F(8);
                    F(10) = F(6) + F(7) + F(7) + F(6) + F(7);
                    F(10) = F(4) + F(5) + F(5) + F(6) + F(5) + F(6) + F(4) + F(5) + F(5) + F(6);
                    ........

最终的结果是:为了计算F(10), 需要计算1次F(9), 2次F(8), 3次F(7), 4次F(6), 5次F(5), 6次F(4)…..快写不下去了。通过分析我们知道,这种递归求解的时间复杂度达到了O(2^N)。F(N)的计算中存在大量重叠的子问题,可想而知,当N为100时,各个F(n)得计算多少次了。有没有办法让每个状态都只计算一次,然后将结果保存,用于下一次计算呢?这样既可以降低CPU的使用率,可以降低系统栈的开销,因为无需堆栈来保存递归的现场。答案是肯定的,动态规范算法就能很好地解决这种问题。

动态规划算法(Dynamic Programming)

定义(以下内容来自百度百科)

动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推或者分治的方式去解决。
  动态规划算法的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
  由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。

求解的基本步骤

以上述的“爬楼梯问题”为例,我们简单解释一下动态规划算法的过程。根据动态规划的思想,首先,将问题分段,过程如下:

  F(1) = 1;
  F(2) = 2;
  F(N) = F(N-2) + F(N-1);

我们从头开始按顺序求解子阶段。

  F(1) = 1;
  F(2) = 2;
  F(3) = F(1) + F(2) = 1 + 2 = 3;
  F(4) = F(2) + F(3) = 2 + 3 = 5;
  F(5) = F(3) + F(4) = 3 + 5 = 8;
  F(6) = F(4) + F(5) = 5 + 8 = 13;
  F(7) = F(5) + F(6) = 8 + 13 = 21;
  F(8) = F(6) + F(7) = 13 + 21 = 34;
  F(9) = F(7) + F(8) = 21 + 34 = 55;
  F(10) = F(8) + F(9) = 34 + 55 = 89;

好了,以上就是求解爬楼梯问题的详细过程。通过分析我们知道,通过递推,我们只需知道当前状态的前两个状态的结果即可推出当前状态的结果。即我们只需要的变量有:

  F(N) = F(N-2) + F(N-1);
  //这里F(N-2),F(N-1)是我们求解过程中的需要存储的中间变量。

代码实现

通过以上分析,我们给出针对“爬楼梯问题”的动态规划求解过程的代码,具体代码如下:

public class DynamicUpstairs {
    public static int numsOfUpStairs(int n){
        int temp1 = 1, temp2 = 2;
        if(n<1){
            return 0;
        }else if(n<3){
            return n;
        }else{
            int sum = 0;
            for(int i=3;i<=n;i++){
                sum = temp1 + temp2;
                temp1 = temp2;
                temp2 = sum;
            }
            return sum;
        }
            
    }
    
    public static void main(String[] args) {
        System.out.println("nums of up stairs : " + numsOfUpStairs(1));
        System.out.println("nums of up stairs : " + numsOfUpStairs(2));
        System.out.println("nums of up stairs : " + numsOfUpStairs(3));
        System.out.println("nums of up stairs : " + numsOfUpStairs(10));
    }

}

结果如下:

nums of up stairs : 1
nums of up stairs : 2
nums of up stairs : 3
nums of up stairs : 89

算法分析

这里我们通过保存前两次的结果,采用迭代递推的方式求解最终问题。很明显,时间复杂度为O(N), 空间复杂度为O(1)。相比较递归方法,既能避免递归堆栈过深可能导致的内存溢出,同时能大大降低求解问题所需的时间。
  “爬楼梯问题”只是动态规划法最简单的用例之一。动态规划法还有很多的应用场景,任何思想方法都有一定的局限性,超出了特定条件,它就失去了作用。同样,动态规划也并不是万能的。适用动态规划的问题必须满足最优化原理和无后效性。接下来说一下动态规划算法的适用条件:

  1. 最优化原理(最优子结构性质) 最优化原理可这样阐述:一个最优化策略具有这样的性质,不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略。简而言之,一个最优化策略的子策略总是最优的。一个问题满足最优化原理又称其具有最优子结构性质。
  2. 无后效性将各阶段按照一定的次序排列好之后,对于某个给定的阶段状态,它以前各阶段的状态无法直接影响它未来的决策,而只能通过当前的这个状态。换句话说,每个状态都是过去历史的一个完整总结。这就是无后向性,又称为无后效性。
  3. 子问题的重叠性 动态规划将原来具有指数级时间复杂度的搜索算法改进成了具有多项式时间复杂度的算法。其中的关键在于解决冗余,这是动态规划算法的根本目的。动态规划实质上是一种以空间换时间的技术,它在实现的过程中,不得不存储产生过程中的各种状态,所以它的空间复杂度要大于其它的算法。

最佳实践(参照百度百科–动态规划)

算法实现是比较好考虑的。但有时也会遇到一些问题,而使算法难以实现。动态规划思想设计的算法从整体上来看基本都是按照得出的递推关系式进行递推,这种递推相对于计算机来说,只要设计得当,效率往往是比较高的,这样在时间上溢出的可能性不大,而相反地,动态规划需要很大的空间以存储中间产生的结果,这样可以使包含同一个子问题的所有问题共用一个子问题解,从而体现动态规划的优越性,但这是以牺牲空间为代价的,为了有效地访问已有结果,数据也不易压缩存储,因而空间矛盾是比较突出的。另一方面,动态规划的高时效性往往要通过大的测试数据体现出来(以与搜索作比较),因而,对于大规模的问题如何在基本不影响运行速度的条件下,解决空间溢出的问题,是动态规划解决问题时一个普遍会遇到的问题。
  一般地说,这种方法可以通过两种思路来实现:一种是递推结果仅使用Data1和Data2这样两个数组,每次将Data1作为上一阶段,推得Data2数组,然后,将Data2通过复制覆盖到Data1之上,如此反复,即可推得最终结果。这种做法有一个局限性,就是对于递推与前面若干阶段相关的问题,这种做法就比较麻烦;而且,每递推一级,就需要复制很多的内容,与前面多个阶段相关的问题影响更大。另外一种实现方法是,对于一个可能与前N个阶段相关的问题,建立数组Data[0..N],其中各项为前面N个阶段的保存数据。这样不采用这种内存节约方式时对于阶段k的访问只要对应成对数组Data中下标为k mod (N+1)的单元的访问就可以了。这种处理方法对于程序修改的代码很少,速度几乎不受影响,而且需要保留不同的阶段数也都能很容易实现。

    原文作者:圈圈_Master
    原文地址: https://www.jianshu.com/p/3babdd1aedca
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞