13.2 经典动态规划问题
前置:动态规划入门。本页用 0-1 背包练习容量维度,用 LCS 与编辑距离练习双序列前缀;代码明确输入合同,并用一维优化展示遍历方向的含义。
从最后一个选择反推依赖
第一张问有限容量里怎样选择物资,第二张比较两份日志保留了多长的共同顺序,第三张计算把一份记录改成另一份至少需要多少次操作。它们看起来不同,建模步骤却相同:先给 dp 下标写一句完整定义,再从最后一个选择反推依赖。
一份可执行的参考实现
背包的重量和价值必须是等长序列,重量为正整数、价值为整数,允许不选物品,因此负价值不会迫使答案为负。容量和金额为非负整数;硬币输入可迭代,每个币值为正整数,重复币值不影响答案但会重复计算。LCS 与编辑距离只接收字符串。函数不修改输入;数值合同不满足时抛 ValueError,字符串类型不符时抛 TypeError。
def knapsack_01(weights, values, capacity):
if len(weights) != len(values):
raise ValueError("weights and values must have equal length")
if not isinstance(capacity, int) or capacity < 0:
raise ValueError("capacity must be a non-negative integer")
if any(not isinstance(weight, int) or weight <= 0 for weight in weights):
raise ValueError("weights must be positive integers")
if any(not isinstance(value, int) for value in values):
raise ValueError("values must be integers")
best = [0] * (capacity + 1)
for weight, value in zip(weights, values):
for remaining in range(capacity, weight - 1, -1):
best[remaining] = max(
best[remaining],
best[remaining - weight] + value,
)
return best[capacity]
def lcs_length(first, second):
if not isinstance(first, str) or not isinstance(second, str):
raise TypeError("first and second must be strings")
previous = [0] * (len(second) + 1)
for first_char in first:
current = [0]
for column, second_char in enumerate(second, start=1):
if first_char == second_char:
current.append(previous[column - 1] + 1)
else:
current.append(max(previous[column], current[column - 1]))
previous = current
return previous[-1]
def edit_distance(source, target):
if not isinstance(source, str) or not isinstance(target, str):
raise TypeError("source and target must be strings")
previous = list(range(len(target) + 1))
for row, source_char in enumerate(source, start=1):
current = [row]
for column, target_char in enumerate(target, start=1):
if source_char == target_char:
current.append(previous[column - 1])
else:
current.append(1 + min(
previous[column], # 删除 source_char
current[column - 1], # 插入 target_char
previous[column - 1], # 替换
))
previous = current
return previous[-1]
def min_coin_count(coins, amount):
if not isinstance(amount, int) or amount < 0:
raise ValueError("amount must be a non-negative integer")
coins = list(coins)
if any(not isinstance(coin, int) or coin <= 0 for coin in coins):
raise ValueError("coins must be positive integers")
unreachable = amount + 1
best = [unreachable] * (amount + 1)
best[0] = 0
for current in range(1, amount + 1):
for coin in coins:
if coin <= current:
best[current] = min(best[current], best[current - coin] + 1)
return -1 if best[amount] == unreachable else best[amount]
if __name__ == "__main__":
assert knapsack_01([2, 3, 4, 5], [3, 4, 5, 6], 8) == 10
assert knapsack_01([], [], 8) == 0
assert lcs_length("abcde", "ace") == 3
assert lcs_length("", "ace") == 0
assert edit_distance("horse", "ros") == 3
assert edit_distance("", "abc") == 3
assert min_coin_count([1, 2, 5], 11) == 3
assert min_coin_count([2], 3) == -1
print("经典 DP 检查通过")0-1 背包:反向遍历保存上一轮
二维定义是:
dp[i][c] = 只看前 i 个物品、容量不超过 c 时的最大价值第 i 个物品只有“不选”和“选一次”两种情况。压成一维后,best[c] 同时承载上一轮与当前轮。容量必须从大到小遍历,读取 best[c-weight] 时它才仍代表“尚未处理当前物品”的上一轮状态。
若改成从小到大,同一物品刚写入的小容量结果会被本轮再次读取,语义就变成可重复取用的完全背包。所谓“0-1 反向、完全正向”不是口诀本身,而是防止或允许当前物品在同一轮被重复使用。
示例最优值 10 来自重量 3 与 5 的两个物品,价值分别为 4 与 6。只返回数值无法告诉调用方具体选项;需要还原方案时,可保留二维表或保存不会被后续更新改写的历史选择。只给一维数组附一个可变的“前驱容量”,可能在回溯时误读后来的状态。
n 个物品、正容量 W 的常见时间上界是 O(nW),空间 O(W)。若把 n=0 或 W=0 也写进同一个式子,初始化与输入检查计入后为 O(nW+n+W+1) 时间、O(W+1) 空间。这里把整数运算视为常数成本,W 是容量数值而不是输入位数。因此这属于伪多项式算法:容量若用二进制表示且数值巨大,O(W) 不能简单称作“输入规模的多项式”。
LCS:子序列不要求连续
dp[i][j] 表示两个长度分别为 i、j 的前缀的最长公共子序列长度。末尾字符相等时,可以在两个更短前缀的答案后接上该字符;不等时,至少有一个末尾字符不属于某个最优答案,于是取“去掉 first 末尾”与“去掉 second 末尾”的较大值。
本页只返回长度,用两行把空间从 O(mn) 压到 O(n)。若要还原实际子序列,需要保留足够的选择信息,或采用能重算路径的算法。非空字符串时,时间为 Θ(mn),m、n 分别是第一、第二个字符串的长度。包括空输入的统一写法是 Θ((m+1)(n+1)) 时间、Θ(n+1) 空间。若先把较短字符串放在第二位,可以把行宽降到两者长度的较小值。
LCS 与最长公共子串不同:子串必须连续,状态在字符不等时通常归零;子序列只要求相对次序不变。
编辑距离:三种操作对应三个邻格
dp[i][j] 表示 source 前 i 个字符变成 target 前 j 个字符的最少操作数。字符不等时:
- 删除 source 末尾:
dp[i-1][j]; - 插入 target 末尾:
dp[i][j-1]; - 替换末尾:
dp[i-1][j-1]。
三种操作各加 1。空前缀到长度 j 的前缀需要 j 次插入,这就是首行;长度 i 的前缀到空串需要 i 次删除,这就是首列。非空字符串时为 Θ(mn) 时间、Θ(n) 两行空间;包括空输入时为 Θ((m+1)(n+1)) 时间、Θ(n+1) 空间,m、n 分别为 source、target 长度。
本页按 Python 字符串的 Unicode 码点序列比较,但没有做规范化。例如 edit_distance("é", "e\u0301") 为 2;若先做 NFC 规范化,结果为 0。Python 的 Unicode 规范化接口可统一规范等价形式,但不会把所有视觉相似字符变成相等,也不会自动按用户感知字符(字素簇)切分。相邻字符交换在本页也不是一个独立操作。
零钱兑换:状态图可能不可达
best[a] 表示凑出金额 a 的最少硬币数。每个正币值可重复使用,转移读取较小金额。用 amount+1 作为不可达哨兵,是因为每枚硬币的整数币值至少为 1,凑出 amount 的方案便至多使用 amount 枚硬币;无需真的存在面值 1。
若币值包含 0 或负数,依赖关系不再严格指向更小金额,本页直接拒绝。金额 0 返回 0;没有硬币且金额大于 0 时返回 -1。设 k 为币值个数、A 为金额,包括复制币值和初始化,时间为 O(k+A(k+1)+1),空间为 O(k+A+1)。这个按金额建表的算法也属于伪多项式。
动手检查状态含义
- 把背包容量循环改成正向,用一个物品证明它会被重复使用。
- 为背包保留选择路径,并规定多组最优方案时的决胜规则。
- 手算
LCS("abc", "bac")的表格,观察最优序列可能不唯一。 - 给编辑距离添加不同的插入、删除、替换代价,哪些边界要同步改变?
- 用 BFS 把小金额零钱兑换看成最短路,与 DP 结果随机对照。
离开状态表
容量 DP 的循环方向决定当前物品能否重复使用;双序列 DP 的三个邻格对应删、插、替或跳过末尾。状态定义写得足够精确,边界和遍历顺序才有可验证的来源。
下一章转向 贪心算法:有些问题不必保留所有候选,只做一个可证明安全的局部选择。