动态规划深度解析:从"傻递归"到"空间魔术师"的进化之路
一、从一个问题说起
上周有个朋友给我发了条消息:
"动态规划?不就是把递归结果存起来吗,这有什么好讲的?"
——我那位"以为懂了但其实没懂"的朋友
我问他:"那你说,编辑距离怎么用动态规划解?"
他沉默了三分钟,发了个表情包。
动态规划(Dynamic Programming,简称 DP)大概是算法世界里被误解最深的概念。每个人都觉得自己会用,但面试的时候一提"空间优化"就卡壳,一遇到"股票买卖"就崩溃。
这篇文章,就是要让这种尴尬消失。我们不只讲"是什么",更要讲透"为什么这么想"。看完之后,你对 DP 的理解会从"会用"升级到"能教别人"的程度。
顺便说一句,这是算法入门系列的第二篇。第一篇我们聊了算法的宇宙观和复杂度分析,如果还没读过,建议先收藏这篇,然后回去补补课。
二、DP 的四要素:一个都不能少
动态规划的本质是什么?我的定义是:把一个复杂问题拆成互相重叠的子问题,只算一次,记录答案,然后用它算出更大问题的答案。
听起来简单,但真正的高手和普通人的差距,藏在四个关键决策里。
要素一:状态定义——这是最难的一步
状态,就是你用什么样的"视角"去描述问题。选对了状态,问题就解了一半;选错了状态,你可能写到两百行还跑不通。
举个例子。背包问题的状态怎么定义?
菜鸟的思路:"状态就是第 i 个物品选不选"——这个描述的是动作,不是问题的数学状态。
高手的思路:dp[i][j] = 考虑前 i 个物品,容量为 j 的背包能装的最大价值。这就是状态。
要素二:状态转移方程——DP 的灵魂
如果说状态是地基,那状态转移方程就是连接每个房间的楼梯。它描述的是:已知小问题的答案,如何得到大问题的答案。
写状态转移方程的核心思路就一句话:新问题 = 选或不选 + 子问题的答案
以斐波那契为例:dp[n] = dp[n-1] + dp[n-2]。第 n 项的值,取决于前面两个子问题的答案相加。
要素三:初始化——万丈高楼从哪起
状态转移方程是递推公式,它需要一个或多个"已知真相"作为起点。这个起点就是初始化。
斐波那契的初始化是 dp[0] = 0, dp[1] = 1。如果初始化错了,整个 DP 都会歪掉。
要素四:遍历顺序——顺序不对,全盘皆错
动态规划的遍历顺序必须满足一个条件:计算每个状态时,它依赖的所有子状态都已经被计算完了。
这四个要素搞清楚了,你对 DP 的理解就已经超过 80% 的面试者了。接下来,我们用经典问题把它们一一落地。
三、入门第一关:斐波那契数列
很多人觉得斐波那契太简单了,不值得讲。但我的观点正好相反——越是简单的问题,越藏着最本质的洞见。
从暴力递归到 DP 的完整进化
先看暴力递归版本:
# ❌ 暴力递归:时间 O(2^n),空间 O(n) 递归栈
def fib1(n):
if n <= 1:
return n
return fib1(n-1) + fib1(n-2)
这个版本的问题在哪?画一下递归树你就知道了——fib(4) 被算了一次,fib(3) 被算了两次,fib(2) 被算了三次……指数爆炸。
加上记忆化,把算过的结果存起来:
# ✅ 记忆化递归:时间 O(n),空间 O(n)
from functools import lru_cache
@lru_cache(maxsize=None)
def fib2(n):
if n <= 1:
return n
return fib2(n-1) + fib2(n-2)
改成自底向上的 DP,消除递归栈:
# ✅ 自底向上 DP:时间 O(n),空间 O(n)
def fib3(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
到这里,很多教程就停了。但我要再进一步——这就是空间优化的起点。
空间优化:一维到两个变量
你注意到没有?在 fib3 的循环里,dp[i] 只依赖 dp[i-1] 和 dp[i-2]。换句话说,我不需要存整个数组,只需要记住最近的两个值就够了。
# ✅ 空间优化版:时间 O(n),空间 O(1)
def fib4(n):
if n <= 1:
return n
prev, curr = 0, 1
for _ in range(2, n + 1):
prev, curr = curr, prev + curr
return curr
从 O(n) 空间压缩到 O(1) 空间,这个技巧叫滚动数组。斐波那契这一关,藏着整个 DP 空间优化的核心思想——只保留真正需要的那些状态。
四、经典战役:背包问题
如果说斐波那契是 DP 的热身,那背包问题就是 DP 的主战场。面试中出现频率极高,而且变体极多——0-1 背包、完全背包、多维背包……但只要掌握了核心思想,万变不离其宗。
0-1 背包:每个物品只能选一次
问题定义:有 n 件物品,每件物品有重量 w[i] 和价值 v[i],背包容量为 C,求不超过容量能装下的最大价值。
状态定义:dp[i][j] = 考虑前 i 件物品,背包容量为 j 时能获得的最大价值。
状态转移:对于第 i-1 件物品,选或不选,取价值更大的那个。
def knapsack_01(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
w, v = weights[i-1], values[i-1]
for j in range(capacity + 1):
dp[i][j] = dp[i-1][j] # 不选
if j >= w:
dp[i][j] = max(dp[i][j], dp[i-1][j-w] + v) # 选
return dp[n][capacity]
这段代码里,关键在第 8 行:dp[i-1][j-w] 指的是"考虑前 i-1 件物品、容量减少 w 之后的最大价值",再加上当前物品的价值 v,就是选这件物品的总价值。
注意表格中一个关键规律:第 i 行只依赖第 i-1 行。这意味着我们不需要保存整个二维数组,用一行就够了。
# ✅ 空间优化版:时间 O(n*C),空间 O(C)
def knapsack_01_optimized(weights, values, capacity):
dp = [0] * (capacity + 1)
for w, v in zip(weights, values):
# ⚠️ 必须倒序遍历容量!
for j in range(capacity, w - 1, -1):
dp[j] = max(dp[j], dp[j-w] + v)
return dp[capacity]
这里有个极其容易出错的地方:容量必须倒序遍历。为什么?如果正序遍历,dp[j-w] 已经被当前物品更新过了(相当于一个物品选了两次),而倒序遍历保证每次都用上一行的旧值。
for j in range(w, capacity + 1): 是错的,for j in range(capacity, w - 1, -1): 才是对的。这个坑我见过无数人面试时踩进去——包括我自己当年。
完全背包:物品无限选
和 0-1 背包的唯一区别:每件物品可以选无限次。解决方案就是容量遍历方向反过来——正序,因为正序时 dp[j-w] 已经被当前物品更新过,正好代表"选了若干件当前物品"的情况。
# ✅ 完全背包:时间 O(n*C),空间 O(C)
def knapsack_complete(weights, values, capacity):
dp = [0] * (capacity + 1)
for w, v in zip(weights, values):
for j in range(w, capacity + 1): # 正序!
dp[j] = max(dp[j], dp[j-w] + v)
return dp[capacity]
一个遍历方向的差异,决定了 0-1 背包还是完全背包。魔鬼藏在细节里。
五、经典双子星:LCS 与 LIS
两个字符串/序列的问题,是 DP 的另一大王牌领域。
最长公共子序列(LCS)
问题定义:给定两个字符串,求它们的最长公共子序列(Subsequence,不要求连续)的长度。
这个问题的应用场景极其广泛:diff 工具(比较两个文件差异)、生物信息学(DNA 序列比对)、文本查重……
状态定义:dp[i][j] = 字符串 A 的前 i 个字符和字符串 B 的前 j 个字符的最长公共子序列长度。
状态转移:
- 如果
A[i-1] == B[j-1]:dp[i][j] = dp[i-1][j-1] + 1(两个字符匹配上,等于各自去掉最后一个字符的 LCS 长度加 1) - 否则:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])(去掉其中一个字符串的最后一个字符,取较大的 LCS)
def lcs(s1, s2):
n, m = len(s1), len(s2)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[n][m]
# 示例:lcs("abcde", "ace") = 3 ("ace")
# 示例:lcs("abcabc", "defabc") = 3 ("abc")
这里有个特别有意思的地方:dp[i][j] 要么等于 dp[i-1][j-1] + 1(当前字符匹配上了),要么等于 dp[i-1][j] 或 dp[i][j-1](至少有一个字符串的最后一个字符不参与匹配)。这个决策逻辑本身就是一种"分治"——要么匹配这两个字符,要么放弃其中一个。
最长递增子序列(LIS)
问题定义:给定一个序列,找出其中最长的递增子序列的长度。Subsequence 可以不连续,但顺序必须保持。
状态定义:dp[i] = 以第 i 个元素结尾的最长递增子序列长度。
状态转移:遍历所有在 i 之前的元素 j,如果 nums[j] < nums[i],说明可以把 nums[i] 接在以 nums[j] 结尾的递增序列后面。
# ✅ LIS:时间 O(n^2),空间 O(n)
def lis(nums):
if not nums:
return 0
n = len(nums)
dp = [1] * n # 每个元素自己构成长度为 1 的递增子序列
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
# 示例:lis([10,9,2,5,3,7,101,18]) = 4([2,3,7,101] 或 [2,3,7,18])
这个 O(n^2) 的版本好理解,但面试官通常还会追问:有没有更快的做法?答案是二分查找优化,把时间复杂度降到 O(n log n),这就是传说中的"贪心 + 二分"解法。
# ✅ LIS 二分优化:时间 O(n log n),空间 O(n)
import bisect
def lis_binary(nums):
d = [] # d[i] = 长度为 i+1 的递增子序列的最小结尾元素
for x in nums:
i = bisect.bisect_left(d, x) # 找第一个 >= x 的位置
if i == len(d):
d.append(x) # x 比所有元素都大,可以延长序列
else:
d[i] = x # 用更小的 x 替换,保持 d 的最小性
return len(d)
二分优化的核心思想是:维护一个数组 d,其中 d[i] 表示长度为 i+1 的递增子序列的最小可能结尾值。这个数组是严格递增的,所以可以用二分查找。对于每个新元素,找到它在 d 中的位置——要么延长最长序列,要么用更小的值替换,让后续元素有更大的可能性接上来。
六、最烧脑挑战:编辑距离
编辑距离是 LeetCode 上的 Hard 级别问题,也是面试官的最爱。它把 DP 的状态定义能力压榨到了极限。
问题定义:给定两个字符串 word1 和 word2,通过插入、删除、替换三种操作,把 word1 变成 word2 的最少步数。
状态定义:dp[i][j] = word1 的前 i 个字符变成 word2 的前 j 个字符所需的最少操作数。
状态转移(最烧脑的部分):
- 如果
word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1](最后一个字符相同,不需要操作) - 否则(三选一,取最小值):
- 替换:
dp[i-1][j-1] + 1(把 word1 的最后一个字符换成 word2 的) - 删除:
dp[i-1][j] + 1(删掉 word1 的最后一个字符) - 插入:
dp[i][j-1] + 1(在 word1 末尾插入 word2 的最后一个字符)
- 替换:
def edit_distance(word1, word2):
n, m = len(word1), len(word2)
dp = [[0] * (m + 1) for _ in range(n + 1)]
# 初始化:第一行/列
for i in range(n + 1):
dp[i][0] = i # word1 前 i 个字符变成空串,需要 i 次删除
for j in range(m + 1):
dp[0][j] = j # 空串变成 word2 前 j 个字符,需要 j 次插入
for i in range(1, n + 1):
for j in range(1, m + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
return dp[n][m]
# 示例:edit_distance("horse", "ros") = 3
# horse → ros(删除 h, 删除 e, 删除 e)或者替换 r→r, o→o, s→s 等多种路径
编辑距离的难点在于:三种操作的物理含义要理解清楚——替换是"改",删除是"少一个",插入是"多一个"。理解了操作本质,状态转移方程就不难写出来。
七、终极奥义:空间优化
前面我们已经见过很多空间优化的例子了(斐波那契、背包)。现在我们来系统地总结一下空间优化的通用技巧。
技巧一:滚动数组——只保留必要的行
如果 dp[i][j] 只依赖 dp[i-1][*](即当前行只依赖上一行),那么我们只需要维护一维数组。
0-1 背包和完全背包的优化就是这个原理。
技巧二:状态压缩到极致
如果一维数组中,每个位置只依赖它左边的某个位置(比如 dp[j] = max(dp[j], dp[j-w] + v)),那么这个 j 的遍历方向就决定了是 0-1 背包(倒序)还是完全背包(正序)。
技巧三:两个变量代替整个数组
斐波那契就是最经典的例子。当 dp[i] 只依赖 dp[i-1] 和 dp[i-2] 时,用 prev 和 curr 两个变量就够了。
# 斐波那契:两个变量
prev, curr = 0, 1
for _ in range(2, n + 1):
prev, curr = curr, prev + curr
# LCS:两行交替
prev = [0] * (m + 1)
curr = [0] * (m + 1)
for i in range(1, n + 1):
for j in range(1, m + 1):
if s1[i-1] == s2[j-1]:
curr[j] = prev[j-1] + 1
else:
curr[j] = max(prev[j], curr[j-1])
prev, curr = curr, [0] * (m + 1)
空间优化的取舍
空间优化虽好,但要注意场景:
- 需要回溯路径(比如输出具体选了哪些物品)时,必须用二维数组记录决策过程
- 空间不是瓶颈时,二维数组代码更清晰、更不容易出错
- 工程代码中,可读性往往比极致优化更重要
八、实战路线图
聊了这么多,最后给你一个可操作的练习路线。
必刷经典题(按难度排序)
| 题目 | LeetCode | 核心技巧 | 难度 |
|---|---|---|---|
| 爬楼梯 | #70 | 斐波那契变体 | ⭐ |
| 打家劫舍 | #198 | 一维 DP,空间优化 | ⭐ |
| 不同路径 | #62 | 二维 DP 入门 | ⭐⭐ |
| 三角形最小路径和 | #120 | 自底向上 DP | ⭐⭐ |
| 最长递增子序列 | #300 | DP + 二分优化 | ⭐⭐ |
| 最长公共子序列 | #1143 | 双串 DP | ⭐⭐ |
| 编辑距离 | #72 | 三操作 DP | ⭐⭐⭐ |
| 背包问题 | #416 等 | 状态定义 + 空间优化 | ⭐⭐⭐ |
| 股票买卖 | #121-123, 188 | 状态机 DP | ⭐⭐⭐ |
我的学习建议
- 先理解四要素:状态定义、状态转移、初始化、遍历顺序。这四个搞清楚了,任何 DP 题都能有思路
- 从简单题入手:爬楼梯、打家劫舍都是斐波那契的变体,先把这些做熟建立信心
- 画表理解:拿到题先在纸上画 DP 表,比直接写代码更能理解状态转移
- 先二维后优化:面试时先写二维数组确保正确,再优化空间展示能力
- 反复咀嚼经典题:LCS、编辑距离、背包刷三遍,每遍都有新收获
我认为:动态规划不是一种"算法模板",而是一种"思维方式"。它教会你的不只是怎么解题,更是怎么把一个大问题分解成小问题、如何找到问题的最小视角。这种能力,才是 DP 真正给你的礼物。
下一步建议:打开 LeetCode,从 #70 爬楼梯开始,把上面的必刷题列表刷完。刷完之后,你对 DP 的理解会彻底不同。
最后留个小彩蛋:斐波那契数列有个鲜为人知的秘密——它的时间复杂度其实可以用矩阵快速幂优化到 O(log n)。如果你的面试官特别"变态",可能会问到这个。提示:斐波那契矩阵形式是 [[1,1],[1,0]] 的 n 次幂。有兴趣的话去研究一下。