动态规划
本部分解决以下几个问题:
动态规划是什么?解决动态规划问题有什么技巧?如何学习动态规划?
- ⾸先,动态规划问题的⼀般形式就是求最值。
既然是要求最值,核⼼问题是什么呢?求解动态规划的核⼼问题是穷举。具体而言
- 第一,动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果暴⼒穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
- 第二,动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得到原问题的最值。
- 另外,虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化,穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅程」,才能正确地穷举。
以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。但是在实际的算法问题中,写出状态转移⽅程是最困难的,可以根据以下思维框架辅助你思考状态转移⽅程:
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
按上⾯的套路⾛,最后的结果就可以套这个框架:
下⾯通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明⽩什么是重叠⼦问题(斐波那契数列没有求最值,所以严格来说不是动态规划问题),后者主要举集中于如何列出状态转移⽅程。
509. 菲波那切数
解法:dp
1、暴力递归
斐波那契数列的数学形式就是递归的,写成代码就是这样:
递归树如下:
由上图我们可以发现,在暴力递归求解过程会重复计算许多子问题,例如求解左右子树里的f(18)。这就是动态规划问题的第⼀个性质:重叠⼦问题。下⾯,我们想办法解决这个问题。
2、带备忘录的递归解法
我们可以造⼀个「备忘
录」,每次算出某个⼦问题的答案后别急着返回,先记到「备忘录」⾥再返回;每次遇到⼀个⼦问题先去「备忘录」⾥查⼀查,如果发现之前已经解决过这个问题了,直接把答案拿出来⽤,不要再耗时去计算了。
⾄此,带备忘录的递归解法的效率已经和迭代的动态规划解法⼀样了。实际上,这种解法和迭代的动态规划已经差不多了,只不过这种⽅法叫做「⾃顶向下」,动态规划叫做「⾃底向上」。
3.dp数组的迭代解法
有了上⼀步「备忘录」的启发,我们可以把这个「备忘录」独⽴出来成为⼀张表,就叫做 DP table 吧,在这张表上完成「⾃底向上」的推算。
class Solution:
def fib(self, n: int) -> int:
if n == 0:
return 0
res = [0] * (n+1)
res[0] = 0
res[1] = 1
for i in range(2, n+1):
res[i] = res[i-1] + res[i-2]
return res[n]
这⾥,引出「状态转移⽅程」这个名词,实际上就是描述问题结构的数学形式:
322. 零钱兑换
解法:动态规划
⾸先,这个问题是动态规划问题,因为它具有「最优⼦结构」的。要符合「最优⼦结构」,⼦问题间必须互相独⽴。
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移⽅程?需要明确以下内容:
得到的状态转移方程如下:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [float('inf')] * (amount + 1)
# base case
dp[0] = 0
# 外层 for 循环在遍历所有状态的所有取值
for x in range(0, amount + 1):
# 内层 for 循环在求所有选择的最⼩值,完成状态转移过程
for coin in coins:
# 子问题无解,跳过
if x - coin < 0:
continue
dp[x] = min(dp[x], dp[x - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
总结
- 第⼀个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」的⽅法来优化递归树,并且明确了这两种⽅法本质上是⼀样的,只是⾃顶向下和⾃底向上的不同⽽已。
- 第⼆个凑零钱的问题,展示了如何流程化确定「状态转移⽅程」,只要通过状态转移⽅程写出暴⼒递归解,剩下的也就是优化递归树,消除重叠⼦问题⽽已。
- 计算机解决问题其实没有任何奇技淫巧,它唯⼀的解决办法就是穷举,穷举所有可能性。算法设计⽆⾮就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。
更加详细的内容可以参考动态规划解题核心框架