记录一下《算法导论》里关于动态规划的一些知识点以及自己的想法。
动态规划
动态规划是通过组合子问题来求解原问题的一种算法。动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题(子问题的求解是递归进行的,将其划分为更小的子子问题)。这种情况下,动态规划算法对每个子子问题只求解一次,将其解保存在一个表格中,从而无需每次求解一个子子问题时都重新计算,避免了不必要的计算工作。动态规划通常用来求解最优化问题。
设计一个动态规划算法通常可以分为四步
- 刻画一个最优解的特征值
- 递归定义最优解的值
- 计算最优解的值,通常采用自底向上的方法
- 利用计算出的信息构造出一个最优解
步骤1~3是动态规划算法求解问题的基础。如果我们仅仅需要一个最优解的值,而非解本身,可以忽略步骤4.如果确实需要步骤4,有时候需要在执行步骤3的过程中维护一些额外信息,以便用来构建一个最优解。
动态规划原理
前面提到了动态规划通常是用来求解“最优化问题”,那么具体什么样的问题适合使用动态规划来求解呢? 适合应用动态规划方法求解的最优化问题应该具备两个要素:最优子结构和子问题重叠。
最优子结构:
用动态规划方法求解最优化问题的第一步就是刻画最优解的结构。如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。但也因此,我们必须小心确保考察了最优解中用到的所有子问题。在发掘最优子结构性质的过程中,实际上遵循了如下的通用模式:
- 证明问题最优解的第一个组成部分是做一个选择,这次选择会产生一个或多个待解决的子问题。
- 对于一个给定问题,在其可能的第一步选择中,你假定已经知道哪种选择才会得到最优解。你现在并不关心这种选择具体是如何得到的,只是假定已经知道了这种选择。
- 给定可获得最优解的选择后,你确定这次选择会产生哪些子问题,以及如何最好地刻画子问题空间。
- 作为构成原问题最优解的组成部分,每个子问题的解就是它本身的最优解。
重叠子问题:
适用动态规划方法求解的最优化问题应该具备的第二个性质是子问题空间必须足够“小”,即问题的递归算法会反复地求解相同的子问题,而不是一直生成新的子问题。一般来讲,不同子问题的总数是输入规模的多项式函数为好。如果递归算法反复求解相同的子问题,我们就称最优化问题具有重叠子问题性质。
一些可以使用动态规划求解的题目:
钢条切割:
这道题应该是动态规划方面最经典的题目了,题目是这样的:某公司购买了长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。假定我们知道出售一段长为i英寸的钢条的价格为pi,钢条的长度均为整英寸。给出一个价格表的样例如下:
长度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
价格pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
给定一段长度为n的钢条和一个价格表,求切割方案使得销售收益达到最大。注意,如果长度为n英寸的钢条的价格pn足够大,那么最优解可能就是完全不需要切割。
我们可以使用这样一种思路来思考这个问题:当完成首次切割后,将两端钢条看成两个独立的钢条切割问题实例。我们通过组合这两个相关子问题的最优解,并在所有可能的两段切割方案中选择收益最大者,来构成我们原问题-----对长度为n的钢条切割的最优解。即长度为n的钢条的最大收益为:
Rn = Max(Pn, R1+R(n-1), R2+R(n-2) ...... , R(n-1) + R1)
上述算式中Rn代表长度为n的钢条的最大收益,如R2+R(n-2)即代表长度为2的钢条的最大收益与长度为n-2的钢条的最大收益的和。而我们获得长度为n的钢条的最大收益的方式就是在所有可能的两段切割方案中选择收益最大者。即上述一系列最优解的组合的最大值。
使用动态规划方法求解最优钢条切割问题有两种方法。
带备忘的自顶向下法:
此方法仍按自然的递归形式编写过程,但过程会保存每个子问题的解。当需要一个子问题的解时,过程首先减产是否已经保存过此解,如果是则直接返回保存的值,从而节省了计算时间。下面给出这种方法的伪代码:
1 MEMOIZED-CUT-ROD(p, n) 2 { 3 let r[0...n] be a new array 4 5 for(int i = 0; i <= n; i++) 6 { 7 r[i] = -∞; 8 } 9 10 return MEMOIZED-CUT-ROD-AUX(p,n,r) 11 } 12 13 MEMOIZED-CUT-ROD-AUX(p,n,r) 14 { 15 if(r[n] >= 0) 16 { 17 return r[n]; 18 } 19 20 if(n == 0) 21 { 22 q = 0; 23 } 24 else 25 { 26 q = -∞; 27 28 for(int i = 1; i<= n; i++) 29 { 30 q = MAX(q, p[i] + MEMOIZED-CUT-ROD-AUX(p,n-i,r)) 31 } 32 } 33 34 r[n] = q; 35 36 return q; 37 }
这里首先把辅助数组r[0...n]的元素初始化为-∞。这里其实是为了将这个对应的值标记为“未知”,因为我们确定收益总是非负值,因此这样一个值可以确定实际上它还没有被计算过。在MEMOIZED-CUT-ROD-AUX(p,n,r)过程中先检查所需要的值是否已知,如果已知则直接返回,否则计算所需的值,然后记录下来。注意28~31行,其实就是我们之前提到的思路,在所有可能的两段切割方案中选择收益最大者。
自底向上法:
这种方法一般需要恰当的定义子问题“规模”的概念,使得任何子问题的求解都只依赖于“更小的”子问题的求解。因此我们可以将子问题按规模排序,按由小到大的顺序进行求解。当求解某个子问题的时候,它所依赖的那些更小的子问题都已求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它时,它的所有前提子问题都已求解完成。下面给出这种方式的伪代码:
1 BOTTOM-UP-CUT-ROD(p,n) 2 { 3 let r[0...n] be a new array 4 r[0] = 0; 5 6 for(int j = 1; j <= n; j ++) 7 { 8 q = -∞; 9 10 for(i = 1; i <= j; i++) 11 { 12 q = max(q, p[i] + r[j-i]); 13 } 14 15 r[j] = q; 16 } 17 18 return r[n]; 19 }
这种方法采用子问题的自然顺序:若i<j,则规模为i的子问题比规模为j的子问题“更小”。因此依次求解。
另:笔者第一次看到这里时有一个疑问,就是按上述的方式对应我们这道题目中给出的长度与价格的表格,如果长度大于10会怎样?p[i]是会报error超出索引边界的。实际上,要想将上面的自底向上的方法实际运用,还需要额外有一部分逻辑,就是更新p[]数组,以此来保证在遇到“更大”规模的问题时依然可以使用已求出的“更小”的规模的解。
下面附上两个LeetCode的动态规划的题目:
来源:力扣(LeetCode),传送门
53. 最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
1 public class Solution { 2 public int MaxSubArray(int[] nums) 3 { 4 int maxValue = nums[0]; 5 6 for (int i = 1; i < nums.Count(); i++) 7 { 8 if (nums[i - 1] > 0) 9 { 10 nums[i] += nums[i - 1]; 11 } 12 13 maxValue = Math.Max(maxValue, nums[i]); 14 } 15 16 return maxValue; 17 } 18 }
这道题其实需要想明白一个点,一旦想通了就可以理解上面的代码了。即上面的代码其实是求出了这个数组nums每个位置上所能获得的最大子树组的和。换句话说,比如index为0,那么这个位置上的最大和为nums[0]。又比如index为3,那么最大和就是Max(nums[0]+nums[1] + nums[2] + nums[3], nums[1] + nums[2] + nums[3], nums[2] + nums[3], nums[3])。
用动态规划的思路来解释是,每个位置可以取得的最大值实际上是和它本身的值以及它前一个位置所能取得的最大和的值密切相关的。
70.爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
1 public class Solution { 2 public int ClimbStairs(int n) 3 { 4 if (n == 0) 5 { 6 return 0; 7 } 8 9 if (n == 1) 10 { 11 return 1; 12 } 13 14 var n_Count = new int[n]; 15 16 n_Count[0] = 1; 17 n_Count[1] = 2; 18 19 for (int i = 3; i <= n; i++) 20 { 21 n_Count[i - 1] = n_Count[i - 2] + n_Count[i - 3]; 22 } 23 24 return n_Count[n_Count.Length - 1]; 25 } 26 }
这道题的思路是,要到达n这一层实际上有两种方式,即从n-1爬1阶,或是从n-2爬两阶。因此到达n这一层的所有方法的和就是到n-1层的所有方法的和 + 到达n-2层所有方法的和。
动态规划还是有很多有趣的题目,如最长公共子序列,矩阵乘法啊等等,篇幅受限就不记录了。