面试常用算法 · 第 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(4) f(3) f(3) f(2) f(2) f(2) f(1) 红色节点 = 在树中重复出现 ⇒ 重复计算
图 6.1计算 f(5) 的递归树:f(3) 出现两次、f(2) 出现三次(红色)。注意:节点总数随 n 指数增长,但不同的子问题只有 n 个——重叠子问题正是这道缝,记忆化与递推都从这里把指数缝合成线性。

下面把同一个斐波那契写三版,看复杂度如何随"是否消除重复"逐级下降。

fib_three_versions.pyPython
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(没有物品,价值为零)。

c−wᵢ c i−1 i 选 i 不选 i dp[i][c] dp[i][c] = max( dp[i−1][c], dp[i−1][c−wᵢ] + vᵢ ) 每格只依赖上一行 ⇒ 行可滚动复用
图 6.2背包 DP 表的填充依赖:dp[i][c](红格)只来自上一行的两格——正上方 dp[i-1][c](不选)和左上方 dp[i-1][c-wᵢ](选)。注意:当前行只依赖上一行,这正是后面能压成一维的根据。
knapsack.pyPython
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](最长子序列未必以最后一个元素结尾)。

lis.pyPython
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),选出最多的两两不重叠区间。贪心策略是按结束时间排序,每次选结束最早且与已选不冲突的区间。直觉:结束越早,给后面留的空间越多。

按结束时间排序 ⇒ 贪心选中 3 个(红) A B C D E 选 A → 跳过与 A 重叠的 B → 选 C → 跳过 D → 选 E:共 3 个 ✓ 按开始时间排序 ⇒ 反例:先选最长那个,只剩 1 个 X(最早开始,但跨度极长) Y Z 先选 X 就占满全程,Y、Z 都被挡 ⇒ 只得 1 个 ✗(最优应是 Y+Z = 2)
图 6.3区间调度:按结束时间排序贪心(上)选中 A、C、E 三个不重叠区间。注意:换成按开始时间排序(下)会先抓住跨度最长的 X,把后面挤光——同一道题,排序键选错,贪心立刻失败。贪心对不对,全押在排序键的证明上。
interval_scheduling.pyPython
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备选方案:求最优解,到底用哪个

表 6.1 · 求最优解的四种武器
方案时间复杂度适用前提代价 / 风险
回溯(朴素穷举) O(指数) 无特殊结构,任何可枚举问题 规模稍大即超时;只在 n 很小或需全部方案时用
记忆化(自顶向下 DP) O(状态数 × 单次转移) 重叠子问题 + 最优子结构 递归栈开销;状态稀疏时只算用得到的
递推(自底向上 DP) 同记忆化 同上,且能定出填表顺序 需想清依赖顺序;常能做空间压缩,更省
贪心 O(n log n) 贪心选择性质成立(需证明) 最快,但证不出来就会给出错误解

一句判别:能贪心就贪心(最快),贪心证不出来就上 DP,DP 状态爆炸就退回回溯 + 剪枝。"selected"那行(贪心)只在能证明时才是首选——证不出来时,它反而是最危险的那个。

自测 · 合上答案再看

  1. DP 必须同时满足哪两个前提?缺一个会怎样?
  2. 记忆化和递推是什么关系?各自在什么场景更顺手?
  3. 为什么斐波那契朴素递归是 O(2ⁿ),而记忆化后变 O(n)?区别在哪一步发生?
  4. 面对一道求最优解的题,贪心和 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 的最少硬币数,转移遍历每种面值)。