跳转至

第 100 章 DP 入门

本章配套 9 道例题,见 §例题。

动态规划是笔试里出现频率最高的算法族。这一章不讲任何具体模型,只讲怎么把一个问题 变成 DP——这套方法论用得熟,后面四章的模型都只是它的实例。


1 从贪心的失败讲起

先看一个经典的开场例子。

硬币问题:有面值 \(1, 5, 11\) 的硬币各无限枚,凑出 \(15\) 最少要几枚?

贪心的想法是「每次拿最大的」:\(11 + 1 + 1 + 1 + 1 = 15\),用了 5 枚。

但正确答案是 \(5 + 5 + 5 = 15\),只要 3 枚。

贪心为什么错?因为它鼠目寸光——拿了 \(11\) 之后,剩下的 \(4\) 只能用 \(1\) 来凑, 这个代价在做决策时看不见。而如果面值是 \(1, 5, 10\)(人民币),贪心恰好是对的。 贪心的正确性依赖于面值的特殊结构,不是普适的。

DP 的做法是:不去猜哪个决策好,而是把每种决策都试一遍,并复用子问题的答案。

\[f(x) = \min\big(f(x-1),\ f(x-5),\ f(x-11)\big) + 1\]
# [片段]
INF = float("inf")
f = [0] + [INF] * n               # f[0] = 0:凑 0 元不需要硬币;其余先设成「凑不出」
for x in range(1, n + 1):         # 从小到大算,保证用到 f[x-c] 时它已经算好了
    for c in (1, 5, 11):          # 枚举「最后一枚硬币」是哪种面值
        if x >= c and f[x - c] + 1 < f[x]:   # 面值不能超过剩余金额
            f[x] = f[x - c] + 1   # 拿掉这一枚,剩下的正是子问题 f[x-c]

DP 与贪心的本质区别:贪心在每一步扔掉其余选项,DP 把所有选项都算完再取最优。 DP 比暴力搜索快,是因为它把重复的子问题只算一次。


2 DP 的两个前提

一个问题能用 DP,必须同时满足两条。

无后效性

当前状态一旦确定,未来的决策就与「怎么走到这个状态」无关。

数楼梯里,「现在站在第 \(i\) 阶」这个状态包含了未来所需的全部信息—— 从 \(i-1\) 迈上来还是从 \(i-2\) 跳上来,对后面的走法毫无影响。所以无后效性成立。

反例:如果题目说「不能连续两次迈 2 阶」,那么「站在第 \(i\) 阶」就不够了, 未来还依赖「上一步迈了几阶」。这时要把状态加宽:

\[f[i][0] = \text{站在 } i \text{ 阶且上一步迈 1 阶的方案数}, \quad f[i][1] = \cdots \text{迈 2 阶}\cdots\]

这是设计 DP 状态的核心动作:发现无后效性不成立时,不是放弃 DP, 而是把缺失的信息补进状态里。BISHI135 就是反例——它看起来需要补一维, 实际上换个坐标系就不用了,见 线性DP。

最优子结构

大问题的最优解,由子问题的最优解拼成。

凑 \(15\) 的最优方案里,如果最后一枚是 \(5\),那么凑 \(10\) 的那部分必然也是最优的—— 否则把它换成更优的方案,总方案会更优,矛盾。

反例:求「路径上数字之和为质数」的路径。子路径的和是质数,并不能推出总和是质数, 子问题的「最优」在这里没有意义。


3 设计一个 DP 的四步法

这是推荐的固定流程,四步缺一不可。

步骤 要回答的问题 数楼梯的答案
1. 状态 用什么变量刻画「一个局面」? \(f_i\) = 走到第 \(i\) 阶的方案数
2. 转移 这个局面能由哪些更小的局面得到? 最后一步从 \(i-1\) 迈 1 阶,或从 \(i-2\) 迈 2 阶
3. 边界 最小的局面是什么? \(f_1 = 1\),\(f_2 = 2\)
4. 目标 答案对应哪个状态? \(f_n\)

转移的推导有一个万能技巧:枚举「最后一步」。

问「走到第 \(i\) 阶的方案数」,就问「最后一步是怎么走的」。最后一步只可能是迈 1 阶 或迈 2 阶,这两类方案互不重叠(最后一步的长度不同)且覆盖全部情况, 所以直接相加:

