动态规划是把大问题拆成会重复出现的子问题,每个子问题只算一次,再按依赖关系拼出答案。核心就四件事:状态、转移、边界、答案落在哪。
怎么拆一道题
- 状态:一个子问题要记哪些信息。通常是「考虑到第 i 步、还剩多少资源」。
- 转移:当前状态由哪些更小的状态推出来。这一步就是递推式。
- 边界:最小的子问题,答案可以直接写出来,例如容量为 0、金额为 0。
- 计算顺序:先算不依赖别人的格子,再算依赖它们的格子。
- 答案:往往是表的最后一格,或整表里的最大值、最小值。
两种写法结果一样:
- 自顶向下:从原问题递归下去,算过的放进字典,再遇到直接查。也叫记忆化。
- 自底向上:开一张表,从小到大填满。面试和工程里更常见,因为顺序清楚、不容易爆栈。
时间大致是「状态个数 × 每个状态的转移次数」。能把二维表压成一维时,空间可以再降一截。
例子一:斐波那契
定义:F(0) = 0,F(1) = 1,F(n) = F(n-1) + F(n-2)。
直接递归算 F(5) 时,F(3)、F(2) 会在不同分支里各算多遍:
F(5)
├─ F(4)
│ ├─ F(3)
│ │ ├─ F(2)
│ │ └─ F(1)
│ └─ F(2)
└─ F(3)
├─ F(2)
└─ F(1)
状态就是「第 n 项」。转移是前两项相加。边界是 F(0)、F(1)。自底向上只留最近两项:
| n | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| F | 0 | 1 | 1 | 2 | 3 | 5 |
每个 n 只算一次,时间 O(n),空间 O(1)。直接递归不加记忆的话,时间大约是 O(2^n)。
def fib(n: int) -> int:
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
例子二:0/1 背包
有三件物品,每件最多拿一次。背包容量是 5。
| 物品 | 重量 | 价值 |
|---|---|---|
| A | 2 | 3 |
| B | 3 | 4 |
| C | 4 | 5 |
状态 dp[i][w]:只考虑前 i 件物品、容量正好用到不超过 w 时,能拿到的最大价值。
转移:第 i 件要么不拿,价值等于 dp[i-1][w];要么拿,前提是 w 够重,价值等于 dp[i-1][w - 重量] + 价值。两者取最大。
边界:没有物品,或容量为 0,价值都是 0。
按物品一行行填(行是「考虑到第几件」,列是容量 0 到 5):
容量 0 1 2 3 4 5
无 0 0 0 0 0 0
+A 0 0 3 3 3 3
+B 0 0 3 4 4 7
+C 0 0 3 4 5 7
读最后一行最后一列:最大价值是 7,对应拿 A 和 B(重量 2+3,价值 3+4)。容量 4 那一格是 5,对应只拿 C。容量 5 时拿 C 只有 5,不如 A+B 的 7。
def knapsack(weights: listint, values: listint, capacity: int) -> int:
n = len(weights)
dp = \[0 * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
weight, value = weightsi - 1, valuesi - 1
for w in range(capacity + 1):
dpiw = dpi - 1w
if w >= weight:
dpiw = max(dpiw, dpi - 1w - weight + value)
return dpncapacity
print(knapsack(2, 3, 4, 3, 4, 5, 5)) # 7
0/1 背包填表时,当前行只能用上一行的结果。如果改成一维数组从容量大到小更新,同一件物品就不会被用两次。
例子三:零钱兑换
硬币面额 [1, 2, 5],凑出金额 11,求最少硬币数。和背包不同,这里同一面额可以用多次,求的是最小值。
状态 dp[x]:凑出金额 x 的最少硬币数。
转移:dp[x] = min(dp[x - coin] + 1),coin 是不超过 x 的面额。
边界:dp[0] = 0。凑不出的金额先记成一个很大的数。
| 金额 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 枚数 | 0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
dp[11] = 3,一种凑法是 5+5+1。例如 dp[6] 可以是 dp[5]+1(再加一枚 1)或 dp[4]+1(再加一枚 2)或 dp[1]+1(再加一枚 5),最小值是 2。
def coin_change(coins: listint, amount: int) -> int:
inf = amount + 1
dp = inf * (amount + 1)
dp0 = 0
for x in range(1, amount + 1):
for coin in coins:
if coin <= x:
dpx = min(dpx, dpx - coin + 1)
return dpamount if dpamount <= amount else -1
print(coin_change(1, 2, 5, 11)) # 3
看到题目时怎么判断
可以沿这个顺序问自己:
- 要求的是最大、最小、方案数,还是能否达到。
- 走到这一步,必须记住哪些信息,漏一个维度答案就会错。
- 当前这一步只依赖更早的状态,不依赖后面的选择。
- 同一个子问题会在不同路径里再次出现。
最长上升子序列、编辑距离、不同路径、股票买卖区间,都是同一套:先定状态,再写「这一步选或不选」的转移。状态定准了,式子通常就能跟着写出来。