动态规划解题方法论

📖 目录


一、适用场景

这篇是写给谁的? —— 正在刷 LeetCode 动态规划题、感觉 "知道 dp 是啥但碰到新题还是不会" 的学习者。

如果你符合以下任意一条,这里适合你:

  • ✅ 你能看懂题解里的转移方程,但自己想不出来
  • ✅ 你刷了十几道 DP 题,换道新题还是无从下手
  • ✅ 你听说过"最后一步分析法",但不知道怎么套

什么时候不适合看这篇? —— 如果你完全不知道什么是 DP(先去看基础概念),或者你已经能独立做 medium DP 题了(这篇对你太浅)。


二、核心原理

30 秒讲清本质

flowchart LR
    subgraph forward["正向思维 (易迷路)"]
        direction LR
        A1["第1步怎么走"] --> A2["第2步怎么走"] --> A3["..."] --> A4["第n步怎么走"]
    end

    subgraph backward["DP 思维 (倒着想)"]
        direction LR
        B1["目标: 第n步的结果"] --> B2{"最后一步做了什么选择"}
        B2 --> B3["选择1 - 子问题规模 n-1"]
        B2 --> B4["选择2 - 子问题规模 n-2"]
        B3 --> B5["递推关系 f(n) = ..."]
        B4 --> B5
    end

一句话口诀:顺着想会迷路,倒着想有公式。

为什么这样有效?

大多数人拿到 DP 题,本能反应是从头开始推:"第一个元素怎么处理?第二个怎么处理?..." 很快就会陷在细节里。

反过来看:跳过前 n-1 步,只看最后一步。

假设问题已经解决了,答案已经在手上了。问自己三个问题:

1️⃣  在得到最终答案之前的那「最后一步选择」,我做了啥?
     (爬楼梯 → 最后跨了 1 步还是 2 步?)

2️⃣  做了这个选择之后,「剩下的部分」变成了什么规模更小的子问题?
     (把最后 1 步去掉 → 剩下的是 n-1 规模的同一问题)

3️⃣  最后一步有几种不同的可能?都列出来,取最优或求和。

你不需要从头规划整个决策链条,只需要想清楚"末端发生了什么",然后相信相同逻辑能递归回去。

朴素方案 vs DP

朴素做法(暴力递归) DP
怎么做 枚举所有可能,DFS 递归 记录子问题答案,避免重复计算
时间复杂度 往往是指数级 O(2ⁿ) 多项式级 O(n) / O(n²)
空间复杂度 递归栈深度 O(n) 或 O(1)
问题在哪 大量重复计算(如 fib(5) 递归树里 fib(2) 算了 3 次) 用数组/字典记下来,算一次就够了

三、具体做法:4 步通用流程

在白纸上按这个步骤画,不要跳步:

步骤 具体动作 举例:零钱兑换
Step 1:定义状态 找到自变量(变化的维度)。问:需要记录哪些变量才能描述当前局面? 自变量是"金额"。定义 dp[i] = 凑出金额 i 所需的最少硬币数
Step 2:分析最后一步 假设最终状态已达最优,最后一次操作做了什么? 最后一枚硬币面值为 coin,前一步是凑出 i - coin
Step 3:写出方程 穷举所有可能的最后一步选择,取最优 dp[i] = min(dp[i - coin] + 1),遍历所有面值
Step 4:边界与循环顺序 初始值?循环方向? dp[0] = 0,完全背包正序循环

注意: 递推关系的思考方向是从 n 到 n-1/n-2(倒着推),但填表的方向是从 0 到 n-1(正着算)。因为计算依赖:要算 dp[5],需要 dp[4] 和 dp[3] 已经算好了。


四、决策辅助:3 种决策类型

在草稿纸上先判断当前题目属于哪种选择。每种配了一句口诀:

类型 A:"选或不选"(0-1 背包变体)

项目 内容
特征 每个物品只能拿一次,面临拿/不拿
思考切入点 dp[i] 的最优解,到底包不包含第 i 个元素?
口诀 "要么装,要么不装,两种取最大"
转移模板 dp[i][j] = max(dp[i-1][j], dp[i-1][j-w] + v)
典型题目 0-1 背包、分割等和子集、最后一块石头的重量 II

类型 B:"枚举最后一刀"(区间 DP)

项目 内容
特征 字符串切割、矩阵连乘、二叉树构建
思考切入点 最后一步合并/切割时,分割点 k 在哪里?
口诀 "穷举切哪里,左右加起来再加一刀代价"
转移模板 dp[i][j] = max(dp[i][k] + dp[k+1][j] + cost)
典型题目 戳气球、矩阵链乘、分割回文串 II