\[f_i = f_{i-1} + f_{i-2}\]

「互不重叠 + 覆盖全部」是计数类 DP 的正确性依据,必须显式检查这两条。 漏了会算少,重了会算多——重复计数是计数 DP 最常见的错误。

对最优化类 DP,把加法换成 \(\min\) / \(\max\) 即可,这就是 BISHI132 与 BISHI131 的关系。


4 两种实现:递推 vs 记忆化

同一个 DP 有两种写法。

递推(自底向上)

# [片段]
f = [0] * (n + 1)
f[1], f[2] = 1, 2                 # 边界:1 阶只有 1 种走法,2 阶有「1+1」和「2」两种
for i in range(3, n + 1):         # 正序:算 f[i] 时 f[i-1]、f[i-2] 都已经就位
    f[i] = f[i - 1] + f[i - 2]    # 按「最后一步迈 1 阶 / 迈 2 阶」分类,两类不重不漏

记忆化搜索(自顶向下)

# [片段]
from functools import lru_cache


@lru_cache(maxsize=None)          # 缓存所有算过的 f(i),同一个状态只真正展开一次
def f(i):
    if i <= 2:                    # 边界恰好满足 f(1)=1、f(2)=2,可以合并成一句
        return i
    return f(i - 1) + f(i - 2)    # 依赖关系由递归自动处理,不用操心枚举顺序
递推 记忆化
代码量 稍多 少,几乎就是把公式抄一遍
需要想清楚 枚举顺序 不需要,递归自动处理依赖
只算用得到的状态 ❌ 全算 ✅ 按需
Python 性能 快 慢 5–20 倍(函数调用 + 缓存查表)
递归深度 无风险 \(n = 10^5\) 直接 RecursionError

在 Python 里,能写递推就不要写记忆化。 这不是风格问题:CPython 的函数调用开销极大,而记忆化搜索每个状态至少一次调用。 \(10^6\) 个状态的记忆化搜索通常要几秒,同样的递推只要 0.1 秒。

记忆化的价值在于状态空间稀疏(大部分状态用不到)或转移顺序难以确定(如博弈、图上 DP)。 详见 记忆化搜索与剪枝。


5 滚动数组

数楼梯的 \(f_i\) 只依赖 \(f_{i-1}\) 和 \(f_{i-2}\),那就没必要存整个数组:

# [片段]
a, b = 1, 2                    # a、b 始终是「相邻两阶」的答案,初值为 f[1], f[2]
for _ in range(3, n + 1):      # 循环变量用不上,只关心迭代次数
    a, b = b, (a + b) % MOD    # 右边整体先算完再赋值,等价于同时更新两个变量

空间从 \(O(n)\) 降到 \(O(1)\)。滚动的三种形态:

依赖关系 滚动方式
\(f_i\) 只依赖 \(f_{i-1}, f_{i-2}\) 两个标量变量
\(f[i][\cdot]\) 只依赖 \(f[i-1][\cdot]\) 两个一维数组交替,或直接原地倒序更新
\(f[i][j]\) 依赖 \(f[i-1][j], f[i][j-1]\) 一维数组原地正序更新

背包的一维写法就是滚动数组,而「倒序还是正序」的区别正好对应 01 背包与完全背包。 这是全书最经典的一个细节,见 背包问题。

Python 里滚动数组还有一个额外好处:数组更短 ⇒ 更容易整段丢给 C 层批处理, 详见 §7。


6 例题

题号 题名 难度 题解
BISHI131 数楼梯 简单 题解
BISHI132 小红的地砖 简单 题解
BM62 斐波那契数列 入门 题解
BM63 跳台阶 简单 题解
BM64 最小花费爬楼梯 简单 题解
LC62 不同路径 中等 题解
LC64 最小路径和 中等 题解
LC70 爬楼梯 简单 题解
LC118 杨辉三角 简单 题解

BISHI131 数楼梯(简单)

每步走 1 或 2 阶,求走到第 \(n\) 阶的方案数,对 \(998244353\) 取模。\(n \le 10^5\)。

四步法直接套用,就是上面的 \(f_i = f_{i-1} + f_{i-2}\),也就是斐波那契数列(错开一位)。

import sys

MOD = 998244353


