笨办法理解动态规划算法

本文详细介绍了动态规划的概念,通过菲波那契数列、走楼梯问题、数塔问题、零-壹背包问题、找零钱问题和最长公共子序列等经典案例,阐述了动态规划的解决思路和套路,即如何将问题拆解为子问题并避免重复计算。动态规划适用于具有最优子结构和无后效性的问题,能够有效地提高算法效率。
摘要由CSDN通过智能技术生成

1. 动态规划的基本思想

如果我们解决一个问题的时候能将一个大问题转换成一个或者若干个规模较小的同等性质的问题,当我们求解出这些小问题的答案后,大问题的答案很容易解决,对于这样的情况,显然我们可以递归(或者说分治)的方式解决问题。如果在求解这些小问题的过程中发现有些小问题我们需要重复计算多次,那么我们就干脆把已经求解过的小问题的答案记录下来放在一张表中,这样下次遇到这个小问题,我们只需要查表就可以直接得到结果,这个就是动态规划的白话讲解。动态规划的难点在于如何定义问题及子问题。

2. 笨办法的套路

1)如果可以将一个规模较大的问题转换成一个或若干个规模较小的子问题,也就是能找到递推关系,这个时候我们不妨先将程序写成递归的形式。

2)如果使用递归求解规模较小的问题上存在子问题重复求解的现象,那么我们就建立一张表(有可能这个表只有一行)记录需要重复求解的子问题。填表的过程和将大问题划分为子问题的方式相反,我们会从最简单的子问题开始填表。现在我们就利用这个套路解决下面这些经典的问题。

3. 利用套路解题

3.1 菲波那切数列

问题描述:菲波那契数列的定义f(n) = f(n-1) + f(n-2), 且f(1)=1, f(2) = 1,求f(n)的值。斐波那契数列的定义本身就是将大问题转换成两个同性质的子问题,所以我们可以直接根据定义写成递归形式。

 

01

02

03

04

05

06

07

08

09

10

11

12

public static int recursion(int n) {

     

    if (n < 0) {

        return 0;

    }

     

    if (n == 1 || n == 2) {

        return 1;

    }

     

    return recursion(n-1) + recursion(n-2);

}

我们以f(6)为例现在把递归的过程画出来

clip_image002

我们发现在求解F(6)时,需要求解F(2)四次,求解F(1)三次,求解F(3)三次,F(4)两次,所以说我们的算法的效率是很低的。提高效率的办法就是将F(1),F(2),F(3) ….的结果放在表中,下次要计算这些问题的时候我们直接从表中获取就好了,这就是一个最简单的动态规划的例子。现在我们按照套路,从最小的子问开始填表就好了。

01

02

03

04

05

06

07

08

09

10

11

12

13

14

public static int dynamic(int n) {

     

    int[] table = new int[n+1];

     

    table[1] = 1;

    table[2] = 1;

     

    /*从小到大填表*/

    for (int i = 3; i < table.length; i++) {

        table[i] = table[i-1] + table[i-2];

    }

     

    return table[n];

}

需要说明的是,这个例子只是一个入门的例子,实际上它不存在最优子结构的问题,而且也不需要长度为n+1的table数组,只需要两个变量即可(可以理解为动态规划的优化版本),而我们之所以这样讲解只是为了让大家从动态规划的角度去理解问题。

3.2 走楼梯问题

问题描述:总共有n个楼梯,每次可以走2个台阶,或者每次走3个台阶,或者每次走5个台阶,请问这n个楼梯总共有几种走法。

n个阶梯的问题,可以分解成三个小问题,即n-2个阶梯有几种走法,n-3个阶梯有几种走法,n-5个阶梯有几种走法,而n个阶梯的走法就是这三种走法的和。或者可以反过来思考,你已经站在最后一个台阶上了,那么到达最后一个台阶的情况只能是三种情况,最后一步恰好走2个台阶恰好到达,最后一步恰好走3个台阶恰好到达,最后一步恰好走5个台阶恰好到达。通过这个思想,我们就可以写出递归形式的代码。

01

02

03

04

05

06

07

08

09

10

11

12

public static int recursion(int n) {

     

    if (n < 0) {

        return 0;

    }

     

    if (n == 0) {

        return 1;

    }

     

    return recursion(n - 5) + recursion(n - 3) + recursion(n - 2);

}

