跳到主要内容

动态规划

动态规划把较小子问题的答案保存下来,后面遇到同一子问题时直接复用,不再重复计算。“状态”记录定义一个子问题所需的信息,“递推关系”说明怎样用其他状态的答案求出当前答案。本页讨论的递推中,状态依赖不能绕回尚未算完的自身,因此构成有向无环图。记忆化是在递归需要某个答案时计算并缓存它;制表法则事先安排计算顺序,先算依赖项。设计时最重要的是弄清楚:状态需要记住什么,递推又为什么没有遗漏合法选择。

设计检查清单

  1. 状态(State): 最少需要保留哪些信息,才能让剩余问题独立于到达当前状态的路径?
  2. 值(Value): dp[state] 具体代表什么?是可行性、方案数、最小成本、最大价值,还是具体的解(Witness)?
  3. 转移(Transition): 哪些更小的状态可以推导出当前状态?
  4. 边界(Base Cases): 最小的有效子问题是什么?
  5. 顺序(Order): 在读取某个依赖项之前,它是否已经计算完成?
  6. 答案(Answer): 最终结果存储在哪个状态,或需要通过哪个聚合操作得出?

自顶向下 vs 自底向上

方法优势代价
记忆化递归仅计算实际到达的状态;代码结构贴近递推公式递归栈深度限制;缓存键(Key)设计复杂
自底向上制表计算顺序明确;更容易进行空间压缩可能计算大量无关状态

复杂度估算: 状态总数 ×\times 单次转移的工作量。

空间压缩原则: 仅当被覆盖的状态在后续转移或答案重建中不再被需要时,才可安全压缩内存。

正确性证明模式

  1. 状态充分性: 证明状态定义涵盖了所有影响结果的历史信息。
  2. 归纳法: 基于依赖顺序进行归纳。假设较小状态的值是正确的,证明递推关系考察了所有合法的最终选择,并依据目标函数正确地进行选择或组合。

何时不该使用动态规划

以下情况 DP 可能不是最优解:

  • 状态依赖无界历史: 所选状态仍依赖无法压缩的长历史。
  • 状态空间过大: 状态空间比直接搜索(如回溯)还大。
  • 贪心/交换性质适用: 存在贪心策略或交换论证,无需比较多个备选方案。

注:扩展状态维度有时能恢复正确性,但可能导致算法在工程上不可行。

状态设计示例:最少硬币

面额为 [1,3,4] 的正整数硬币可无限使用。优先拿大面额会把 6 凑成 4+1+1,需要三枚;3+3 只要两枚。反例推翻了这条贪心规则,但“有重叠子问题”本身还不能证明新递推正确。

定义 dp[t] 为恰好凑出 t 所需的最少硬币数。设 dp[0]=0,无法凑出的金额初始化为无穷大,不能设零。对正数 t,枚举不超过 t 的面额 c,取 1+dp[t-c] 的最小值。每个解都有最后一枚硬币,移走它后恰好留下相应较小金额,因此没有遗漏选择。正面额保证依赖严格变小。金额 0 到 6 的结果为 [0,1,2,1,1,2,2]。目标 T、面额数 k 时,时间 O(Tk)、空间 O(T)。若只有面额 [4],目标 6 就不可达。

def min_coins(coins, target):
if target < 0 or any(c <= 0 for c in coins):
raise ValueError("positive denominations and nonnegative target required")
dp = [0] + [float("inf")] * target
for amount in range(1, target + 1):
for coin in coins:
if coin <= amount:
dp[amount] = min(dp[amount], 1 + dp[amount - coin])
return dp[target]

assert min_coins([1, 3, 4], 6) == 2
assert min_coins([4], 6) == float("inf")
assert min_coins([], 0) == 0

不同问题需要记住什么

斐波那契数列只需一个下标及可复用的先前结果。0/1 背包还要记住物品是否可用与剩余容量,最长公共子序列用两个前缀长度描述状态。最短路径中的动态规划解释什么图顺序能让距离依赖无环。这些例子的差别在于状态必须保留什么信息,而不在于有没有一张表。

来源

探索关联打开关联网络