何时能够使用动态规划
动态规划(Dynamic Programming, DP)与其说是一种算法,更准确地说是一种解决问题的思维方式,因为其并没有对所有相关问题抽象出一种通用的算法程序,而是要在解题时根据具体的问题运用动态规划的思想进行问题的建模并编码求解。因此在理解动态规划解题之前,首先要了解什么样的问题能够用动态规划的思想解决。
本质上来说,动态规划是一种更高效的递归算法的实现,所以在学习动态规划之前要对递归算法有比较深刻的理解。
在用朴素递归解决问题时,首先将目标问题分解成多个子问题,而在解决这些子问题时又被分解成更小的子问题……如此重复,直到分解到递归的边界为止。在分解过程中,同一个子问题可能会被多次重复解决。
动态规划的思想就是,每解决一个子问题,都将该子问题的结果保存起来,则每个子问题只需要被解决一次。那么,哪些递归算法可以用动态规划的思想去实现呢?最简单的方法就是看朴素递归的算法有没有将同一个子问题一次次重复计算(可以画一个简单的递归树,能够很直观地发现,如果同一个子问题在树中不断出现,并且不止一次地出现在非叶节点上,那么就可以判断该子问题被重复计算了)。
一般来说,动态规划是解决组合对象上优化问题的方法,这些对象的组成具有固定的从左到右的顺序。这类对象包括字符串、根树(rooted trees)、多边形和整数列等。 [1](这段话我也没太看明白,不知道是不是对原文有什么误解。不过既然是在书上看到的,先摘抄下来。)
如果要给动态规划的适用情况做更严谨的定义,那么具有以下性质的问题能够用动态规划的思想解决:[2]
- 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
- 无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
- 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
解题基本思路
- 确定状态。前文提到,对于每一个子问题都只计算一次,然后将其计算结果保存在一个表格中;对动态规划进行问题建模的第一步也是最困难的一步就是设计这个表格,实现时一般用一个整数数组dp[](进阶题也可能是一个二维数组)。dp[i]就代表着第i个子问题的状态,难点就在于这个dp[i]到底代表什么。一般情况下,问题问什么,dp[i]就代表什么,即迭代完成后的dp[N]就是问题的最终解,这种情况下dp[i]实际上就是子问题i的最优解。但是也有一些难题,迭代完成后的dp[N]需要进行进一步的计算才是最终问题的解,此时dp[i]就只是能导出子问题i最优解的状态值。因此dp[]数组称为状态数组更为合理。
- 确定边界值(初始状态)。由于动态规划解决的问题都是一些递归问题,那么也有递归问题中的边界值的问题。通常情况下是第一个或者最前面的两个子问题的最优解。
- 确定状态转移方程。即找出dp[i]与dp[i-1],dp[i-2]…的关系。
- 优化:如果dp[i]只与固定的前k项有关,那么对算法的空间复杂度进行优化,即只保存前k个状态,而无需保存完整的dp[]数组。
例题
斐波那契数列
基本的动态规划实现
斐波那契数列是经典的递归问题,递归式为 F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n) = F(n-1)+F(n-2) F(n)=F(n−1)+F(n−2),计算斐波那契数列的第 n n n项的朴素递归实现代码如下:
int fib_r(int n) {
if(n == 0) return