人人都能学编程
English 开始学习
核心 · 算法第 8 课 / 共 8 课免费阅读

动态规划 · 数据结构与算法

怎么避免一遍又一遍地解同一个小问题?

课程目录

为什么需要它

有些递归解法会把同样的调用重复几百万次。动态规划(DP)把每个小问题的答案只算一次并保存起来重复使用,把指数时间变成线性时间。路线规划、拼写检查,以及很多较难的面试题背后都是它。

直觉理解

爬楼梯,每次走 1 级或 2 级:到达第 n 级的方法数 = ways(n − 1) + ways(n − 2)。直接递归会一遍遍重复计算 ways(3);每多一级,调用次数差不多翻一倍。

两种解决办法:

  • 记忆化(自顶向下):保留递归,但把答案存进字典。Python 的 @functools.lru_cache 能帮你做。
  • 填表(自底向上):从最小的情况开始填一个列表:ways[i] = ways[i-1] + ways[i-2]。

最难的是第一步:定义一个小答案是什么意思(「ways[i] = 到达第 i 级的方法数」),以及它怎么由更小的答案构成。

定义

动态规划

Solving a problem by combining answers to overlapping subproblems, computing each subproblem once.
通过组合重叠子问题的答案来解决问题,每个子问题只算一次。

记忆化

Caching a function's results by its arguments, so repeated calls return instantly.
按参数缓存函数的结果,重复调用时立即返回。

示例

数一数重复的工作

动手试试

直接递归和记忆化递归,各调用了多少次。

calls = 0

def ways(n):
    global calls
    calls += 1
    if n <= 1:
        return 1
    return ways(n - 1) + ways(n - 2)

print(ways(20), calls)

memo = {}
calls = 0

def ways_memo(n):
    global calls
    calls += 1
    if n <= 1:
        return 1
    if n not in memo:
        memo[n] = ways_memo(n - 1) + ways_memo(n - 2)
    return memo[n]

print(ways_memo(20), calls)
逐步执行
  1. L8每次调用又产生两次调用:次数爆炸。
  2. L1010946 种方法,21891 次调用。10946 21891
  3. L20每个 n 只算一次。
  4. L24答案一样,只调用了 39 次。10946 39

最少硬币,自底向上

动手试试

best[a] 是凑出金额 a 所需的最少硬币数。从 0 开始往上填。

def fewest_coins(coins, amount):
    INF = amount + 1
    best = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and best[a - c] + 1 < best[a]:
                best[a] = best[a - c] + 1
    return best[amount] if best[amount] != INF else -1

print(fewest_coins([1, 3, 4], 6))
print(fewest_coins([5, 10], 3))
逐步执行
  1. L30 个硬币凑出 0。INF 表示「目前还凑不出」。
  2. L6在凑出 a − c 的最佳方案上再加一枚 c。
  3. L103 + 3:两枚。(贪心的 4 + 1 + 1 要三枚。)2
  4. L115 和 10 怎么组合都凑不出 3。-1

常见错误

用贪心代替 DP

总是拿最大的硬币,在硬币 [1, 3, 4]、金额 6 时就会出错。当一个选择会影响后面的选择时,考虑 DP。

状态定义含糊

如果你没法用一句话说清 best[i] 是什么意思,递推式就会写错。先写下这句话。

小测验

1. 这段代码会输出什么?

ways = [1, 1]
for i in range(2, 6):
    ways.append(ways[i - 1] + ways[i - 2])
print(ways)
  1. A
    [1, 1, 2, 3, 5, 8]
  2. B
    [1, 1, 2, 4, 8, 16]
  3. C
    [1, 1, 2, 3, 5]
查看答案

答案:A

每一项都是前两项之和。

2. 什么样的问题适合用动态规划?

  1. A同样的子问题反复出现,而且最优答案由子问题的最优答案构成
  2. B输入是有序的
  3. C它需要队列
查看答案

答案:A

重叠子问题加上最优子结构:这时缓存才划算。

动手练习

3 道练习,分三层:先预测程序的输出,再按新需求改程序,最后从零写一个。代码在浏览器里运行,提交马上判题。

  1. 预测输出填表

    准确写出这段程序的输出。

  2. 改需求加上记忆化

    grid_paths(r, c) 计算在 r × c 的网格里,从左上角只向右或向下走到右下角的路径数。它是对的,但是指数级的。加上记忆化字典(或 functools.lru_cache),让速度测试通过。

  3. 从零写最少硬币

    写函数 fewest_coins(coins, amount),返回凑出 amount 所需的最少硬币数;凑不出返回 -1。从 0 到 amount 建一张表 best。

在互动课里练这一课

打开互动课,这些例子都能直接改、直接运行,练习题提交马上判题。免费开始,不用注册。

互动课按顺序解锁:前面的课还没学,会先带你到课程地图。

本课术语

术语中文英文定义
Dynamic programming动态规划Reusing answers to overlapping subproblems instead of recomputing them.
Memoisation记忆化Caching a function's results by its arguments.