跳转至

动态规划(Dynamic Programming)

Abstract

动态规划(DP)是一种算法设计思想,通过将复杂问题拆解为重叠子问题,并利用最优子结构,逐步构建最终解。 其核心在于保存并复用子问题的解,从而显著提升效率。 动态规划被广泛应用于最优化、计数、决策等众多场景,是解决诸如背包、路径、子序列等类型问题的利器。

一句话总结:把一个大问题,拆成小问题,解决小问题并存好,最后合起来解决大问题。


核心思想

动态规划的核心在于避免重复计算。与暴力递归不同,DP 会将已经计算过的子问题结果保存下来,后续直接复用,从而将指数级时间复杂度降至多项式级。

何时考虑使用 DP?

当问题同时满足以下两个条件时,通常可以用 DP 来解决:

  1. 最优子结构(Optimal Substructure):问题的最优解可以由子问题的最优解组合而来。
  2. 重叠子问题(Overlapping Subproblems):子问题会被反复求解,而非每次都是独立的新问题。

实现方式

DP 有两种实现方式:自顶向下(Memoization)自底向上(Tabulation)

  • 自顶向下:记忆化递归(Memoization)


    从原始问题出发,递归地分解为子问题,并用缓存保存已计算的结果。

    def fib(n, memo={}):
        if n <= 1:
            return n
        if n not in memo:
            memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
        return memo[n]
    
    • 思路自然,与递归公式直接对应
    • 只计算需要的子问题(懒计算)
    • 递归深度过大时可能栈溢出
  • 自底向上:递推表格(Tabulation)


    从最小的子问题开始,逐步递推到目标问题,使用数组存储中间结果。

    def fib(n):
        if n <= 1:
            return n
        dp = [0] * (n + 1)
        dp[1] = 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        return dp[n]
    
    • 无递归开销,不会栈溢出
    • 计算所有子问题(即使部分不需要)
    • 通常更容易进行空间优化

如何选择?

  • 大多数面试和工程场景中,自底向上(Tabulation) 更常用,因为它没有递归栈的限制,且更容易优化空间。
  • 当问题的状态空间很大但实际只访问其中一小部分时,自顶向下(Memoization) 可以避免不必要的计算。

解题框架

解决 DP 问题通常遵循以下步骤:

步骤 说明
1. 定义状态 明确 dp[i](或 dp[i][j])的含义
2. 推导转移方程 找出 dp[i] 与更小子问题之间的递推关系
3. 确定初始条件 最小子问题(base case)的值
4. 确定遍历顺序 确保计算 dp[i] 时所依赖的状态已经算好
5. 空间优化(可选) 若当前状态只依赖有限的前几个状态,可用滚动数组压缩空间

常见 DP 类型总结

类型 典型问题 状态维度
线性 DP 爬楼梯、打家劫舍、最大子数组和 1D
背包 DP 0-1 背包、完全背包、多重背包 1D / 2D
序列 DP LIS、LCS、编辑距离 1D / 2D
区间 DP 戳气球、石子合并、回文分割 2D
树形 DP 打家劫舍 III、二叉树最大路径和 树结构递归
状态压缩 DP 旅行商问题(TSP)、任务分配 bitmask
数位 DP 统计满足条件的数字个数 按位递推

常见误区

  1. 混淆贪心与 DP:贪心每步取局部最优且不回退;DP 会综合考虑所有子问题的结果。如果局部最优不能保证全局最优,就需要 DP。
  2. 忘记初始化 base case:状态转移方程写对了,但初始值设错,结果全错。
  3. 遍历顺序错误:尤其是空间优化后的背包问题,正序/逆序搞反会导致结果错误。
  4. 状态定义不清晰dp[i] 到底是"前 i 个"还是"以第 i 个结尾"?必须在开始前严格定义。

优化技巧

空间优化

dp[i] 只依赖 dp[i-1](或有限的前几项)时,无需保留完整数组:

# 斐波那契 O(1) 空间
prev, curr = 0, 1
for _ in range(2, n + 1):
    prev, curr = curr, prev + curr

前缀和加速

部分 DP 的转移涉及区间求和,可借助前缀和将单次转移从 O(n) 降至 O(1)。

单调栈 / 单调队列优化

当转移方程形如 \( dp[i] = \min_{j \in [l, r]} dp[j] + cost(i) \) 时,可使用单调队列将每次转移优化至 O(1)。典型应用:滑动窗口最大值、跳跃游戏变种。

二分查找优化

LIS 的 O(n log n) 解法即利用了二分查找来维护贪心的辅助数组。


练习推荐

难度 题目 类型
🟢 Easy 70. 爬楼梯 线性 DP
🟢 Easy 746. 使用最小花费爬楼梯 线性 DP
🟡 Medium 322. 零钱兑换 完全背包
🟡 Medium 300. 最长递增子序列 序列 DP
🟡 Medium 1143. 最长公共子序列 序列 DP
🟡 Medium 416. 分割等和子集 0-1 背包
🔴 Hard 72. 编辑距离 序列 DP
🔴 Hard 312. 戳气球 区间 DP
🔴 Hard 516. 最长回文子序列 区间 DP