动态规划(Dynamic Programming)是算法设计中一种非常重要的方法论,特别适用于解决具有重叠子问题和最优子结构性质的问题。我第一次接触这个概念是在解决斐波那契数列问题时,当时就被它"记忆化"的思想所震撼。
简单来说,动态规划就是把大问题分解成小问题,通过解决小问题来构建大问题的解。这听起来和分治法很像,但关键在于动态规划会存储已经解决的子问题的答案,避免重复计算。就像我们做数学题时会把中间结果写在草稿纸上一样,动态规划就是算法的"草稿纸"。
注意:动态规划不是一种具体的算法,而是一种方法论,它没有固定的模板,但有一些通用的解题思路。
一个问题的最优解包含其子问题的最优解,这个性质称为最优子结构。比如在最短路径问题中,从A到C的最短路径如果经过B,那么这条路径中A到B的部分也必须是A到B的最短路径。
我在解决背包问题时深刻体会到这一点:要得到背包容量为W时的最优解,必须先知道所有小于W的背包容量的最优解。
不同的子问题会重复出现,这是动态规划能提高效率的关键。斐波那契数列就是个典型例子:计算fib(5)需要计算fib(4)和fib(3),而计算fib(4)又需要计算fib(3)和fib(2),这里fib(3)就被重复计算了。
这是动态规划的灵魂所在,它定义了如何从小问题的解推导出大问题的解。比如斐波那契数列的状态转移方程就是:
fib(n) = fib(n-1) + fib(n-2)
找到正确的状态转移方程往往是解决动态规划问题最困难的部分。
状态就是问题的子问题表示方式。比如在背包问题中,状态dp[i][j]表示考虑前i个物品,背包容量为j时的最大价值。
我刚开始学习时经常犯的错误是状态定义不清晰,导致后续推导困难。建议用具体的例子来验证状态定义是否合理。
这是最核心也最具挑战性的步骤。以爬楼梯问题为例:
因为你可以从n-1阶爬1步上来,或者从n-2阶爬2步上来。
没有正确的初始条件,状态转移方程就无法启动。比如斐波那契数列需要定义:
fib(0) = 0
fib(1) = 1
否则整个计算就无从开始。
动态规划可以是自顶向下(记忆化递归)或自底向上(迭代)。初学者建议从自底向上开始,因为它更直观,也更容易理解状态之间的依赖关系。
斐波那契数列的递归解法非常简单:
python复制def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
但这个解法的时间复杂度是O(2^n),因为存在大量重复计算。计算fib(5)的递归树如下:
code复制 fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
...(继续展开)
可以看到fib(3)被计算了两次,fib(2)被计算了三次。
我们可以用动态规划来优化:
python复制def fib(n):
if n <= 1:
return n
dp = [0] * (n+1)
dp[0], dp[1] = 0, 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
这个解法的时间复杂度是O(n),空间复杂度也是O(n)。但我们可以进一步优化空间:
python复制def fib(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n+1):
a, b = b, a + b
return b
这样空间复杂度就降到了O(1)。
这类问题的状态转移沿着线性方向进行,比如:
涉及区间操作的问题,比如:
经典的优化问题,包括:
状态转移发生在树结构上,比如:
当状态转移只依赖有限的几个前驱状态时,可以压缩存储空间。比如斐波那契数列问题中,我们只需要存储前两个状态。
这是一种常用的空间优化技术,通过交替使用数组空间来减少空间复杂度。比如在二维DP问题中,如果当前行只依赖上一行,就可以把空间从O(n^2)降到O(n)。
自顶向下的递归实现,配合缓存已计算结果。这在某些问题中比自底向上的迭代更直观:
python复制from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
初学者常犯的错误是状态定义不能完整描述问题。比如在背包问题中,如果只定义dp[i]表示前i个物品的最大价值,而不考虑剩余容量,就无法正确推导。
初始条件设置不当会导致整个计算错误。比如在爬楼梯问题中,dp[0]应该是1(表示地面有1种方式),还是0?这需要根据问题定义仔细考虑。
有些问题的状态转移有特定的依赖关系,必须按正确的顺序计算。比如在二维DP中,可能需要按行、按列或对角线顺序填充表格。
很多动态规划问题都可以先用递归思路解决,再优化为DP。以爬楼梯问题为例:
递归思路:
python复制def climb(n):
if n == 0: return 1
if n == 1: return 1
return climb(n-1) + climb(n-2)
然后我们观察到有重复计算,加入记忆化:
python复制memo = {}
def climb(n):
if n in memo: return memo[n]
if n == 0: return 1
if n == 1: return 1
memo[n] = climb(n-1) + climb(n-2)
return memo[n]
最后转化为迭代形式的DP:
python复制def climb(n):
if n == 0: return 1
dp = [0] * (n+1)
dp[0], dp[1] = 1, 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
这种"递归→记忆化→DP"的思考路径对初学者非常有帮助。
如何判断一个问题是否适合用动态规划解决?我总结了以下几个特征:
例如,以下问题适合用DP:
根据我的学习经验,建议按以下顺序逐步掌握动态规划:
每个阶段都要确保真正理解状态定义和转移方程的推导过程,而不是死记硬背模板。我建议每个经典类型至少做3-5道题目来巩固理解。