1. 为什么递归和动态规划总是被放在一起考
面试算法环节里,递归和动态规划几乎是绕不开的两座山。很多人刷了几十道题,看到"爬楼梯"能秒写递归,看到"最长递增子序列"就卡壳,看到"零钱兑换"直接放弃。问题不在于题刷得不够多,而在于没有真正理解这两者之间的血缘关系。
递归是一种自顶向下的思考方式,它把大问题拆成小问题,假设小问题已经解决,然后拼出大问题的答案。动态规划则是自底向上的工程化手段,它把递归过程中重复计算的部分用表格存起来,用空间换时间。两者本质上描述的是同一个东西:状态转移。递归是状态转移的"自然语言",动态规划是状态转移的"汇编语言"。
我见过太多候选人,面试时能写出递归版本,但面试官一句"能不能优化一下时间复杂度"就慌了。其实从递归到动态规划有一条非常清晰的路径:先写出暴力递归,找到重叠子问题,加上记忆化搜索,再改写成自底向上的递推。这条路径走通了,面试中百分之八十的动态规划题都能拿下。
这篇文章会从递归的底层原理讲起,逐步过渡到动态规划的核心思想,再通过几道经典面试题把两者的转换过程完整演示一遍。适合正在准备面试的开发者、刚接触算法的新手,以及刷题刷到瓶颈想系统梳理思路的朋友。读完之后,你至少能做到:看到一道新题,能判断它是不是动态规划问题,能写出递归解,能优化成动态规划解,能分析时间和空间复杂度。
2. 递归的本质与常见误区
2.1 递归到底在做什么
递归函数的本质是一个数学归纳法的程序化表达。它包含两个部分:基准情况和递归关系。基准情况是问题规模小到可以直接给出答案的情况,递归关系是把当前问题归约到更小规模问题的公式。
以经典的斐波那契数列为例。定义是 F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)。前两行是基准情况,第三行是递归关系。写成代码就是:
def fib(n): if n <= 1: return n return fib(n-1) + fib(n-2)这段代码读起来几乎和数学定义一模一样,这就是递归的魅力:表达力极强,接近人类思维。但它的代价也很明显。计算 fib(5) 的时候,fib(3) 会被计算两次,fib(2) 会被计算三次。随着 n 增大,重复计算的次数呈指数级增长。n=40 的时候,函数调用次数超过三亿次,普通机器要跑好几秒。
2.2 递归的三个致命陷阱
第一个陷阱是重复计算。上面斐波那契的例子已经说明了。重复计算是递归效率低下的头号原因,也是动态规划要解决的核心问题。
第二个陷阱是栈溢出。每次函数调用都会在调用栈上压入一个栈帧,保存参数、局部变量和返回地址。Python 默认的递归深度限制是 1000 左右,超过就会抛出 RecursionError。即使语言层面没有限制,栈空间也是有限的,递归太深必然崩溃。我踩过这个坑:有一次写一个链表反转的递归解法,测试用例的链表长度是十万,直接栈溢出。后来改成迭代才通过。
第三个陷阱是状态定义不清晰。很多人在写递归的时候,没有想清楚"这个函数的参数代表什么,返回值代表什么"。比如写一个"求二叉树最大深度"的递归,函数签名是def depth(node),那它的语义应该是"以 node 为根的子树的最大深度"。如果语义模糊,递归关系就写不对,基准情况也容易漏。
写递归之前,先用一句话把函数的语义写下来。这句话必须包含:参数是什么,返回值是什么,在什么情况下返回。这句话写不清楚,代码一定写不对。
2.3 递归的适用场景判断
不是所有问题都适合用递归。适合递归的问题通常具备两个特征:一是问题可以分解为结构相同的子问题,二是子问题的规模比原问题小且最终能收敛到基准情况。
典型的适合递归的场景包括:树和图的遍历、分治算法(归并排序、快速排序)、回溯搜索(全排列、子集、N皇后)、以及具有递推关系的数学问题。不适合递归的场景包括:简单的线性迭代、状态空间巨大且没有重叠子问题的问题、以及递归深度可能非常大的情况。
判断标准很简单:如果你画出的递归树是一棵"瘦高"的树(每层只有一个分支),那递归深度就是问题规模,容易栈溢出,应该考虑迭代。如果递归树是"矮胖"的(每层有多个分支但深度不大),那递归写起来很自然。如果递归树中有大量重复节点,那就是动态规划的用武之地。
3. 动态规划的核心思想与解题框架
3.1 动态规划解决了什么问题
动态规划解决的是具有重叠子问题和最优子结构的问题。重叠子问题指的是递归过程中同一个子问题被多次计算;最优子结构指的是原问题的最优解可以由子问题的最优解推导出来。
这两个条件缺一不可。如果没有重叠子问题,那递归本身就够了,加缓存没有意义。如果没有最优子结构,那子问题的最优解拼不出原问题的最优解,动态规划的状态转移方程就写不出来。
动态规划的核心操作是用表格记录子问题的解,避免重复计算。这个表格可以是一维数组、二维数组,也可以是哈希表。表格的每个格子代表一个子问题的答案,格子之间的递推关系就是状态转移方程。
3.2 动态规划的四步解题框架
我总结了一个四步框架,适用于绝大多数动态规划问题。
第一步:定义状态。状态就是"表格的索引代表什么"。比如dp[i]表示"前 i 个元素的最优解",或者dp[i][j]表示"从 i 到 j 这个区间的最优解"。状态定义是动态规划中最关键也最难的一步,状态定义错了,后面全错。
第二步:写出状态转移方程。这是"从已知状态推导未知状态"的公式。比如dp[i] = max(dp[i-1], dp[i-2] + nums[i])。状态转移方程决定了代码的循环结构。
第三步:确定初始条件和边界。初始条件是表格中最小的那些子问题的答案,边界是防止数组越界的处理。初始条件写错,整个表格都会错。
第四步:确定遍历顺序。遍历顺序必须保证"计算某个状态时,它所依赖的状态已经计算完毕"。比如dp[i]依赖dp[i-1],那就从小到大遍历;如果dp[i]依赖dp[i+1],那就从大到小遍历。
这四步中,第一步和第二步是核心,第三步和第四步是细节。面试的时候,先把状态定义和转移方程说清楚,再写代码,面试官会觉得你思路清晰。
3.3 记忆化搜索:递归到动态规划的桥梁
很多人觉得递归和动态规划是两种完全不同的东西,其实它们之间有一座桥:记忆化搜索。记忆化搜索就是在递归的基础上加一个缓存,计算过的子问题直接查缓存,不再重复计算。
还是斐波那契的例子,加上记忆化:
def fib(n, memo={}): if n <= 1: return n if n in memo: return memo[n] memo[n] = fib(n-1, memo) + fib(n-2, memo) return memo[n]这段代码的时间复杂度从 O(2^n) 降到了 O(n),空间复杂度是 O(n)。它保留了递归的自顶向下思维,同时消除了重复计算。对于很多问题,记忆化搜索已经足够通过面试,而且写起来比自底向上的动态规划更直观。
记忆化搜索和自底向上动态规划的区别在于:记忆化搜索是"需要哪个子问题的解就算哪个",自底向上是"把所有子问题都算一遍"。前者在某些情况下更高效(不需要计算所有子问题),后者在某些情况下更高效(没有递归调用开销)。面试中两种都可以写,但自底向上的写法通常更受青睐,因为它没有栈溢出的风险。
4. 从递归到动态规划的完整转换实战
4.1 爬楼梯问题:最简单的转换演示
爬楼梯问题是动态规划的入门题:每次可以爬 1 阶或 2 阶,问爬到第 n 阶有多少种方法。
先用递归写。定义climb(n)为爬到第 n 阶的方法数。最后一步要么从 n-1 阶爬 1 阶上来,要么从 n-2 阶爬 2 阶上来。所以climb(n) = climb(n-1) + climb(n-2)。基准情况是climb(0)=1,climb(1)=1。
def climb(n): if n <= 1: return 1 return climb(n-1) + climb(n-2)这个递归树和斐波那契一模一样,有大量重复计算。加上记忆化:
def climb(n, memo={}): if n <= 1: return 1 if n in memo: return memo[n] memo[n] = climb(n-1, memo) + climb(n-2, memo) return memo[n]再改成自底向上:
def climb(n): if n <= 1: return 1 dp = [0] * (n + 1) dp[0] = 1 dp[1] = 1 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] return dp[n]进一步优化空间,发现dp[i]只依赖前两个状态,所以可以用两个变量滚动:
def climb(n): if n <= 1: return 1 prev, curr = 1, 1 for i in range(2, n + 1): prev, curr = curr, prev + curr return curr从递归到记忆化到自底向上到空间优化,这是动态规划问题的标准优化路径。面试的时候,如果你能把这四个版本都写出来,面试官基本会认可你的功底。
4.2 零钱兑换:状态定义决定成败
零钱兑换问题:给定不同面额的硬币和一个总金额,问凑出总金额所需的最少硬币数。如果凑不出,返回 -1。
这道题的状态定义很关键。如果定义dp[i]为"凑出金额 i 所需的最少硬币数",那状态转移方程是dp[i] = min(dp[i-coin] + 1),其中 coin 遍历所有硬币面额。初始条件dp[0]=0,其他dp[i]=infinity。
def coinChange(coins, amount): dp = [float('inf')] * (amount + 1) dp[0] = 0 for i in range(1, amount + 1): for coin in coins: if coin <= i: dp[i] = min(dp[i], dp[i-coin] + 1) return dp[amount] if dp[amount] != float('inf') else -1如果用递归写,定义minCoins(amount)为凑出 amount 所需的最少硬币数。那minCoins(amount) = min(minCoins(amount-coin) + 1)。基准情况是minCoins(0)=0,minCoins(负数)=infinity。
def coinChange(coins, amount, memo={}): if amount == 0: return 0 if amount < 0: return float('inf') if amount in memo: return memo[amount] res = float('inf') for coin in coins: res = min(res, coinChange(coins, amount - coin, memo) + 1) memo[amount] = res return res对比两个版本,递归版本更直观地表达了"枚举最后一枚硬币"的思路,动态规划版本则是把这个思路反过来,从 0 开始逐步构建到 amount。两者的状态转移本质相同,只是方向不同。
零钱兑换有一个容易踩的坑:
dp[i]的初始值必须是一个"不可能的大数",不能是 0。如果初始值是 0,那min操作永远会选 0,结果全错。我见过好几个候选人在这里翻车。
4.3 最长递增子序列:两种状态定义的对比
最长递增子序列(LIS)问题:给定一个数组,求最长递增子序列的长度。注意子序列不要求连续。
这道题有两种动态规划的状态定义。第一种:dp[i]表示"以 nums[i] 结尾的最长递增子序列长度"。状态转移方程是dp[i] = max(dp[j] + 1),其中 j < i 且 nums[j] < nums[i]。答案是max(dp)。
def lengthOfLIS(nums): if not nums: return 0 dp = [1] * len(nums) for i in range(1, len(nums)): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)第二种定义:dp[i]表示"长度为 i 的递增子序列的最小结尾元素"。这个定义更巧妙,可以用二分查找优化到 O(n log n)。但理解起来更难,面试中如果想不到,用第一种 O(n^2) 的解法也足够。
从递归的角度看,LIS 的递归定义是:LIS(i)表示以 nums[i] 结尾的最长递增子序列长度,那LIS(i) = max(LIS(j) + 1),其中 j < i 且 nums[j] < nums[i]。基准情况是LIS(0)=1。这个递归和动态规划的状态定义完全对应。
4.4 二维动态规划:编辑距离
编辑距离问题:给定两个字符串,求把第一个字符串转换成第二个字符串所需的最少操作次数。操作包括插入、删除、替换。
状态定义:dp[i][j]表示"把 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作次数"。状态转移方程分两种情况:如果word1[i-1] == word2[j-1],那dp[i][j] = dp[i-1][j-1];否则dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1。
def minDistance(word1, word2): m, n = len(word1), len(word2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] else: dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1 return dp[m][n]这道题的递归版本是:edit(i, j)表示把 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作次数。递归关系和动态规划完全一样,只是方向相反。
二维动态规划的难点在于边界条件的初始化。dp[i][0]表示把前 i 个字符转换成空串,需要删除 i 次,所以是 i。dp[0][j]表示把空串转换成前 j 个字符,需要插入 j 次,所以是 j。这两个边界如果写错,整个表格都会错。
5. 面试中的高频问题与排查技巧
5.1 如何判断一道题是不是动态规划
面试中遇到一道新题,怎么判断它是不是动态规划?我总结了一个快速判断法:先想递归,再看递归树有没有重复节点。
具体操作是:尝试写出暴力递归的解法,然后在纸上画出递归树。如果递归树中有大量重复节点,那这道题就可以用动态规划优化。如果递归树是一棵没有重复节点的树,那动态规划不适用,应该考虑其他方法(如贪心、分治、回溯)。
另一个判断依据是问题是否要求"最优解"或"方案数"。求最大值、最小值、最长、最短、多少种方法,这些通常都是动态规划的强项。而求"是否存在"的问题,有时候用贪心或数学方法更简单。
5.2 状态定义想不出来怎么办
状态定义是动态规划中最难的一步。想不出来的时候,我通常用两个技巧。
第一个技巧是从递归函数的参数入手。递归函数的参数通常就是状态定义的维度。比如递归函数是f(i, j),那状态就是dp[i][j]。递归函数的返回值就是dp[i][j]的值。
第二个技巧是从问题的"最后一步"入手。问自己:最后一步做了什么操作?这个操作依赖哪些子问题?比如爬楼梯的最后一步是爬 1 阶或 2 阶,所以依赖dp[n-1]和dp[n-2]。零钱兑换的最后一步是选了一枚硬币,所以依赖dp[amount-coin]。
如果实在想不出来,先写暴力递归,然后观察递归函数的参数和返回值,状态定义自然就出来了。这是最稳妥的方法。
5.3 遍历顺序搞错了会怎样
遍历顺序错误是动态规划中很隐蔽的 bug。比如dp[i][j]依赖dp[i-1][j]和dp[i][j-1],那遍历顺序必须是外层 i 从小到大,内层 j 从小到大。如果内层 j 从大到小,那dp[i][j-1]还没计算,结果就错了。
判断遍历顺序的方法:画出状态依赖图,确保计算某个状态时,它依赖的所有状态都已经计算完毕。对于一维动态规划,如果dp[i]依赖dp[i-1],那就从小到大;如果依赖dp[i+1],那就从大到小。对于二维动态规划,通常是从左上到右下,但有些问题(如背包问题的空间优化版本)需要从右下到左上。
5.4 空间优化什么时候可以做
空间优化是动态规划的进阶技巧。当dp[i]只依赖dp[i-1]和dp[i-2]时,可以用滚动变量把空间从 O(n) 降到 O(1)。当dp[i][j]只依赖上一行时,可以用一维数组代替二维数组。
但空间优化不是必须的。面试中,如果时间紧张,先写出未优化的版本,确保正确性,再考虑优化。我见过候选人为了空间优化把代码写得很复杂,结果逻辑出错,得不偿失。
空间优化的前提是"不影响可读性"。如果优化后代码变得难以理解,那不如不优化。面试官更看重的是思路清晰和代码正确,而不是极致的空间复杂度。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 结果比预期大很多 | 初始值设置错误 | 检查 dp 数组的初始值是否合理 |
| 结果比预期小很多 | 状态转移漏了情况 | 检查转移方程是否覆盖所有情况 |
| 数组越界 | 边界条件没处理 | 检查循环的起止范围 |
| 结果不稳定 | 遍历顺序错误 | 画出依赖图,检查遍历方向 |
| 超时 | 没有消除重复计算 | 检查是否加了记忆化或动态规划 |
| 栈溢出 | 递归深度太大 | 改成自底向上或迭代 |
6. 进阶技巧与实战心得
6.1 背包问题的统一框架
背包问题是动态规划的经典模型,包括 01 背包、完全背包、多重背包等变体。掌握背包问题的统一框架,能解决一大类问题。
01 背包的状态定义:dp[i][j]表示"前 i 个物品,容量为 j 的背包能装的最大价值"。状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])。空间优化后,内层循环从大到小遍历。
完全背包的状态定义相同,但内层循环从小到大遍历。这个区别的原因是:01 背包每个物品只能用一次,所以计算dp[j]时必须保证dp[j-weight[i]]是上一轮的状态;完全背包每个物品可以用无限次,所以dp[j-weight[i]]可以是本轮的状态。
# 01 背包 for i in range(n): for j in range(capacity, weight[i] - 1, -1): dp[j] = max(dp[j], dp[j-weight[i]] + value[i]) # 完全背包 for i in range(n): for j in range(weight[i], capacity + 1): dp[j] = max(dp[j], dp[j-weight[i]] + value[i])这个框架可以解决很多变体问题,比如"零钱兑换"就是完全背包的变体,"分割等和子集"就是 01 背包的变体。
6.2 区间动态规划的套路
区间动态规划的状态定义通常是dp[i][j]表示"区间 [i, j] 上的最优解"。遍历顺序通常是按区间长度从小到大,因为长区间的解依赖短区间的解。
经典题目包括"最长回文子序列"、"戳气球"、"矩阵链乘法"。以最长回文子序列为例,状态定义dp[i][j]表示"字符串 s 在区间 [i, j] 上的最长回文子序列长度"。转移方程:如果s[i] == s[j],那dp[i][j] = dp[i+1][j-1] + 2;否则dp[i][j] = max(dp[i+1][j], dp[i][j-1])。
def longestPalindromeSubseq(s): n = len(s) dp = [[0] * n for _ in range(n)] for i in range(n): dp[i][i] = 1 for length in range(2, n + 1): for i in range(n - length + 1): j = i + length - 1 if s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2 else: dp[i][j] = max(dp[i+1][j], dp[i][j-1]) return dp[0][n-1]区间动态规划的难点在于遍历顺序。必须按区间长度从小到大遍历,因为dp[i][j]依赖dp[i+1][j-1]、dp[i+1][j]、dp[i][j-1],这些区间的长度都比[i, j]小。
6.3 状态压缩的技巧
状态压缩是用二进制位来表示状态的技巧,适用于状态空间是"集合"的问题。经典题目是"旅行商问题"和"集合覆盖问题"。
以旅行商问题为例,状态定义dp[mask][i]表示"已经访问的城市集合为 mask,当前在城市 i 的最短路径"。mask 是一个二进制数,第 k 位为 1 表示城市 k 已访问。状态转移方程:dp[mask][i] = min(dp[mask ^ (1<<i)][j] + dist[j][i]),其中 j 是 mask 中除了 i 之外的城市。
def tsp(dist): n = len(dist) dp = [[float('inf')] * n for _ in range(1 << n)] dp[1][0] = 0 for mask in range(1 << n): for i in range(n): if dp[mask][i] == float('inf'): continue for j in range(n): if mask & (1 << j): continue new_mask = mask | (1 << j) dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist[i][j]) return min(dp[(1 << n) - 1][i] + dist[i][0] for i in range(n))状态压缩的时间复杂度通常是 O(2^n * n^2),适用于 n 不超过 20 的情况。n 再大就会超时。
6.4 我踩过的那些坑
第一个坑是忘记处理空输入。有一次面试,题目是"最长递增子序列",我写完代码后面试官问"如果数组为空呢",我才发现代码会返回错误结果。后来养成了习惯,写完动态规划先检查空输入和单元素输入。
第二个坑是初始值设得太大导致溢出。用float('inf')做初始值的时候,如果做加法运算,inf + 1还是inf,不会溢出。但如果用sys.maxsize做初始值,加法就会溢出变成负数。我建议统一用float('inf'),省心。
第三个坑是二维数组的创建方式。dp = [[0] * n] * m这种写法是错误的,因为它创建的是 m 个指向同一个列表的引用。修改dp[0][0]会同时修改dp[1][0]。正确的写法是dp = [[0] * n for _ in range(m)]。这个坑我踩过不止一次。
第四个坑是递归中的可变默认参数。def fib(n, memo={})这种写法,memo 会在多次调用之间共享。如果函数被多次调用,memo 会保留上一次的结果,导致错误。正确的写法是def fib(n, memo=None),然后在函数内部判断if memo is None: memo = {}。
这些坑看起来很小,但在面试中一旦踩中,可能就是"通过"和"不通过"的区别。建议平时刷题的时候就养成好习惯,不要等到面试才注意。
6.5 面试中的沟通技巧
动态规划题在面试中,沟通比代码更重要。我建议按以下顺序和面试官沟通:
先说出暴力递归的思路,包括状态定义和转移方程。这一步展示的是你的思维能力。然后指出递归中的重复计算问题,提出用记忆化或动态规划优化。这一步展示的是你的优化意识。接着写出自底向上的代码,边写边解释每一行的含义。最后分析时间和空间复杂度,并提出可能的优化方向。
如果卡住了,不要沉默。可以这样说:"我目前想到的状态定义是 XXX,但转移方程还不太确定,能不能给我一点提示?"面试官通常愿意给提示,关键是你要展示出思考的过程,而不是直接放弃。
另外,写完代码后一定要手动跑一个简单的测试用例。比如爬楼梯问题,跑 n=3 的情况,验证输出是不是 3。这一步能发现很多低级错误,也能让面试官看到你的严谨。
7. 刷题路线与练习建议
7.1 分阶段刷题路线
动态规划的刷题应该分阶段进行,不要一上来就刷难题。
第一阶段是入门题,包括爬楼梯、斐波那契数列、零钱兑换。这些题的状态定义和转移方程都很直观,适合建立信心。
第二阶段是经典模型题,包括 01 背包、完全背包、最长递增子序列、最长公共子序列。这些题覆盖了动态规划的主要模型,刷完之后能识别大部分题目的类型。
第三阶段是变体题和综合题,包括编辑距离、戳气球、正则表达式匹配。这些题的状态定义需要一些技巧,适合提升能力。
第四阶段是状态压缩和区间动态规划,包括旅行商问题、最长回文子序列。这些题难度较高,适合冲刺高级岗位。
每个阶段建议刷 10 到 15 道题,不要贪多。刷题的质量比数量重要。每道题都要做到:能写出递归解、能写出记忆化解、能写出自底向上解、能分析复杂度、能处理边界情况。
7.2 如何高效复习
动态规划的题目容易忘,复习很重要。我建议用"三遍法"复习。
第一遍是刷题时,写出完整解法并记录思路。第二遍是三天后,不看答案重新写一遍,检查是否还记得。第三遍是一周后,只看题目,口述思路和转移方程,不写代码。
另外,建议建一个"错题本",记录自己做错的题和踩过的坑。每次复习的时候,先看错题本,再看新题。错题本上的题要反复做,直到能流畅写出为止。
动态规划的进步不是线性的。有时候刷了十几道题感觉还是不会,但突然某一天就开窍了。关键是坚持,不要因为一时卡壳就放弃。
7.3 面试前的冲刺建议
面试前一周,不要再刷新题,而是复习经典题。把爬楼梯、零钱兑换、最长递增子序列、编辑距离、01 背包这几道题写熟,做到不看答案能默写。
面试前一天,早点休息,不要熬夜刷题。动态规划需要清晰的思维,疲劳状态下容易出错。
面试中,如果遇到没见过的题,先深呼吸,然后按四步框架分析:定义状态、写转移方程、确定初始条件、确定遍历顺序。即使最后没写出完整代码,只要思路清晰,面试官也会给分。
最后分享一个我个人的习惯:每次写完动态规划代码,我都会用一个小例子手动模拟一遍表格的填充过程。这个习惯帮我发现了无数个边界错误和遍历顺序错误。看起来费时间,但实际上比调试省时间得多。