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 |
|
我们以f(6)为例现在把递归的过程画出来
我们发现在求解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 |
|
需要说明的是,这个例子只是一个入门的例子,实际上它不存在最优子结构的问题,而且也不需要长度为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 |
|
显然上面递归的处理方式需要重复计算很多子问题,画出递归调用的图就一目了然,由于该图和上一个问题的图很类似,这里就省略了。因此就创建一张表,把子问题的结果都记录下来,dp[i]表示走到第i个阶梯有多少种走法。按照套路,我们应该从小的阶梯数开始填表。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 |
|
同样,这里例子中也不存在最优问题。
3.3 数塔问题
问题描述:从顶部出发在每一个节点可以选择向下或者向右下走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大。
对于上图所示的数塔:最大值为379, 绿色的的数字就是被选择的节点。
这个问题不能使用贪心算法,请大家自己用三层的阶梯列举出反例。我们现在试着将这个问题分解成子问题,如下图所示。想求得最大值,我们只要选择的红色边框数塔最大值和蓝色边框数塔的最大值中更大的那个,然后加上32,就整个数塔的最大值。这样我们就将一个大的问题转化成了两个规小的问题,然后这两个规模较小的问题还可以继续分解成更小的子问题。根据这个思路,我们可以得到如下递归形式的代码。
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 |
|
上述代码能够得到正确的结果,但是我们发现计算大一点的数塔计算会很费时间,这主要是重复计算的问题,我们现在来分析一下为什么会出现重复计算的问题。
上图中的紫色边框数塔既存在于红色边框数塔中,也存在于蓝色边框数塔中,会重复计算两次。实际上,我们使用递归时重复计算的问题显然不止这一个,所以效率不高。为此我们应该创建一张和数塔形状一样的三角形表用来记录更小的数塔的最大值。我们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表的最下面一行就应该等于数塔表中的最下面一行。
按照定义,我们就可以填倒数第二行的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表的倒数第二行,如下图所示
有了倒数第二行,我们就可以推出倒数第三行,依次类推,我们就可以得到最上面table [0][0]的数值,它就表示了整个数塔的最大值。除了最大值,如果我们还需要知道走了哪些路径,我们还应该定义一个path表,在填table[i][j]时,同时填写path[i][j]。path[i][j]表示了以(i, j)为顶点的数塔的最大值是由两个子数塔(table[i-1][j]为顶点的数塔和table[i-1][j+1]为顶点的数塔)中的哪一个得到的。