Skip to content

动态规划

动态规划 🔥 核心章节

💡 核心要点

动态规划的核心不是背公式,而是学会一套通用的思考流程:面对任何 DP 题,都能从状态定义出发,一步步推出转移方程、初始化、遍历顺序和返回值。掌握这个流程,比刷 100 道题更有用。

什么是动态规划

动态规划(Dynamic Programming, DP)的本质是:把一个大问题拆成若干重叠的子问题,每个子问题只计算一次,把结果存起来复用。

与贪心的区别

贪心算法在每一步做局部最优选择,一旦选了就不回头。DP 则考虑所有子问题的最优组合——当前的最优选择可能依赖于"如果我之前做了不同的选择,现在会怎样"。

判断标准:如果你能证明局部最优一定导致全局最优,用贪心;否则用 DP。

与分治的区别

分治也是把问题拆成子问题,但分治的子问题互不重叠(如归并排序的左右两半)。DP 的子问题大量重叠(如斐波那契的 f(3) 被算了无数次)。

两个前提条件

一个问题能用 DP 解,必须同时满足:

  1. 重叠子问题:不同的递归分支会反复计算相同的子问题
  2. 最优子结构:大问题的最优解可以由子问题的最优解推导出来

如何识别 DP 题

面试中拿到一道题,以下信号提示你可能需要 DP:

题目问的是DP 类型例子
最大/最小/最长/最短最优化 DP最长递增子序列、最小路径和
有多少种方式/方案计数 DP爬楼梯、零钱兑换 II
是否可能/能否达到可行性 DP分割等和子集、跳跃游戏

验证方法:尝试写出暴力递归,画递归树。如果发现大量重复节点 → 确认是 DP。

解题五步法

这是整个 DP 章节最核心的方法论。面对任何 DP 题,都按这五步走:

第一步:定义状态

