一、动态规划是什么
动态规划是运筹学的一种最优化方法,一般用于 「求最值」 。而求解动态规划的核心问题就是 「穷举」 ,通过穷举所有可能性来计算最值。那么我们为什么不直接使用暴力穷举?为什么要用一堆 “最优子结构”、“状态转移”、“自顶向下”、“自底向上” 的概念把问题搞复杂?
原因在于动态规划类的问题一般都存在重叠子问题,如果暴力穷举会导致大量的重复计算,效率极其低下。例如斐波那契数列,如果采用暴力递归,
const fib = function(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n -2);
}
假设n = 20
,我们画出相应的递归树:
可以看到f(18)
在计算f(20)
和f(19)
时都计算了一次,f(17)
在计算f(19)
和f(18)
时也都计算了一次。
按照 「递归算法的时间复杂度 = 子问题个数 * 解决一个子问题需要的时间」 ,子问题个数等于递归节点的个数,也就是二叉树的节点总数,为指数级别O(2^n)
。然后解决一个子问题的时间,f(n) = f(n - 1) + f(n - 2)
,加法操作、时间O(1)
。 所以暴力递归的时间复杂度为O(2^n)
,效率很差。
二、动态规划的辅助:备忘录
很明显,递归树之所以低效的原因在于存在大量的重复计算,也就是上文讲到的 「重叠子问题」。
为了解决这个问题,我们可以每次计算出子问题的答案后,将答案记到 「备忘录」 里,这样后续我们遇到一个子问题时,先去备忘录里查一查,如果存在的话就不需要再计算了,直接拿来用就行。相当于对递归树剪枝了!
这时的时间复杂度 = 子问题个数 * 解决一个子问题需要的时间,其中子问题个数
O(n)
,解决一个子问题需要的时间O(1)
,所以 「带备忘录的递归算法」 时间复杂度为O(n)
。
这种 「带备忘录的递归算法」 的分析思路实际上就是 「自顶向下」 ,从规模较大的问题逐渐分解规模,直到分解到 「base case」(对斐波那契来说,base case就是f(0)
和f(1)
)。
三、动态规划的解题思路
动态规划借鉴了 「备忘录」 的思想(称为dp数组),但和自顶向下的思路不同,动态规划采用了 「冰火两重天」 的解决方法: 使用 「自顶向下」 的递归思想考虑分析动态规划最优子结构,然后用 「自底向上」 的方法解题。
动态规划的解题步骤主要有两步: 分解「最优子结构」 和 写「状态转移方程」 。
-
「最优子结构」: 动态规划问题一定能拆分成最优子结构,这样才能通过求解子结构的最值得到原问题的最值,通过自顶向下的递归思想分解最优子结构。注意,要符合最优子结构,子结构间必须相互独立,不能相互制约。
-
「状态转移方程」:
- 明确 「dp数组」 的含义
- 然后确定 「base case」,类似递归的终止条件,dp[0] ? dp[1]?。
- 明确 「状态」 和 「选择」 。
四、实例分析
我们来具体分析一个题目零钱兑换。
1. 自顶向下地递归分析,进而拆分最优子结构
假设我们要求总金额amount = 11
所需硬币的最少个数,因为我们有3
种不同面值的硬币[1, 2, 5]
,按照递归树自顶向下地分析,我们可以拆分成3种子结构:
(1)拿一枚面值为1
的硬币 + 总金额为10
所需硬币的最少个数,也就是dp[11] = dp[11 - 1] + 1
。注意:不要纠结于总金额10的最优解法未知,随着层层降解肯定会计算到的!
(2)拿一枚面值为2
的硬币 + 总金额为9
所需硬币的最少个数,也就是dp[11] = dp[11 - 2] + 1
。
(3)拿一枚面值为5
的硬币 + 总金额为6
所需硬币的最少个数,也就是dp[11] = dp[11 - 5] + 1
。
最后,我们验证一下子问题是不是相互独立的。因为题目中已经说明,各面值硬币的数量是没有限制的,当然独立!
2. 确定状态转移方程
(1)明确 「dp数组」 的含义: 「表示总金额为i
时所需硬币的最少个数」 。
(2)确定 「base case」(dp[0]) 。当amount
为0
时,算法返回0
。
其实这里比较灵活,你也可以设置成amount为0时返回0, 和amount为1时返回1
。
(3)明确 「状态」 和 「选择」。每选择一枚硬币,就会导致状态变化。
推导出 「状态转移方程」 dp[i] = Math.min(dp[i - coin1] + 1, dp[i - coin2] + 1, dp[i - coin3] + 1)
。其中coin1、coin2、coin3
就是题目给定的不同面值硬币的种数。
算法实现:
const coinChange = function(coins, amount) {
// 1. 创建备忘录
// 数组长度为(amount + 1),这样dp[i]就能对应总金额等于 i 时的最少硬币个数
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0; // 2. 确定 base case
// 3. 自底向上填充dp数组,dp[0]已经赋值,循环从1开始
for (let i = 1; i <= amount; i++) {
for (let coin of coins) { // 状态转移方程的变形
if (i >= coin) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount]; // 注意,因为dp可能没有具体值
};
时间复杂度:O(mn)。 其中m
是金额,n
是不同面值的种类数。
空间复杂度:O(m)。数组dp需要长度为总金额m
的空间。
参考blog