类型 C:"多条路径汇合"(网格 / 线性递推)

项目 内容
特征 当前状态由上方、左方、或前几个固定方向转移而来
思考切入点 能到达 (i,j) 的上一个位置有哪几个?
口诀 "上一步来自哪几个方向,取最优再操作"
转移模板 dp[i][j] = op(dp[i-1][j], dp[i][j-1])
典型题目 不同路径、最小路径和、股票买卖、打家劫舍

五、实战案例

例 1:爬楼梯

题目:每次可以爬 1 或 2 个台阶,爬到第 n 阶有几种方法?

映射表:4 步流程 → 本题实现

模板步骤 本题实现
Step 1:定义状态 dp[i] = 爬到第 i 阶的方法数
Step 2:分析最后一步 最后一步要么从 n-1 跨 1 步,要么从 n-2 跨 2 步
Step 3:写出方程 dp[i] = dp[i-1] + dp[i-2]
Step 4:边界与顺序 dp[0]=1, dp[1]=2,从 2 到 n-1 正序循环

Step 1:定义状态

思考过程:问题问的是"爬到第 n 阶有几种方法" → 那 dp[i] 很自然地定义为"爬到第 i 阶的方法数"。

dp[i] = 爬到第 i 阶的方法数

Step 2:分析最后一步

假设已经爬到第 n 阶了。前一刻只能有两种可能:

flowchart LR
    A[当前在第 n 阶] --> B{前一刻在哪}
    B --> C[第 n-1 阶 · 跨 1 步]
    B --> D[第 n-2 阶 · 跨 2 步]
    C --> E["f(n) = f(n-1) + f(n-2)"]
    D --> E

Step 3:写出方程

f(n) = f(n-1) + f(n-2)

转换成 dp 下标
dp[i] = dp[i-1] + dp[i-2]    (i  2)

Step 4:边界与顺序

最小的 n 直接心算:

dp[0] = 1   第 1 阶:只有 [1]
dp[1] = 2   第 2 阶:可以是 [1+1] 或 [2]

验证递推:

dp[2] = dp[1] + dp[0] = 2 + 1 = 3  → [1+1+1, 1+2, 2+1] ✓
dp[3] = dp[2] + dp[1] = 3 + 2 = 5  → [1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2] ✓

代码实现

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2:
            return n
        dp = [0] * n
        dp[0] = 1  # 第 1 阶
        dp[1] = 2  # 第 2 阶
        for i in range(2, n):
            dp[i] = dp[i-1] + dp[i-2]
        return dp[n-1]

朴素版本用了 O(n) 空间。观察发现 dp[i] 只依赖前两个值,可以压到 O(1):

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2:
            return n
        a, b = 1, 2
        for _ in range(3, n + 1):
            a, b = b, a + b
        return b

例 2:打家劫舍

题目:一排房间,每个房间有现金 nums[i],不能偷相邻的两家,求最大偷窃金额。

映射表:4 步流程 → 本题实现

模板步骤 本题实现
Step 1:定义状态 dp[i] = 从 0~i 范围能偷到的最大金额(不一定要偷 i)
Step 2:分析最后一步 最后一间房,要么偷(拿钱 + 跳过前一个),要么不偷(等于前 i-1 间的最优)
Step 3:写出方程 dp[i] = max(dp[i-1], dp[i-2] + nums[i])
Step 4:边界与顺序 dp[0]=nums[0], dp[1]=max(nums[0], nums[1]),从 2 到 n-1 正序循环

Step 1:定义状态

为什么这样定义? 尝试直接定义"第 i 间"容易卡住,因为偷了 i 就不能偷 i-1。用范围最优(0~i 范围能拿到的最大值)更自然,不要求一定偷 i。

dp[i] = 从第 0 间到第 i 间(包含),能偷到的最大金额

Step 2:分析最后一步

来到最后一间房,两个选择:

选择 后果 总金额
不能偷第 i-1 间,但第 i-2 间可以偷 dp[i-2] + nums[i]
不偷 第 i-1 间可偷可不偷 dp[i-1]

Step 3:写出方程

dp[i] = max(dp[i-1], dp[i-2] + nums[i])

Step 4:边界与验证

dp[0] = nums[0]                   ← 只有一个房间,只能偷它
dp[1] = max(nums[0], nums[1])     ← 两个房间,选钱多的

验证 [100, 1, 1, 1000]

dp[0] = 100
dp[1] = max(100, 1) = 100
dp[2] = max(dp[1], dp[0] + nums[2]) = max(100, 100+1) = 101
dp[3] = max(dp[2], dp[1] + nums[3]) = max(101, 100+1000) = 1100 ✓