用一句自然语言说清楚 dp[i](或 dp[i][j]到底代表什么

这是最关键的一步,状态定义错了,后面全错。

思考技巧:问自己"我需要知道哪些信息,才能做出当前位置的决策?"这些信息就是状态的维度。

  • 如果只需要知道"到第 i 个位置为止"的信息 → 一维 dp[i]
  • 如果还需要知道"剩余容量"或"到第 j 个字符为止" → 二维 dp[i][j]
  • 如果还有"当前处于第 k 种状态" → 三维 dp[i][j][k]

常见陷阱dp[i] 表示"前 i 个元素的最优解"和"以第 i 个元素结尾的最优解"是完全不同的定义,会导致完全不同的转移方程。

第二步:推导转移方程

问自己:dp[i] 可以从哪些更小的状态得到?做了什么决策

转移方程就是把"决策"翻译成代码。

思考方法:站在第 i 个位置,思考"我有哪些选择":

  • 选或不选当前元素 → dp[i] = max(dp[i-2] + nums[i], dp[i-1])
  • 从哪个前驱转移过来 → dp[i] = min(dp[j] + cost) for all valid j
  • 匹配或不匹配 → dp[i][j] 根据 s[i] == t[j] 分两种情况

第三步:确定初始化

找到最小的、不能再拆分的子问题,直接赋值。

初始化就是转移方程的"递归基"。

常见模式

  • dp[0] = 0dp[0] = 1(空集/起点)
  • dp[0][j] = ..., dp[i][0] = ...(第一行/第一列)
  • 全部初始化为 Integer.MAX_VALUEInteger.MIN_VALUE(求最小/最大值时)

关键问题dp 数组长度是 n 还是 n + 1?这取决于你的状态定义是从 0 开始还是从 1 开始。

第四步:确定遍历顺序

原则:计算 dp[i] 时,它依赖的所有状态必须已经算好。

从转移方程的依赖关系反推遍历方向:

  • 依赖 dp[i-1] → 从小到大遍历
  • 依赖 dp[i+1] → 从大到小遍历
  • 依赖 dp[i-1][j-1] → 两层循环都从小到大
  • 0/1 背包一维优化 → 容量从大到小(防止同一物品被重复使用)

第五步:确定返回值

返回值不一定是 dp[n]

  • 如果 dp[i] 定义为"前 i 个元素的最优解" → 返回 dp[n]
  • 如果 dp[i] 定义为"以第 i 个元素结尾的最优解" → 返回 max(dp[0..n-1])
  • 双串 DP → 通常返回 dp[m][n]

用爬楼梯演示五步法

LeetCode 70 - 爬楼梯 为例,完整走一遍:

题目:每次可以爬 1 或 2 个台阶,问爬到第 n 阶有多少种方法。

第一步:定义状态dp[i] = 爬到第 i 阶的方法数。

第二步:转移方程 到第 i 阶,要么从第 i-1 阶爬 1 步,要么从第 i-2 阶爬 2 步:

第三步:初始化dp[0] = 1(站在地面,1 种方式),dp[1] = 1(爬 1 阶只有 1 种方式)。

第四步:遍历顺序dp[i] 依赖 dp[i-1]dp[i-2],从小到大遍历。

第五步:返回值dp[n]

java
public int climbStairs(int n) {
    if (n <= 1) return 1;
    int[] dp = new int[n + 1];
    dp[0] = 1;
    dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

自顶向下 vs 自底向上

DP 有两种实现方式,本质相同,形式不同:

记忆化递归(自顶向下)

从大问题出发,递归拆解,用数组或哈希表缓存已计算的子问题。

java
public int climbStairs(int n) {
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);
    return dfs(n, memo);
}

private int dfs(int n, int[] memo) {
    if (n <= 1) return 1;
    if (memo[n] != -1) return memo[n];
    memo[n] = dfs(n - 1, memo) + dfs(n - 2, memo);
    return memo[n];
}

优点:思路自然(先想大问题怎么拆),只计算需要的子问题。 缺点:递归栈开销,不方便做空间优化。

递推表(自底向上)

从最小子问题出发,逐步填表,直到算出目标。

优点:没有递归栈开销,方便做空间优化。 缺点:需要想清楚遍历顺序,可能计算不需要的子问题。

面试建议:先用记忆化递归理清思路,确认状态和转移没问题后,再改写成递推表。

空间优化技巧

观察依赖范围

空间优化的核心是观察转移方程依赖了多少历史状态:

依赖范围优化方式例子
只依赖前 1-2 个状态用 2-3 个变量滚动爬楼梯、斐波那契
只依赖上一行两行数组交替网格路径、LCS
二维依赖整列一维数组 + 遍历方向控制0/1 背包

一维压缩的遍历方向

当把二维 DP 压缩到一维时,遍历方向至关重要:

  • 0/1 背包:容量倒序遍历(保证每件物品只用一次)
  • 完全背包:容量正序遍历(允许重复使用)

何时不做空间优化

如果题目要求输出具体方案(路径、操作序列),需要回溯 dp 表 → 保留完整的二维表。

常见错误与调试方法

错误症状调试方法
状态定义模糊转移方程写不出来,或写出来但结果不对打印 dp 表,检查每个值是否符合你的自然语言定义
初始化遗漏dp 表前几个值就不对手算前 3-5 个值,与 dp 表对照
遍历顺序错误用到了还没算好的状态,结果偏小或偏大在转移前打印依赖的状态,确认它们已更新
Off-by-one数组越界或最后一个状态没算到明确 dp 数组长度是 n 还是 n+1,画图确认下标范围

DP 题型全景图

掌握了五步法之后,接下来按题型深入。每种类型有自己的状态定义套路和转移模式,但五步法的思考流程是通用的。

类型核心特征详细页面
线性 DP + 网格 DP状态沿一维/二维线性推进线性与网格 DP
背包 DP物品 + 容量限制 + 选择决策背包 DP
序列与回文 DP字符串匹配、子序列、回文结构序列与回文 DP
区间 DP + 状态机 DP区间合并分割 / 多状态转移区间与状态机 DP
树形 DP子树信息汇聚到父节点树形 DP
状压 DP + 计数 DP集合位掩码 / 方案计数状压与计数 DP