解决动态规划(Dynamic Programming, DP)问题,就像是拼拼图:你不能直接看到全貌,但可以通过组合已经拼好的小板块来构建大图。

该问题特征识别: 状态转移方程, 最优子结构

DP四部曲

通常我们可以遵循“DP 四部曲”来攻克任何一道动规题:

确定 DP 数组及其下标的含义

这是最关键的一步。你需要明确 $dp[i]$ 到底代表什么? 即怎么用数据模型描述

确定状态转移方程

也就是“找关系”。思考:要到达 $dp[i]$,前一步可能在哪里?

$$dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]$$

初始化 DP 数组

DP 是靠推导出来的,所以必须有“第一块砖”。

确定遍历顺序

从前往后还是从后往前?

总结:从“记忆化”到“空间优化”

当你写出基础的代码后,可以尝试更高级的思考:

阶段核心思想优缺点
暴力递归尝试所有路径存在大量重复计算,效率极低(容易超时)。
备忘录法递归 + 哈希表/数组记录算过的结果,效率大幅提升。
迭代 DP填表法(从小到大)逻辑清晰,是面试的主流解法。
空间优化滚动变量如果 $dp[i]$ 只依赖前两项,可以用两个变量代替数组,空间复杂度从 $O(n)$ 降到 $O(1)$。

子问题拆解:DP 思维的核心突破口

解决"动态规划(DP)或递归问题中如何拆解子问题"是算法设计中最核心的思维突破口。

要掌握子问题的拆解,可以遵循一套标准的思考框架,并结合具体的拆解模式来训练直觉。

子问题拆解的"三步思考法"

在拆解子问题时,不要一上来就尝试写代码,而是依次回答以下 3 个核心问题:

寻找"最后一步"(Last Step)

假设原问题已经被解决,思考形成这个最终解的前一步发生了什么

识别"变动状态"(Variables)

要描述任何一个子问题,需要哪些参数(维度)才能唯一确定它?

建立"无后效性"的边界与转移(Transition & State)

子问题的求解不能依赖后续更高级别的决策,只能依赖比它规模更小、已经解决完的子状态。

常见的子问题拆解模式

绝大多数 DP 题目的拆解可以归纳为以下 4 种基本模式:

模式 1:前缀/后缀分解(从序列一端剥离)

每次决策只影响序列的开头或结尾。

$$\text{Problem}(n) \to \text{Choose}(n) + \text{Problem}(n-1)$$

模式 2:双序列双指针分解

同时处理两个序列(如字符串、数组),通常看它们的结尾字符/元素。

模式 3:区间/分割点分解(从中间切开)

问题的解答取决于将一个大区间切分成若干小区间,或者从中间某个位置做出选择。

$$\text{Problem}(i, j) = \min_{k} \Big( \text{Problem}(i, k) + \text{Problem}(k+1, j) + \text{Cost} \Big)$$

模式 4:状态维度叠加(状态机/约束扩展)

除了位置维度,还需要额外的维度来记录"当前的状态或选择限制"。

实战演练:如何用"切分点思维"思考复杂题

87. Scramble String 为例,直觉拆解流程如下:

  1. 原问题:$s_1[0\dots N-1]$ 和 $s_2[0\dots N-1]$ 是否 match?
  2. 问自己"最后一步":$s_1$ 肯定在某个位置 $p$ 被切成了左右两半。
  3. 推导子问题
    • 左半部分长度 $p$:$s_1[0 \dots p-1]$
    • 右半部分长度 $N-p$:$s_1[p \dots N-1]$
  4. 问自己"对应关系":$s_2$ 怎么切?
    • 不交换:$s_2[0 \dots p-1]$ 和 $s_2[p \dots N-1]$
    • 交换:$s_2[N-p \dots N-1]$ 和 $s_2[0 \dots N-p-1]$
  5. 归纳子问题形式:发现子问题变成了:"$s_1$ 中长为 $k$ 的某段子串" 与 “$s_2$ 中长为 $k$ 的某段子串” 是否匹配。
  6. 参数化定义:确定状态需要 3 个参数:length(长度 $k$)、start1($s_1$ 起点 $i$)、start2($s_2$ 起点 $j$)。

避坑训练建议

  1. 从自顶向下的递归(DFS + 记忆化)开始训练:不要一开始就强迫自己写自底向上的三维/四维循环。写 DFS 时,函数的参数(如 dfs(a, b))就是子问题的定义,函数内部的 for 循环就是子问题的拆解逻辑。
  2. 画递归树(Recursion Tree):拿一个很短的输入(如 length = 3 或 4),在纸上画出递归调用的展开树,观察哪些分支的参数是一模一样的,这就是重叠子问题。
  3. 先抽象参数,再找方程:如果觉得无从下手,先在纸上列出:描述当前状态最少需要几个变量? 变量找准了,转移方程往往水到渠成。

DP 的通用本质与统一范式

动态规划的核心本质不是某种特定的“公式”,而是一种问题的求解框架与思想。虽然各种题目呈现的状态方程和遍历顺序千差万别,但底层都遵循着统一的决策范式。

统一的状态转移范式

所有的 DP 状态本质上都是在解答:

$$\text{DP}(\text{当前状态 } S) = \text{最优值}$$

其通用状态转移公式可表示为:

$$\text{DP}(\text{State}) = \text{Optimum}_{\text{action} \in \text{Actions}} \Big( \text{DP}(\text{NextState}(\text{State}, \text{action})) + \text{Cost}(\text{action}) \Big)$$