def main():
    n = int(sys.stdin.buffer.read().split()[0])
    if n == 1:                        # n=1 时下面的循环一次都不跑,b 会停在 f[2] 上
        print(1)
        return
    a, b = 1, 2                       # f[1] = 1, f[2] = 2
    for _ in range(3, n + 1):         # 每轮把窗口往前挪一阶,结束时 b 就是 f[n]
        a, b = b, (a + b) % MOD       # 每步取模,f 始终是十位以内的小整数
    print(b % MOD)                    # n=2 时 b 还没取过模,这里补一次


main()

三个坑:

  1. \(n = 1\) 要特判,否则 \(f_2\) 的初值会被当成答案输出。
  2. 每步都要取模。Python 大整数不会溢出,但 \(f_{10^5}\) 有约 2 万位, 大整数加法退化成 \(O(\text{位数})\),总复杂度变成 \(O(n^2/64)\),白慢几十倍。 这是 Python 特有的陷阱——C++ 选手不取模是「答案错」,Python 选手不取模是「超时」。
  3. \(n \le 10^5\) 用不着矩阵快速幂。矩阵加速见 DP优化。

题解:solutions/nowcoder/BISHI131/sol.py(已通过官方样例验证)

BISHI132 小红的地砖(简单)

从第 1 块走到第 \(n\) 块,每步走 1 或 2 格,踩到第 \(i\) 块消耗体力 \(a_i\),求最小总体力。

这题和 BISHI131 是同一个 DP,只是把「计数的加法」换成了「求最优的 min」。

\[f_i = a_i + \min(f_{i-1},\ f_{i-2})\]

含义:走到第 \(i\) 块必然是从 \(i-1\) 或 \(i-2\) 迈过来的,而无论从哪来,踩上 \(i\) 都要付 \(a_i\)。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    if n == 1:                        # 只有一块地砖时不用走,也就不消耗体力
        print(0)
        return
    prev2, prev1 = a[0], a[0] + a[1]  # f[1], f[2]:第 2 块只能由第 1 块迈过来
    for i in range(2, n):             # 下标 i 对应第 i+1 块地砖
        # 从 i-1 或 i-2 迈过来都要踩上 a[i],所以先取两者较小再加 a[i]
        prev2, prev1 = prev1, a[i] + (prev1 if prev1 < prev2 else prev2)
    print(prev1)                      # 循环结束时 prev1 就是 f[n]


main()

计数 DP 与最优化 DP 是同构的:状态、转移、边界的推导完全一样, 只是合并子问题时一个用 \(+\)、一个用 \(\min\)/\(\max\)。 把这两题放在一起看,能省下一半的学习成本。

题解:solutions/nowcoder/BISHI132/sol.py(已通过官方样例验证)


面试题单里的三道入门题

这三道题可以直接拿来练 §3 的四步法——动态规划的入门题几乎都长成这个样子。

斐波那契数列与 跳台阶是同一个递推的两种题面: \(f_i = f_{i-1} + f_{i-2}\),差别只在边界。 跳台阶的 \(f_1 = 1, f_2 = 2\),斐波那契的 \(f_1 = f_2 = 1\)—— 四步法里的「边界」这一步就是为这种差别准备的,状态与转移一模一样时, 写错的地方必然在边界上。两题都只依赖前两项,按 §5 滚成两个变量即可,空间 \(O(1)\)。

最小花费爬楼梯把计数换成了最优化:每级台阶有代价,求登顶的最小总代价。 转移从 \(f_{i-1} + f_{i-2}\) 变成 \(\min(f_{i-1} + c_{i-1},\ f_{i-2} + c_{i-2})\)—— 正是 §3 末尾那句「对最优化类,把加法换成 \(\min\) / \(\max\)」的字面实现。 它唯一的坑在目标:可以从第 0 级或第 1 级起步,也可以从倒数第二级直接跨到终点, 所以答案是 \(f_n\) 而不是 \(f_{n-1} + c_{n-1}\),四步法的第 4 步「目标」必须单独想清楚。

另一处题库里的四道入门题

LC 70. 爬楼梯 与 BISHI131 数楼梯是同一个递推 \(f_i = f_{i-1} + f_{i-2}\), 只是数据范围小得多(\(n \le 45\)),滚动两个变量即可,\(O(n)\) 时间、\(O(1)\) 空间。 它最值得记的一点是:这个数列就是斐波那契数列,只是起点错开一位。

