本节摘要:动态规划(DP)治一类特殊问题:子问题相互重叠、且大问题的最优解由小问题的最优解拼成。做法是定义状态、写出转移方程、按依赖顺序填表,每个子问题只算一次。本节从爬楼梯的台阶计数进入 0-1 背包,填出完整的状态表并做滚动数组优化,最后给出三个高频翻车点。承接 1.3 节的记忆化伏笔。
不是所有问题都配用动态规划。它要求:
三条全中,心法就一句话:把每个子问题的答案存进表里,按规模从小到大填,填到最后就是原问题的答案。递归加缓存(记忆化)是自顶向下的写法;循环填表是自底向上的写法,后者没有递归开销、边界更直观,工程上更常用。两种写法的取舍有句口诀:转移关系还想不清楚时先写记忆化(照着递归自然生长,只加一行缓存),跑通后再翻译成自底向上的填表并顺手做空间优化——先对再快,别倒过来。
# 热身:爬楼梯——一步一格或两格,n 格有几种走法 def climb(n): if n <= 2: return n dp = [0] * (n + 1) dp[1], dp[2] = 1, 2 # 地基:一格一种、两格两种 for i in range(3, n + 1): dp[i] = dp[i - 1] + dp[i - 2] # 转移:最后一步跨一格或两格 return dp[n] for n in (3, 4, 5): print(f"{n} 格楼梯:{climb(n)} 种走法") # 输出: # 3 格楼梯:3 种走法 # 4 格楼梯:5 种走法 # 5 格楼梯:8 种走法 # 转移方程就是斐波那契:dp[i] 只依赖前两格——这就是重叠子问题的最纯样本
问题:三个物品,重量 1、3、4,价值 15、20、30,背包容量 4,每件至多带一件,求最大总价值。状态定义:dp[i][j] 表示"只考虑前 i 件、容量为 j 时的最大价值"。转移方程:第 i 件只有两种命运——
取两者较大。

# 0-1 背包:完整状态表 + 答案 def knapsack(weights, values, cap): n = len(weights) dp = [[0] * (cap + 1) for _ in range(n + 1)] for i in range(1, n + 1): # 逐件考虑 w, v = weights[i - 1], values[i - 1] for j in range(cap + 1): # 逐容量填表 dp[i][j] = dp[i - 1][j] # 不带第 i 件 if j >= w: dp[i][j] = max(dp[i][j], dp[i - 1][j - w] + v) # 带它 for row in dp: print(row) return dp[n][cap] best = knapsack([1, 3, 4], [15, 20, 30], 4) print("最大价值 =", best) # 输出: # [0, 0, 0, 0, 0] # [0, 15, 15, 15, 15] # [0, 15, 15, 20, 35] # [0, 15, 15, 20, 35] # 最大价值 = 35
表里的每个数字都能用上图复算:35 出现在第 2 行第 4 列,来源是第 1 行第 1 列的 15 加上第 2 件价值 20。时间 O(n·cap)、空间同阶——注意这里 cap 也进了复杂度(伪多项式),容量上百万时填表并不可行。
第 i 行只依赖第 i-1 行,整张表可以压成一行。但容量必须倒序枚举:正序会先更新小容量格子,再被同件物品"二次使用"(那成了无限件背包);倒序保证每件物品只用一次:
# 滚动数组:一行搞定,容量倒序是生死线 def knapsack_1d(weights, values, cap): dp = [0] * (cap + 1) for w, v in zip(weights, values): for j in range(cap, w - 1, -1): # 倒序:防止本件被重复装 dp[j] = max(dp[j], dp[j - w] + v) return dp[cap] print("一维版答案 =", knapsack_1d([1, 3, 4], [15, 20, 30], 4)) # 输出:一维版答案 = 35 # 空间从 (物品数+1)×(容量+1) 压到 (容量+1) 个格子
⚠️ 常见坑:0-1 背包一维写成正序。正序枚举时 dp[j-w] 已被本件更新过,等于允许同一件带多次——答案变大且错。判断口诀:每件至多一次,容量倒序;每件可无限次,容量正序。
💡 关键直觉:动态规划 = 图论视角下的最短路。把每个状态当顶点、转移当边,填表就是按拓扑序求最短路(对照 4.5 节)。这个视角能解释为什么无后效性不可缺:环上的状态没有合法的填表顺序。
翻车一:状态定义含糊。"dp[i] 是前 i 个的最优"与"dp[i] 是以第 i 个结尾的最优"是两个世界(后者如最长上升子序列)。定义里必须写清"以什么结尾/还剩什么",转移才推得动。写代码前先用一句话把状态念出来。
**翻车二:初始化与答案错位。**求"恰好装满"的背包,dp[0] 初始化 0、其余初始化负无穷;求"不超过容量"的背包,全零初始化。混用两者是习题错误率的头部来源。
**翻车三:把所有问题都往 DP 上套。**子问题不重叠时(多数分治场景),DP 的表只是浪费内存;没有最优子结构时(某些带后效性的博弈问题),转移方程根本不成立。先验证三要素,再动笔。
一步一个脚印的填表之后,下一节反其道而行:每步只取眼前最好,赌它能通向全局最优——贪心。