跳转至

第 100 章 DP 入门

配套例题:BISHI131 数楼梯、BISHI132 小红的地砖 来源:S3 day6/DP入门.md(硬币问题、无后效性、最优子结构、数楼梯、过河卒)、day6/DP资料/基础资料/动态规划 by 余行江.md

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


100.1 从贪心的失败讲起

S3 课件《DP 入门》的开场例子非常好,这里完整复现。

硬币问题:有面值 \(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 比暴力搜索快,是因为它把重复的子问题只算一次


100.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 就是反例——它看起来需要补一维, 实际上换个坐标系就不用了,见 102-线性DP

最优子结构

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

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

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


100.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 的关系。


100.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)。 详见 62-记忆化搜索与剪枝


100.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 背包与完全背包。 这是全书最经典的一个细节,见 101-背包问题

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


100.6 例题

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\) 用不着矩阵快速幂。矩阵加速见 104-DP优化

题解:solutions/BISHI131.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/BISHI132.py(已通过官方样例验证)


100.7 Python 写 DP 的三条铁律

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

1. 每步取模,不要最后才取

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

2. 把最内层循环下沉到 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 语义自动成立。

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

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

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


100.8 本章速查

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