拨开迷雾:动态规划,你到底是什么神仙?
是不是每次听到“动态规划”四个字,都感觉脑仁儿有点疼?什么“最优子结构”、“重叠子问题”,听起来就像是某个神秘的武林秘籍,离我们凡人的世界好远。别担心,今天咱们就来好好聊聊这个东西,保证让你觉得它没那么高冷,甚至有点儿意思。
核心思想:拆解与重复利用,做个聪明的“懒人”
想象一下,你手里有一堆任务,想要用最省力气的方式完成。动态规划就像是咱们的大脑在处理这类问题时的一种“聪明的懒惰”策略。它不蛮干,而是先把大问题拆成一堆小问题,然后盯着这些小问题,看看能不能找到一些重复的规律。一旦发现了规律,就把它记下来(就像你把某个常用招式写在笔记本上),下次再遇到同样的小问题,就直接拿来用,不用从头再乎。
举个最最接地气的例子:爬楼梯
这可能是动态规划的入门级选手了。假设你要爬一个有 n 阶的楼梯,每次你可以选择爬 1 阶或者爬 2 阶。问你总共有多少种不同的爬法?
暴力破解(不推荐): 你可以想象自己一个一个地枚举所有可能的爬法,但楼梯越高,组合就越多,很快就会让你晕头转向。
动态规划的思路:
最简单的情况:
爬 1 阶:只有一种方法 (爬 1 阶)。
爬 2 阶:有两种方法 (爬 1 阶 + 爬 1 阶,或者直接爬 2 阶)。
推广到更高阶:
想爬到第 3 阶,你只能从第 2 阶上来 (爬 1 阶),或者从第 1 阶上来 (爬 2 阶)。所以,爬到第 3 阶的总方法数就是爬到第 2 阶的方法数 加上 爬到第 1 阶的方法数。
爬到第 4 阶,你只能从第 3 阶上来 (爬 1 阶),或者从第 2 阶上来 (爬 2 阶)。所以,爬到第 4 阶的总方法数就是爬到第 3 阶的方法数 加上 爬到第 2 阶的方法数。
你会发现一个规律:爬到第 i 阶的方法数 = 爬到第 i1 阶的方法数 + 爬到第 i2 阶的方法数。
这就是典型的动态规划的模式!
1. 分解问题 (Subproblem Decomposition): 将“爬到 n 阶”的问题分解成“爬到 n1 阶”和“爬到 n2 阶”的问题。
2. 重叠子问题 (Overlapping Subproblems): 你会发现,计算爬到第 4 阶需要知道爬到第 3 和第 2 阶,而计算爬到第 3 阶又需要知道爬到第 2 和第 1 阶。你看,爬到第 2 阶的方法数被计算了两次!动态规划就是把这些重复计算的结果“存”下来,避免重复劳动。
3. 最优子结构 (Optimal Substructure): 这是动态规划的另一个关键点。这里的“最优”不一定是最小值或最大值,而是指“问题可以通过其子问题的最优解来构建”。在爬楼梯的例子中,爬到第 n 阶的总方法数,就是由爬到第 n1 阶和爬到第 n2 阶的方法数简单相加得到的。如果问题是找“最少步数”,那么爬到第 n 阶的最小步数,就是从爬到第 n1 阶的最小步数再加一步,或者从爬到第 n2 阶的最小步数再加一步,两者取最小。
两种实现方式:填表法 (递推) 和记忆化搜索 (递归)
知道了上面那个规律,我们就可以用两种方法来实现这个“聪明的懒惰”:
1. 填表法 (BottomUp / Iterative):
就像我们前面分析的那样,从最简单的子问题开始,一步一步往上计算。
创建一个数组(或者叫“表格”),用来存放计算结果。
初始化前几个值(比如爬 1 阶和爬 2 阶的方法数)。
然后用循环从第 3 阶开始计算,直到第 n 阶。
每次计算当前阶的方法数时,直接从表格里取出前面两阶已经算好的结果,相加即可。
这样,每计算一个子问题,结果就保存在表格里,下次再用到就直接拿。
用代码描述一下就是:
```python
def climb_stairs_iterative(n):
if n == 1:
return 1
dp = [0] (n + 1) dp[i] 存储爬到第 i 阶的方法数
dp[1] = 1
dp[2] = 2
for i in range(3, n + 1):
dp[i] = dp[i1] + dp[i2]
return dp[n]
```
这种方法就像是按部就班地把任务列表一项一项填满。
2. 记忆化搜索 (TopDown / Recursive with Memoization):
这个方法更接近我们大脑的思考方式,遇到问题先尝试一下,如果算过了就直接用结果,没算过就去算,算完了顺便记下来。
写一个递归函数来解决问题(比如爬到 n 阶的方法数)。
在递归函数内部,先检查是否已经计算过当前这个子问题(比如是否已经知道爬到 n 阶有多少种方法了),如果算过,直接返回之前存的结果。
如果没算过,就按照规律递归地计算它所依赖的子问题(比如爬到 n1 阶和 n2 阶的方法数)。
把计算得到的结果存起来(比如存在一个字典或者数组里),然后返回这个结果。
用代码描述一下就是:
```python
memo = {} 用来存储已经计算过的结果
def climb_stairs_recursive(n):
if n in memo: 如果已经算过,直接返回
return memo[n]
if n == 1:
return 1
if n == 2:
return 2
计算结果,并存入 memo
result = climb_stairs_recursive(n1) + climb_stairs_recursive(n2)
memo[n] = result
return result
```
这种方法就像是遇到一个难题,先看看有没有现成的答案,没有就自己去研究,研究明白了就记下来,以后再有人问就直接告诉他。
什么时候要考虑动态规划?
当你遇到一个问题,并且能观察到以下两点时,就很有可能是动态规划的用武之地:
最优子结构: 问题的最优解可以由其子问题的最优解构成。
重叠子问题: 在解决大问题的过程中,会反复遇到相同的子问题。
一些经典的动态规划问题(它们都有点“套路”的影子):
背包问题 (Knapsack Problem): 假设你有一个背包,容量有限,里面有一些物品,每种物品都有重量和价值。如何在不超过背包容量的情况下,使得装进背包的物品总价值最高?
最长公共子序列 (Longest Common Subsequence, LCS): 找出两个字符串中最长的那一个公共子序列。
编辑距离 (Edit Distance): 计算将一个字符串转换成另一个字符串所需的最少编辑操作次数(插入、删除、替换)。
硬币找零问题 (Coin Change Problem): 给你一些面额的硬币和目标金额,找出用最少的硬币数量凑成目标金额的方法。
动态规划的“套路”总结一下:
1. 定义状态: 确定你的“表格”或者“记忆体”里要存储什么信息。通常是 `dp[i]` 或者 `dp[i][j]` 表示解决子问题到某个阶段的状态。
2. 找到状态转移方程: 这是最核心的一步。你如何从已知的小问题(之前的状态)推导出当前问题(当前状态)的解?这就像是把你前面说的规律写成数学公式。
3. 确定初始条件/边界条件: 最简单的子问题的解是什么?比如爬楼梯的 `dp[1]` 和 `dp[2]`。
4. 选择填表法还是记忆化搜索: 大部分情况下两种都可以,但有时某种方法会更自然或更高效。
别怕复杂,从简单入手
动态规划听起来唬人,但一旦你抓住“分解”、“重复利用”这两个核心点,并且能找到那个“状态转移方程”,很多问题就迎刃而解了。刚开始学的时候,多做几个简单的例子,你会逐渐体会到它的精妙之处。别把它们当成高深的理论,而是当成一种解决问题的“聪明技巧”来学习,你会发现,嗯,这个东西还挺好玩的!