经典场景的映射关系如下:

场景当前 State合法选择 ActionsNextState (转移去哪)Optimum
二项系数$(i, j)$选 / 不选第 $i$ 个$(i-1, j-1)$ 或 $(i-1, j)$$\sum$ (求和)
Floyd$(k, i, j)$经过 / 不经过点 $k$$(k-1, i, k) + (k-1, k, j)$ 或 $(k-1, i, j)$$\min$ (找最短)
最优 BST$[i, j]$ 区间枚举根节点 $r \in [i, j]$$[i, r-1]$ 与 $[r+1, j]$$\min$ (找最小代价)
0-1 背包$(i, V)$选 / 不选第 $i$ 个物品$(i-1, V - w_i)$ 或 $(i-1, V)$$\max$ (找最大价值)

统一的建模解题套路

遇到任何 DP 问题,都可以按以下三步拆解:

  1. 定义“状态”(精简历史): 用最少的信息描述当前局势,遵循无后效性原则(只关心现在处于什么状态,不关心是如何到达这里的)。
  2. 穷举“决策”(列出分支): 列出当前状态下的合法选择以及选择带来的收益与状态演进。
  3. 确定“基底与拓扑序”(从小到大): 明确已知的基础状态(Base Case),并确保计算某状态时其依赖的子状态已求解完成。

总结: 动态规划是用状态去压缩历史,用决策去连接未来,用记忆去消除重复计算

经典 DP 模型与 LeetCode 题解

背包模型

0-1 背包:分割等和子集 (LeetCode 416)

func canPartition(nums []int) bool {
    totalSum := 0
    for _, num := range nums {
        totalSum += num
    }
    if totalSum%2 != 0 {
        return false
    }

    target := totalSum / 2
    dp := make([]bool, target+1)
    dp[0] = true // 容量为 0 时默认可填满

    for _, num := range nums {
        // 0-1背包倒序遍历容量,防止重复使用同一个物品
        for j := target; j >= num; j-- {
            dp[j] = dp[j] || dp[j-num]
        }
    }

    return dp[target]
}

完全背包:零钱兑换 (LeetCode 322)

func coinChange(coins []int, amount int) int {
    // dp[i] 初始化为 amount+1 表示不可达(相当于无穷大)
    dp := make([]int, amount+1)
    for i := range dp {
        dp[i] = amount + 1
    }
    dp[0] = 0 // 凑齐金额 0 需要 0 个硬币

    // 完全背包正序遍历容量,允许重复选取硬币
    for i := 1; i <= amount; i++ {
        for _, coin := range coins {
            if i-coin >= 0 && dp[i-coin]+1 < dp[i] {
                dp[i] = dp[i-coin] + 1
            }
        }
    }

    if dp[amount] > amount {
        return -1
    }
    return dp[amount]
}

线性与子序列模型

最长递增子序列 LIS (LeetCode 300)

func lengthOfLIS(nums []int) int {
    if len(nums) == 0 {
        return 0
    }

    dp := make([]int, len(nums))
    maxLen := 1

    for i := range nums {
        dp[i] = 1 // 每个元素自身都是一个长度为 1 的子序列
        for j := 0; j < i; j++ {
            if nums[j] < nums[i] && dp[j]+1 > dp[i] {
                dp[i] = dp[j] + 1
            }
        }
        if dp[i] > maxLen {
            maxLen = dp[i]
        }
    }

    return maxLen
}

最长公共子序列 LCS (LeetCode 1143)

$$\begin{aligned} dp[i][j] = \begin{cases} dp[i-1][j-1] + 1, & \text{if } text1[i-1] == text2[j-1] \\ \max(dp[i-1][j], dp[i][j-1]), & \text{if } text1[i-1] \neq text2[j-1] \end{cases} \end{aligned}$$
func longestCommonSubsequence(text1 string, text2 string) int {
    m, n := len(text1), len(text2)
    dp := make([][]int, m+1)
    for i := range dp {
        dp[i] = make([]int, n+1)
    }

    for i := 1; i <= m; i++ {
        for j := 1; j <= n; j++ {
            if text1[i-1] == text2[j-1] {
                dp[i][j] = dp[i-1][j-1] + 1
            } else {
                if dp[i-1][j] > dp[i][j-1] {
                    dp[i][j] = dp[i-1][j]
                } else {
                    dp[i][j] = dp[i][j-1]
                }
            }
        }
    }

    return dp[m][n]
}

最长公共子串 (Longest Common Substring)

子序列 vs 子串

  • 子序列 (Subsequence):字符相对顺序不变,但不要求连续
  • 子串 (Substring):字符必须严格连续
$$dp[i][j] = \begin{cases} dp[i-1][j-1] + 1, & \text{if } str1[i-1] == str2[j-1] \\ 0, & \text{if } str1[i-1] \neq str2[j-1] \end{cases}$$
func longestCommonSubstring(str1 string, str2 string) int {
    m, n := len(str1), len(str2)
    dp := make([][]int, m+1)
    for i := range dp {
        dp[i] = make([]int, n+1)
    }

    maxLen := 0
    for i := 1; i <= m; i++ {
        for j := 1; j <= n; j++ {
            if str1[i-1] == str2[j-1] {
                dp[i][j] = dp[i-1][j-1] + 1
                if dp[i][j] > maxLen {
                    maxLen = dp[i][j]
                }
            } else {
                dp[i][j] = 0 // 不相等则断开,连续性中断,重置为 0
            }
        }
    }

    return maxLen
}