面试常用算法 · 第 06 章
动态规划与贪心
第 05 章的回溯能枚举所有方案,但代价是指数级——这一章解决"枚举太慢":当子问题重叠时,记忆化与递推把指数砍成多项式;贪心则更激进,赌每步局部最优就够。
本章骨架
- DP 的两前提是重叠子问题 + 最优子结构,缺一不可。
- 记忆化是带缓存的回溯——自顶向下,递归一次就把结果存起来。
- 递推是把递归翻成填表——自底向上,按依赖顺序逐格算。
- 贪心是放弃回溯的赌注,只在贪心选择性质成立时才对。
§6.1从回溯到 DP:重叠子问题
最优子结构:大问题的最优解由子问题的最优解拼成。
第 05 章的回溯在一棵决策树上穷举,节点数随深度指数增长。如果这棵树上有大量完全相同的节点被反复求解,指数级里就藏着可压缩的冗余。斐波那契是看清这一点的最短例子。
定义 f(n) = f(n-1) + f(n-2),边界 f(0)=0, f(1)=1。朴素递归直接照搬定义,复杂度 O(2ⁿ)——不是因为结果多,而是因为同一个 f(k) 被不同的调用路径重复计算无数次。下图把这种重复画出来。
f(5) 的递归树:f(3) 出现两次、f(2) 出现三次(红色)。注意:节点总数随 n 指数增长,但不同的子问题只有 n 个——重叠子问题正是这道缝,记忆化与递推都从这里把指数缝合成线性。下面把同一个斐波那契写三版,看复杂度如何随"是否消除重复"逐级下降。
from functools import lru_cache
# 版本 1:朴素递归。直接照搬定义。
# 复杂度 O(2^n):同一个 f(k) 沿不同路径被重复展开。
def fib_naive(n: int) -> int:
if n < 2: # 边界:f(0)=0, f(1)=1
return n
return fib_naive(n - 1) + fib_naive(n - 2)
# 版本 2:记忆化(自顶向下)。给版本 1 加一层缓存。
# 每个 f(k) 只真正计算一次,命中缓存直接返回 ⇒ O(n) 时间、O(n) 空间。
@lru_cache(maxsize=None) # 缓存即"备忘录",结构与版本 1 完全一致
def fib_memo(n: int) -> int:
if n < 2:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
# 版本 3:递推(自底向上)。把递归翻成填表,从边界顺着算到 n。
# 只需保留最近两个值 ⇒ O(n) 时间、O(1) 空间。
def fib_iter(n: int) -> int:
if n < 2:
return n
prev, cur = 0, 1 # prev=f(0), cur=f(1)
for _ in range(2, n + 1): # 转移:新值 = 前两个之和
prev, cur = cur, prev + cur
return cur
if __name__ == "__main__":
assert fib_naive(10) == 55
assert fib_memo(50) == 12586269025 # 朴素版在 n=50 会卡死
assert fib_iter(50) == 12586269025
print("ok")
版本 1→2结构一字未改,只在函数上加了 @lru_cache。记忆化的本质就是带缓存的回溯:递归还在自顶向下展开,但每个子问题求解一次后结果入缓存,重复调用直接命中。指数级的树被剪成 n 个不同节点。
版本 2→3把"递归向下问"翻转成"循环向上填"。版本 2 的缓存表被版本 3 显式地从 f(0) 顺着填到 f(n);既然只用到相邻两格,连数组都省了,空间从 O(n) 降到 O(1)。这是递推相对记忆化的常见红利。
记忆化与递推是同一套 DP 的两种实现方向,复杂度相同。记忆化保留递归形态、只算用得到的状态(适合状态稀疏);递推显式控制填表顺序、易做空间压缩(适合状态稠密)。选哪个看题,不看高下。
§6.2设计一个 DP:状态 / 转移 / 边界
定义 dp[i] 表示什么、写出转移方程、确定边界初值——三件事齐了,DP 就成了。
斐波那契的状态天然只有一维。真正的 DP 设计要自己回答三个问题:
- 状态:
dp[i](或dp[i][j])表示什么?这是整个 DP 的锚,定义错了后面全错。 - 转移:
dp[i] = f(dp[更小的状态])——大状态如何由小状态拼出。这一步要求最优子结构成立。 - 边界:最小的状态初值是多少,从哪里开始填。
自顶向下(记忆化)与自底向上(递推)只是同一个状态转移图的两种遍历方向,等价。下面用 0-1 背包把三件事走一遍。
题面:有 n 件物品,第 i 件重 w[i]、值 v[i],背包容量 W,每件物品选或不选(0-1),求能装下的最大价值。
第 01 章的复杂度分析在这里直接复用:暴力枚举每件物品选不选是 2ⁿ 种方案;而状态只有 n × W 个,每个状态 O(1) 转移,DP 把 O(2ⁿ) 压到 O(n·W)。
状态定义:dp[i][c] = 只在前 i 件物品里挑、容量上限为 c 时的最大价值。转移看第 i 件物品选不选:
- 不选第
i件:价值 =dp[i-1][c]。 - 选第
i件(需c ≥ w[i]):价值 =dp[i-1][c-w[i]] + v[i]。
取两者较大。边界:dp[0][*] = 0(没有物品,价值为零)。
dp[i][c](红格)只来自上一行的两格——正上方 dp[i-1][c](不选)和左上方 dp[i-1][c-wᵢ](选)。注意:当前行只依赖上一行,这正是后面能压成一维的根据。def knapsack_2d(weights: list[int], values: list[int], cap: int) -> int:
"""二维 DP。dp[i][c] = 前 i 件、容量 c 上限下的最大价值。O(n*cap) 时间与空间。"""
n = len(weights)
dp = [[0] * (cap + 1) for _ in range(n + 1)] # 边界:第 0 行全为 0
for i in range(1, n + 1):
wi, vi = weights[i - 1], values[i - 1] # 第 i 件物品(下标从 0)
for c in range(cap + 1):
dp[i][c] = dp[i - 1][c] # 不选第 i 件
if c >= wi: # 容量够才能选
dp[i][c] = max(dp[i][c], dp[i - 1][c - wi] + vi)
return dp[n][cap]
def knapsack_1d(weights: list[int], values: list[int], cap: int) -> int:
"""一维压缩。只保留一行,容量逆序遍历。O(n*cap) 时间、O(cap) 空间。"""
dp = [0] * (cap + 1)
for wi, vi in zip(weights, values):
for c in range(cap, wi - 1, -1): # 逆序:见下方"想一想"
dp[c] = max(dp[c], dp[c - wi] + vi)
return dp[cap]
if __name__ == "__main__":
w = [2, 3, 4, 5]
v = [3, 4, 5, 6]
assert knapsack_2d(w, v, 8) == 10 # 选物品 (w=3,v=4) + (w=5,v=6)
assert knapsack_1d(w, v, 8) == 10
print("ok")
二维表里每一行只依赖上一行(图 6.2)——那能不能只用一维数组、原地更新?如果能,为什么 knapsack_1d 的容量循环要逆序?正序会出什么错?
展开:一维压缩与逆序遍历的理由
能压成一维。因为 dp[i][c] 只读上一行,把二维降成一行滚动即可,空间从 O(n·cap) 降到 O(cap)。
逆序是关键。一维数组里 dp[c] 在被覆盖前存的是"上一行"的值,即 dp[i-1][c]。转移要读 dp[c-wi],它必须也还是上一行的值。
若正序遍历容量,dp[c-wi] 在本轮已经被更新成"本行"的值,相当于允许第 i 件物品被选多次——那解的是完全背包,不是 0-1。逆序遍历时 dp[c-wi] 还没被本轮触碰,仍是上一行的值,保证每件物品最多选一次。
§6.3另一类经典:序列 DP
序列 DP 的状态常是"以第 i 个元素结尾"的某个最优量。
背包的状态由"物品 + 容量"两维构成。另一大类题——序列 DP——状态往往挂在序列的某个位置上。最长递增子序列(LIS)是标准入口。
题面:给一个整数数组,求最长严格递增子序列的长度(子序列可不连续)。
状态定义是这里的关键决策:dp[i] = 以 nums[i] 结尾的最长递增子序列长度。"以 i 结尾"这个限定让转移有了抓手——要接在某个更靠前、且值更小的 j 后面。
转移:dp[i] = max(dp[j] + 1),其中 j < i 且 nums[j] < nums[i];若没有这样的 j,则 dp[i] = 1(自身成一段)。边界:每个 dp[i] 初值为 1。答案是 max(dp),不是 dp[-1](最长子序列未必以最后一个元素结尾)。
from bisect import bisect_left
def lis_dp(nums: list[int]) -> int:
"""O(n^2) DP。dp[i] = 以 nums[i] 结尾的最长递增子序列长度。"""
if not nums:
return 0
n = len(nums)
dp = [1] * n # 边界:每个元素自成长度 1
for i in range(n):
for j in range(i): # 枚举所有更靠前的 j
if nums[j] < nums[i]: # 能接在 j 后面(严格递增)
dp[i] = max(dp[i], dp[j] + 1)
return max(dp) # 答案在全表最大,不在末尾
def lis_binary(nums: list[int]) -> int:
"""O(n log n) 优化。tails[k] = 长度为 k+1 的递增子序列的最小末尾值。
复用第 04 章的二分:每个新元素用 bisect 找它该替换的位置。"""
tails: list[int] = []
for x in nums:
pos = bisect_left(tails, x) # 第一个 >= x 的位置
if pos == len(tails):
tails.append(x) # x 比所有末尾都大 ⇒ 延长
else:
tails[pos] = x # 否则替换,保持各长度末尾最小
return len(tails)
if __name__ == "__main__":
a = [10, 9, 2, 5, 3, 7, 101, 18]
assert lis_dp(a) == 4 # 例如 2,3,7,18 或 2,5,7,101
assert lis_binary(a) == 4
print("ok")
状态dp[i] 限定"以 nums[i] 结尾",转移才能落到"接在某个 j 后"这一具体动作上。若状态定义成"前 i 个元素的 LIS 长度",转移反而写不出来——因为它不告诉你当前子序列的末尾值是多少,无法判断下一个元素能否接上。状态定义决定转移能否成立,这是序列 DP 最容易翻车的一步。
二分优化lis_binary 用 tails 数组维护"各长度递增子序列的最小末尾",借第 04 章的 bisect_left 把内层 O(n) 扫描换成 O(log n) 查找,整体降到 O(n log n)。它返回的是长度,不再是每个位置的 dp 值——这是用结构换速度的典型取舍。
§6.4贪心:何时能赌赢
每步取当前最优,不回头。
DP 保留所有子问题的最优解、靠转移把它们拼起来,本质是"全都试、用缓存省"。贪心更激进:它在每一步只认死当前看起来最好的选择,做完不反悔、不回溯。这让贪心通常很快(排序主导,常 O(n log n)),但也极易出错——局部最优不等于全局最优。
贪心成立必须证明贪心选择性质:存在一个全局最优解,包含"每步的贪心选择"。常用工具是交换论证——假设最优解没用贪心选择,那么把它换成贪心选择后解不会变差,于是贪心选择也能导出最优。
经典正例是区间调度:给若干区间 [start, end),选出最多的两两不重叠区间。贪心策略是按结束时间排序,每次选结束最早且与已选不冲突的区间。直觉:结束越早,给后面留的空间越多。
def max_non_overlapping(intervals: list[tuple[int, int]]) -> int:
"""区间 [start, end)。返回最多能选的两两不重叠区间数。O(n log n)。"""
if not intervals:
return 0
intervals.sort(key=lambda iv: iv[1]) # 关键:按结束时间升序
count = 0
last_end = float("-inf") # 已选区间的最晚结束时间
for start, end in intervals:
if start >= last_end: # 与已选不重叠 ⇒ 贪心选中
count += 1
last_end = end
return count
if __name__ == "__main__":
ivs = [(1, 3), (2, 5), (4, 7), (6, 8), (8, 10)]
assert max_non_overlapping(ivs) == 3 # (1,3) (4,7) (8,10)
print("ok")
贪心不是万能。找零问题:用面值凑出目标金额、求最少硬币数。对面值 [1, 5, 10, 25],"每次取不超过余额的最大面值"恰好正确;但换成面值 [1, 3, 4] 凑 6,贪心先取 4,剩 2 只能 1+1,得 4+1+1=3 枚;最优却是 3+3=2 枚。贪心证不出贪心选择性质时就会这样翻车——这类题必须退回 DP。
§6.5备选方案:求最优解,到底用哪个
| 方案 | 时间复杂度 | 适用前提 | 代价 / 风险 |
|---|---|---|---|
| 回溯(朴素穷举) | O(指数) | 无特殊结构,任何可枚举问题 | 规模稍大即超时;只在 n 很小或需全部方案时用 |
| 记忆化(自顶向下 DP) | O(状态数 × 单次转移) | 重叠子问题 + 最优子结构 | 递归栈开销;状态稀疏时只算用得到的 |
| 递推(自底向上 DP) | 同记忆化 | 同上,且能定出填表顺序 | 需想清依赖顺序;常能做空间压缩,更省 |
| 贪心 | O(n log n) | 贪心选择性质成立(需证明) | 最快,但证不出来就会给出错误解 |
一句判别:能贪心就贪心(最快),贪心证不出来就上 DP,DP 状态爆炸就退回回溯 + 剪枝。"selected"那行(贪心)只在能证明时才是首选——证不出来时,它反而是最危险的那个。
自测 · 合上答案再看
- DP 必须同时满足哪两个前提?缺一个会怎样?
- 记忆化和递推是什么关系?各自在什么场景更顺手?
- 为什么斐波那契朴素递归是 O(2ⁿ),而记忆化后变 O(n)?区别在哪一步发生?
- 面对一道求最优解的题,贪心和 DP 怎么选?判别的关键证据是什么?
展开:参考答案
1. 重叠子问题(同一子问题被反复求解,缓存才有意义)+ 最优子结构(大问题最优解由子问题最优解拼成,转移才成立)。缺重叠子问题,记忆化没收益、退化成回溯;缺最优子结构,转移方程根本写不对。
2. 同一套 DP 的两种实现方向,复杂度相同。记忆化保留递归、按需求解(状态稀疏更省、写起来贴近定义);递推显式填表、易做空间压缩(状态稠密、需空间优化时更顺)。
3. 朴素递归把同一个 f(k) 沿不同路径重复展开,节点数指数增长(图 6.1)。记忆化在第一次求出 f(k) 后入缓存,之后命中即返回——不同的子问题只有 n 个,于是降到 O(n)。区别发生在"是否复用已算过的子问题结果"。
4. 先试贪心:能证明贪心选择性质(通常用交换论证)就用贪心,最快。证不出或找得到反例(如找零 [1,3,4] 凑 6),退回 DP。关键证据是"局部最优能否导出全局最优"。
编辑距离(Edit Distance)
给两个字符串 word1、word2,每次可插入、删除或替换一个字符。求把 word1 变成 word2 的最少操作次数。先合上提示,自己定义状态、写出转移方程,再动手编码。
展开:状态定义提示
令 dp[i][j] = 把 word1 前 i 个字符变成 word2 前 j 个字符的最少操作数。边界:dp[i][0]=i(删空)、dp[0][j]=j(全插)。转移:若 word1[i-1]==word2[j-1] 则 dp[i][j]=dp[i-1][j-1](不动);否则取 1 + min(dp[i-1][j](删), dp[i][j-1](插), dp[i-1][j-1](替))。答案 dp[len1][len2],复杂度 O(len1·len2)。变体练习:零钱兑换最少硬币数(dp[a] = 凑出金额 a 的最少硬币数,转移遍历每种面值)。