LC 62. 不同路径与 LC 64. 最小路径和:同一张网格,一个数方案、一个求最优。 两题的状态都是「走到格子 \((i, j)\) 的答案」,转移都只从上方与左方来, 差别只在合并方式——LC62 是相加(计数),LC64 是取最小再加上当前格的代价(最优化)。 这正是计数与最优化在同一个状态空间上的对照:状态、转移、遍历顺序全都一样, 换掉的只有那个二元运算。两题都能压成一维滚动数组,\(O(mn)\) 时间、\(O(n)\) 空间。 LC62 另有一条组合数的闭式解:总共走 \(m + n - 2\) 步、其中 \(m - 1\) 步向下, 答案就是 \(\binom{m+n-2}{m-1}\)。

LC 118. 杨辉三角:状态表本身就是答案的那一类题。 递推是 \(C[i][j] = C[i-1][j-1] + C[i-1][j]\),即组合数的加法递推, \(O(n^2)\) 时间与空间——题面要的就是整张表,这里没有压维的余地。 逐行生成时把内层循环交给列表推导 ([1] + [a[j] + a[j+1] for j in range(len(a) - 1)] + [1]), 比双重下标赋值少一层解释开销。

7 Python 写 DP 的三条铁律

这三条会在后面四章反复出现,先在这里立住。

每步取模,不要最后才取

见 BISHI131 的坑 2。凡是计数类 DP,转移里就写 % MOD。

把最内层循环下沉到 C 层

这是 Python DP 优化的全部秘诀。以 01 背包的一维转移为例:

# [片段]
# 慢:Python 层循环,1e6 次迭代;倒序是为了让 f[c-v] 保持「上一轮」的值
for c in range(V, v - 1, -1):
    if f[c - v] + w > f[c]:
        f[c] = f[c - v] + w

# 快:整段批处理,1e6 次元素操作全在 C 层,快 5-8 倍
# 右边的候选列表在赋值前就用旧的 f 算完了,所以连倒序都不需要
f[v:] = list(map(max, f[v:], [x + w for x in f[:V + 1 - v]]))

两者等价的关键:右边的候选数组是在赋值之前用旧的 f 算好的, 所以「同一件物品不会被重复选」这个 01 语义自动成立。

这个改写贯穿 背包问题 全章, 也是 solutions/nowcoder/BISHI136/sol.py ~ BISHI142.py 全部采用的写法。 原理见 复杂度与Python性能 §4。

树形 DP 必须迭代,不能递归

\(n = 2\times10^5\) 的链状树,递归深度就是 \(2\times10^5\),setrecursionlimit 也救不了 (会爆 C 栈段错误)。见 树形 DP。


8 计数 DP 与取模

三种形态都可能出计数版本(把 \(\min\)/\(\max\) 换成 \(+\))。要点:

要点 说明
每步取模 否则大整数膨胀,见本章 §7
「互不重叠 + 覆盖全部」 计数 DP 的正确性依据,必须显式检查
答案可能不需要取模 BISHI144 保证答案 \(\le 10^9\),Python 不溢出所以不用取模
最优值的方案数 同时维护 \(f\)(最优值)与 \(g\)(方案数):更优则覆盖 \(g\),相等则累加 \(g\)

区间 / 树形 / 状压三种形态都会用到本节,它们各自的章里只留一句指回来。


9 本章速查

要点 结论
DP 的两个前提 无后效性、最优子结构
无后效性不成立怎么办 把缺失的信息补进状态,不是放弃 DP
设计流程 状态 → 转移 → 边界 → 目标,四步缺一不可
推导转移的万能技巧 枚举「最后一步」
计数 DP 的正确性依据 各类情况「互不重叠 + 覆盖全部」
计数 vs 最优化 同构,只差合并时用 \(+\) 还是 \(\min\)/\(\max\)
Python 该用哪种实现 递推;记忆化只在状态稀疏或顺序难定时用
记忆化的两个风险 递归深度、函数调用开销(慢 5–20 倍)
滚动数组 依赖几层就留几层;也让整段 C 层批处理更容易
Python 铁律 ① 每步取模 ② 内层下沉 C 层 ③ 树形 DP 用迭代