显然上面递归的处理方式需要重复计算很多子问题,画出递归调用的图就一目了然,由于该图和上一个问题的图很类似,这里就省略了。因此就创建一张表,把子问题的结果都记录下来,dp[i]表示走到第i个阶梯有多少种走法。按照套路,我们应该从小的阶梯数开始填表。

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

public static int dynamic(int n) {

     

    int[] record = new int[n+1];

     

    record[0] = 1;

     

    for (int i = 0; i < record.length; i++) {

         

        int n2 = i - 2 >= 0 ? record[i-2] : 0;

        int n3 = i - 3 >= 0 ? record[i-3] : 0;

        int n5 = i - 5 >= 0 ? record[i-5] : 0;

         

        record[i] = n2 + n3 + n5;

    }

     

    return record[n];

}

同样,这里例子中也不存在最优问题。

3.3 数塔问题

问题描述:从顶部出发在每一个节点可以选择向下或者向右下走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大。

clip_image002[6]

对于上图所示的数塔:最大值为379, 绿色的的数字就是被选择的节点。

这个问题不能使用贪心算法,请大家自己用三层的阶梯列举出反例。我们现在试着将这个问题分解成子问题,如下图所示。想求得最大值,我们只要选择的红色边框数塔最大值和蓝色边框数塔的最大值中更大的那个,然后加上32,就整个数塔的最大值。这样我们就将一个大的问题转化成了两个规小的问题,然后这两个规模较小的问题还可以继续分解成更小的子问题。根据这个思路,我们可以得到如下递归形式的代码。

clip_image004

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

/*我们用一个二维数组的左下半数组表示数塔*/

public static int recursion(int[][] a){

    return recursion(a, 0, 0);

}

 

/*参数i表示所在的行,j表示所在的列*/

private static int recursion(int[][] a, int i, int j){

     

    /*

     * 当分解问题到最下一层时,

     * (a.length - 1, j)位置为顶点的数塔实际上数塔只有一个元素,

     * 直接返回

    */

    if (i == a.length - 1){

        return a[i][j];

    }

     

    /*求(i+1, j)位置为顶点的数塔最大值*/

    int r1 = recursion(a, i+1, j);

     

    /*求(i+1, j+1)位置为顶点的数塔最大值*/

    int r2 = recursion(a, i+1, j+1);

     

    /*返回(i,j)为顶点位置的数塔的最大值*/

    return Math.max(r1, r2) + a[i][j];

}

上述代码能够得到正确的结果,但是我们发现计算大一点的数塔计算会很费时间,这主要是重复计算的问题,我们现在来分析一下为什么会出现重复计算的问题。clip_image002[8]

上图中的紫色边框数塔既存在于红色边框数塔中,也存在于蓝色边框数塔中,会重复计算两次。实际上,我们使用递归时重复计算的问题显然不止这一个,所以效率不高。为此我们应该创建一张和数塔形状一样的三角形表用来记录更小的数塔的最大值。我们table表示这个表,表中table[i][j]位置的值表示以(i,j)为顶点的数塔的最大值。我们用a[i][j]表示数塔中第i行,第j列的值。那么table[i][j] = a[i][j] + Math.max(table[i-1][j], table[i-1][j-1])。按照套路,我们应该从最小的数塔开始填表。按照table[i][j]的定义,table表的最下面一行就应该等于数塔表中的最下面一行。

clip_image004[4]

按照定义,我们就可以填倒数第二行的dp[i][j]。

table[4][0] = 79 + Math.max(0, 71) = 150
table[4][1] = 69 + Math.max(71, 51) = 140
table[4][2] = 78 + Math.max(51, 82) = 160
table[4][3] = 29 + Math.max(82, 91) = 120
table[4][4] = 63 + Math.max(91, 64) = 154

填入到table表的倒数第二行,如下图所示

clip_image002[10]

有了倒数第二行,我们就可以推出倒数第三行,依次类推,我们就可以得到最上面table [0][0]的数值,它就表示了整个数塔的最大值。除了最大值,如果我们还需要知道走了哪些路径,我们还应该定义一个path表,在填table[i][j]时,同时填写path[i][j]。path[i][j]表示了以(i, j)为顶点的数塔的最大值是由两个子数塔(table[i-1][j]为顶点的数塔和table[i-1][j+1]为顶点的数塔)中的哪一个得到的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值