代码实现

class Solution:
    def rob(self, nums: list[int]) -> int:
        n = len(nums)
        if n == 1:
            return nums[0]
        dp = [0] * n
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])
        for i in range(2, n):
            dp[i] = max(dp[i-1], dp[i-2] + nums[i])
        return dp[n-1]

空间优化(只依赖前两个状态):

class Solution:
    def rob(self, nums: list[int]) -> int:
        if len(nums) == 1:
            return nums[0]
        prev2, prev1 = nums[0], max(nums[0], nums[1])
        for i in range(2, len(nums)):
            prev2, prev1 = prev1, max(prev1, prev2 + nums[i])
        return prev1

六、安全锁清单

6.1 dp 数组大小怎么定?

长度 = n     → 一般对应输入规模,下标 0~n-1
长度 = n+1   → 下标 0 做 dummy/边界,下标 1~n 对应实际数据

经验: 如果递推式里出现了 dp[i-2],数组长度至少为 n。如果 dp[0] 对应第一个元素,那 dp[n-1] 就是答案。

6.2 下标从 0 还是 1 开始?

用从 0 开始的下标 + 题目描述的自然语言做对照:

自然语言 下标 0-based 常见写法
第 1 个房间 nums[0] dp[0] = nums[0]
第 n 个房间 nums[n-1] 答案在 dp[n-1]

常见错误: 递推式里 i 从 1 开始循环但写成了 dp[i-2] 导致下标变负。解决:统一让 i 从 2 开始循环。

6.3 什么时候用一维 dp,什么时候用二维?

判断依据 一维 二维
自变量个数 1 个(如金额、长度) 2 个(如区间起止、背包容量+物品)
典型题 爬楼梯、打家劫舍 不同路径、0-1 背包
口诀 "一个维度管到底" "两个维度交叉出结果"

6.4 dp[i-2] 不保证偷了 i-2,用起来放心吗?

放心。dp[i-2] 是 0~i-2 范围的最优解,不管它具体怎么选,i-2 和 i 之间隔着 i-1,一定不会相邻

所以 dp[i-2] + nums[i] 永远是一个合法方案,不需要知道 dp[i-2] 的内部细节——这就是 DP 的"黑盒"抽象。

6.5 循环方向搞反了怎么办?

场景 正确方向 原因
线性递推(爬楼梯、打家劫舍) 从小到大(正序) dp[i] 依赖 dp[i-1]/dp[i-2],小的要先算好
0-1 背包一维优化 从大到小(逆序) 防止同一个物品被重复使用
完全背包一维优化 从小到大(正序) 允许同一个物品被重复使用
区间 DP 按区间长度从小到大 长区间依赖短区间的结果

6.6 递推关系推出来了,但答案不对?

按这个排查顺序走:

① 检查 dp 数组定义 → 和问题结果对得上吗?
② 检查边界条件 → dp[0]/dp[1] 手算验证了吗?
③ 检查递推下标 → dp[i] 依赖的是 dp[i-1] 还是 dp[i-2],写对了没?
④ 检查循环范围 → 从哪到哪?最后一个下标覆盖到了吗?
⑤ 检查返回值 → dp[n-1] 还是 max(dp)?

七、进阶方向

变种题目

题型 变化点 难度
打家劫舍 II 房间围成一圈(首尾相邻) ⭐⭐
打家劫舍 III 房间变成二叉树(树形 DP) ⭐⭐⭐
不同路径 II 网格中有障碍物 ⭐⭐
最小路径和 路径有代价,求最小 ⭐⭐
完全平方数 类似零钱兑换但面值是平方数 ⭐⭐

学习路线

爬楼梯(入门)→ 打家劫舍(线性DP)→ 不同路径(二维DP)
→ 0-1 背包(选或不选)→ 完全背包(无限取用)
→ 最长递增子序列(LIS)→ 最长公共子序列(LCS)
→ 区间DP(戳气球)→ 树形DP(打家劫舍 III)

优化方向

  • 空间优化:大部分二维 DP 可以压成一维(滚动数组)
  • 状态压缩:当状态是集合时用位运算替代数组(如旅行商问题)
  • 四边形不等式:某些区间 DP 的 k 枚举范围可以剪枝
  • DP + 二分:LIS 的 O(n log n) 优化

记住:DP 不是玄学,是"暴力枚举 + 聪明缓存"。递推关系靠从结尾往前想得出,代码实现靠从 0 到 n 正向填表。先学会定义状态,再谈优化。如果卡住了,回到最小规模手动枚举,找到规律再泛化。