有 $n$ 个气球排成一排,每个气球上标有一个数字。当你戳破第 $i$ 个气球时,你会获得 nums[i-1] * nums[i] * nums[i+1] 枚硬币。如果相邻位置超出边界,则视为数字为 1 的虚拟气球。问题是:如何安排戳气球的顺序,才能获得最多的硬币?

这个看似简单的问题困扰了无数面试者。直觉告诉我们要先戳破小的气球,或者先戳破边界——但所有贪心策略都会失败。真正正确的思路是逆向思考:考虑最后一个被戳破的气球是谁。这种"从两端向中间收缩"的思考方式,正是区间动态规划(Interval DP)的核心精髓。

区间动态规划是动态规划家族中一个独特而重要的分支。与常见的线性 DP(状态基于前缀)不同,区间 DP 的状态定义在任意子区间上,状态转移涉及区间的分割或收缩。掌握这一模式,意味着你能优雅地解决矩阵链乘法、回文串、多边形三角剖分等经典问题。

区间DP的本质:状态定义在区间两端

常规的动态规划问题,状态通常是"前 $i$ 个元素"或"以位置 $i$ 结尾"——这是一种"从左到右"的线性思维。但有一类问题,其最优解的结构天然依赖于区间的两端。

以最长回文子序列为例:要判断字符串 s[l...r] 的最长回文子序列长度,我们需要知道其内部子区间 s[l+1...r-1]s[l+1...r]s[l...r-1] 的解。这种依赖关系无法用线性 DP 表达——因为状态不仅涉及"左边",还涉及"右边"。

区间 DP 的状态定义形式为 dp[l][r],表示区间 [l, r] 上的最优解。状态转移通常有两种模式:

区间分割模式:将区间 [l, r] 在某个位置 $k$ 分割成两个子区间 [l, k][k+1, r],分别求解后合并。

$$dp[l][r] = \min_{l \leq k < r} \{dp[l][k] + dp[k+1][r] + cost(l, k, r)\}$$

区间收缩模式:根据端点元素的关系,将区间向内收缩。

$$dp[l][r] = \begin{cases} dp[l+1][r-1] + 2 & \text{if } s[l] = s[r] \\ \max(dp[l+1][r], dp[l][r-1]) & \text{otherwise} \end{cases}$$

无论哪种模式,核心都是:大区间的解依赖于小区间的解。这决定了遍历顺序必须"先小区间,后大区间"。

遍历顺序:为什么必须按长度递增

这是区间 DP 最容易出错的地方。很多初学者习惯性地按 l 从 0 到 n-1、r 从 l+1 到 n-1 的顺序遍历,结果发现状态转移时引用的子问题尚未计算。

正确的遍历顺序应该是按区间长度递增:先计算所有长度为 1 的区间,再计算长度为 2 的区间,依此类推。这样,当计算 dp[l][r] 时,所有可能的子区间(长度都小于 r-l+1)都已经计算完毕。

// 正确的遍历顺序:按区间长度递增
for (int len = 1; len <= n; len++) {          // 区间长度
    for (int l = 0; l + len - 1 < n; l++) {   // 左端点
        int r = l + len - 1;                   // 右端点
        // 状态转移...
    }
}

另一种等价的写法是从右下角往左上角填充:外层循环 ln-1 递减到 0,内层循环 rl+1 递增到 n-1。这种写法的原理是:dp[l][r] 依赖于 dp[l+1][...]dp[...][r-1],而 l+1 > l(下一行已计算),r-1 < r(左边已计算)。

// 另一种正确的遍历顺序:从下往上,从左往右
for (int l = n - 1; l >= 0; l--) {
    for (int r = l + 1; r < n; r++) {
        // 状态转移...
    }
}

理解遍历顺序的关键是画出 DP 表格,观察状态之间的依赖关系。dp[l][r] 位于表格的第 l 行、第 r 列,它依赖于"更下面"和"更左边"的格子。因此,我们必须从下往上、从左往右填充。

最长回文子序列:区间收缩的经典

LeetCode 516 要求找出字符串的最长回文子序列长度。子序列不要求连续,这正是区间 DP 的典型场景。

状态定义dp[l][r] 表示子串 s[l...r] 中最长回文子序列的长度。

状态转移

  • 如果 s[l] == s[r],两端字符可以配对,贡献长度 2:dp[l][r] = dp[l+1][r-1] + 2
  • 否则,至少有一个端点不在回文中,取两种收缩方式的较大值:dp[l][r] = max(dp[l+1][r], dp[l][r-1])

边界条件:单个字符本身就是回文,dp[i][i] = 1

class Solution {
    public int longestPalindromeSubseq(String s) {
        int n = s.length();
        int[][] dp = new int[n][n];
        
        // 单个字符是回文,长度为1
        for (int i = 0; i < n; i++) {
            dp[i][i] = 1;
        }
        
        // 按区间长度递增遍历
        for (int len = 2; len <= n; len++) {
            for (int l = 0; l + len - 1 < n; l++) {
                int r = l + len - 1;
                if (s.charAt(l) == s.charAt(r)) {
                    // 两端字符相同,可以配对
                    dp[l][r] = dp[l + 1][r - 1] + 2;
                } else {
                    // 两端字符不同,取收缩后的较大值
                    dp[l][r] = Math.max(dp[l + 1][r], dp[l][r - 1]);
                }
            }
        }
        
        return dp[0][n - 1];
    }
}

复杂度分析:时间复杂度 $O(n^2)$,空间复杂度 $O(n^2)$。可以通过滚动数组将空间优化到 $O(n)$。

这个问题的美妙之处在于状态转移极其简洁——只需比较两端字符,然后决定是"配对"还是"收缩"。这体现了区间 DP 的核心思想:问题的解由边界向中心收敛。

戳气球:逆向思维的区间分割

LeetCode 312 是区间 DP 中最经典也是最难的问题之一。关键洞察是:正向思考极其复杂,逆向思考则柳暗花明

正向思考的问题是:戳破一个气球后,左右相邻的气球变成新的邻居,状态难以追踪。但如果我们逆向思考——考虑哪个气球最后被戳破——问题就豁然开朗了。

假设在区间 (l, r) 开区间内(不含端点),气球 k 是最后被戳破的。那么:

  1. 戳破 k 时,它的左右邻居一定是 lr(因为其他气球都已被戳破)
  2. 获得的硬币是 nums[l] * nums[k] * nums[r]
  3. 左边区间 (l, k) 和右边区间 (k, r) 的求解是独立的

这完美符合区间分割的模式!

状态定义dp[l][r] 表示在开区间 (l, r) 内戳破所有气球能获得的最大硬币数。

状态转移

$$dp[l][r] = \max_{l < k < r} \{dp[l][k] + dp[k][r] + nums[l] \times nums[k] \times nums[r]\}$$

技巧:在数组两端添加虚拟气球(值为 1),简化边界处理。

class Solution {
    public int maxCoins(int[] nums) {
        int n = nums.length;
        // 添加虚拟气球
        int[] arr = new int[n + 2];
        arr[0] = 1;
        arr[n + 1] = 1;
        for (int i = 0; i < n; i++) {
            arr[i + 1] = nums[i];
        }
        
        // dp[l][r] 表示开区间 (l, r) 内的最大硬币
        int[][] dp = new int[n + 2][n + 2];
        
        // 区间长度从3开始(至少需要一个气球)
        for (int len = 3; len <= n + 2; len++) {
            for (int l = 0; l + len - 1 <= n + 1; l++) {
                int r = l + len - 1;
                // 枚举最后被戳破的气球
                for (int k = l + 1; k < r; k++) {
                    dp[l][r] = Math.max(dp[l][r], 
                        dp[l][k] + dp[k][r] + arr[l] * arr[k] * arr[r]);
                }
            }
        }
        
        return dp[0][n + 1];
    }
}

复杂度分析:时间复杂度 $O(n^3)$(三重循环),空间复杂度 $O(n^2)$。

这个问题的精妙之处在于:通过逆向思维,将一个"动态变化"的问题转化为标准的区间 DP。关键洞察是:最后被戳破的气球,其邻居是确定的——就是区间的两个端点。

多边形三角剖分:几何与动态规划的交融

LeetCode 1039 将区间 DP 应用到几何问题上。给定凸多边形的顶点值,要求将其分割成若干三角形,使得所有三角形的得分之和最小。每个三角形的得分是其三个顶点值的乘积。

直觉:凸多边形的三角剖分问题与矩阵链乘法有相似的结构——都需要在某个位置"切一刀",然后递归处理子问题。

状态定义dp[l][r] 表示顶点 l 到顶点 r 形成的多边形(凸包)的最小得分。

状态转移:选择一个顶点 k 作为三角形的第三个顶点,将多边形分割成一个三角形和两个子多边形。

$$dp[l][r] = \min_{l < k < r} \{dp[l][k] + dp[k][r] + values[l] \times values[k] \times values[r]\}$$

这与戳气球的状态转移几乎一模一样!

class Solution {
    public int minScoreTriangulation(int[] values) {
        int n = values.length;
        int[][] dp = new int[n][n];
        
        // 区间长度从3开始(三角形至少需要3个顶点)
        for (int len = 3; len <= n; len++) {
            for (int l = 0; l + len - 1 < n; l++) {
                int r = l + len - 1;
                dp[l][r] = Integer.MAX_VALUE;
                // 枚举第三个顶点
                for (int k = l + 1; k < r; k++) {
                    dp[l][r] = Math.min(dp[l][r], 
                        dp[l][k] + dp[k][r] + values[l] * values[k] * values[r]);
                }
            }
        }
        
        return dp[0][n - 1];
    }
}

复杂度分析:时间复杂度 $O(n^3)$,空间复杂度 $O(n^2)$。

这个问题揭示了区间 DP 的一个重要特点:状态转移的形式可能在不同问题中惊人地相似。戳气球和多边形三角剖分,一个是博弈问题,一个是几何问题,但它们共享完全相同的状态转移结构——都是通过枚举分割点,将大区间分解为两个独立的子区间。

最少插入形成回文:动态填充的策略

LeetCode 1312 要求在字符串任意位置插入字符,使其成为回文串,求最少插入次数。

分析:要让字符串成为回文,可以考虑保留其中的某个最长回文子序列,然后在相应位置插入字符来"补齐"其他字符。因此,答案等于 n - 最长回文子序列长度

不过,我们也可以直接用区间 DP 求解,这样思路更加直观。

状态定义dp[l][r] 表示让子串 s[l...r] 成为回文的最少插入次数。

状态转移

  • 如果 s[l] == s[r],两端已经匹配,无需插入:dp[l][r] = dp[l+1][r-1]
  • 否则,可以插入一个字符与 s[l] 配对(等于处理 s[l+1...r]),或插入一个字符与 s[r] 配对(等于处理 s[l...r-1]): dp[l][r] = min(dp[l+1][r], dp[l][r-1]) + 1
class Solution {
    public int minInsertions(String s) {
        int n = s.length();
        int[][] dp = new int[n][n];
        
        // 从下往上,从左往右填充
        for (int l = n - 2; l >= 0; l--) {
            for (int r = l + 1; r < n; r++) {
                if (s.charAt(l) == s.charAt(r)) {
                    dp[l][r] = dp[l + 1][r - 1];
                } else {
                    dp[l][r] = Math.min(dp[l + 1][r], dp[l][r - 1]) + 1;
                }
            }
        }
        
        return dp[0][n - 1];
    }
}

复杂度分析:时间复杂度 $O(n^2)$,空间复杂度 $O(n^2)$。

这个问题展示了区间 DP 的另一种模式:通过"填充"操作消除不匹配。与回文子序列问题相比,这里的状态转移同样简洁,但目标是"最小操作数"而非"最大长度"。

扰乱字符串:递归结构的复杂性

LeetCode 87 是区间 DP 中最具挑战性的问题之一。判断字符串 s2 是否可以由 s1 通过"扰乱"操作得到——即将字符串分割成两部分,可以选择交换这两部分,然后递归地对每部分进行同样的操作。

这个问题的难点在于:除了区间端点,还需要额外状态来表示"区间长度"和"是否交换"。

状态定义dp[i][j][len] 表示 s1 从位置 i 开始、s2 从位置 j 开始、长度为 len 的子串是否可以相互扰乱得到。

状态转移:枚举分割点 k(1 到 len-1),检查两种情况:

  1. 不交换:s1[i...i+k-1]s2[j...j+k-1] 匹配,且 s1[i+k...i+len-1]s2[j+k...j+len-1] 匹配
  2. 交换:s1[i...i+k-1]s2[j+len-k...j+len-1] 匹配,且 s1[i+k...i+len-1]s2[j...j+len-k-1] 匹配
class Solution {
    private Boolean[][][] memo;
    private String s1, s2;
    
    public boolean isScramble(String s1, String s2) {
        int n = s1.length();
        this.s1 = s1;
        this.s2 = s2;
        memo = new Boolean[n][n][n + 1];
        return dfs(0, 0, n);
    }
    
    private boolean dfs(int i, int j, int len) {
        if (memo[i][j][len] != null) {
            return memo[i][j][len];
        }
        
        // 长度为1,直接比较字符
        if (len == 1) {
            return s1.charAt(i) == s2.charAt(j);
        }
        
        // 剪枝:字符频率必须相同
        int[] count = new int[26];
        for (int k = 0; k < len; k++) {
            count[s1.charAt(i + k) - 'a']++;
            count[s2.charAt(j + k) - 'a']--;
        }
        for (int c : count) {
            if (c != 0) {
                memo[i][j][len] = false;
                return false;
            }
        }
        
        // 枚举分割点
        for (int k = 1; k < len; k++) {
            // 不交换
            if (dfs(i, j, k) && dfs(i + k, j + k, len - k)) {
                memo[i][j][len] = true;
                return true;
            }
            // 交换
            if (dfs(i, j + len - k, k) && dfs(i + k, j, len - k)) {
                memo[i][j][len] = true;
                return true;
            }
        }
        
        memo[i][j][len] = false;
        return false;
    }
}

复杂度分析:时间复杂度 $O(n^4)$,空间复杂度 $O(n^3)$。

这个问题的复杂度高于典型区间 DP,因为需要额外维度来表示区间长度。但核心思想仍然是区间分割——将大区间分解为小区间的组合。

区间DP的通用模板

总结上述问题,区间 DP 有以下通用模式:

识别特征

  • 问题涉及线性序列(数组或字符串)
  • 答案可以由子区间的答案组合得到
  • 通常与区间分割、区间合并、区间操作相关

状态定义dp[l][r] 表示区间 [l, r] 上的最优解

遍历顺序:按区间长度递增

// 通用区间DP模板
for (int len = 最小长度; len <= n; len++) {
    for (int l = 0; l + len - 1 < n; l++) {
        int r = l + len - 1;
        if (len == 最小长度) {
            // 边界条件
            dp[l][r] = 基础值;
        } else {
            // 枚举分割点
            for (int k = l; k < r; k++) {
                dp[l][r] = 最优(dp[l][r], dp[l][k] 与 dp[k+1][r] 的组合);
            }
            // 或根据端点关系收缩
            if (条件) {
                dp[l][r] = dp[l+1][r-1] + 增量;
            } else {
                dp[l][r] = 最优(dp[l+1][r], dp[l][r-1]);
            }
        }
    }
}

时间复杂度:通常为 $O(n^2)$ 到 $O(n^3)$,取决于状态转移的复杂度

空间复杂度:$O(n^2)$,某些问题可以优化到 $O(n)$

更多经典题目

题目 难度 状态转移类型 时间复杂度
LeetCode 516 最长回文子序列 中等 区间收缩 $O(n^2)$
LeetCode 312 戳气球 困难 区间分割 $O(n^3)$
LeetCode 1039 多边形三角剖分 中等 区间分割 $O(n^3)$
LeetCode 1312 最少插入形成回文 困难 区间收缩 $O(n^2)$
LeetCode 87 扰乱字符串 困难 区间分割+交换 $O(n^4)$
LeetCode 132 分割回文串 II 困难 区间收缩 $O(n^2)$
LeetCode 1000 合并石头 困难 区间分割 $O(n^3)$
LeetCode 546 移除盒子 困难 区间分割+扩展状态 $O(n^4)$

区间动态规划的魅力在于其优雅的数学结构:大问题的解由小问题的解递归构建,而遍历顺序保证了这种递归的正确性。从最长回文子序列的简洁转移,到戳气球的逆向思维,再到扰乱字符串的多维状态,区间 DP 覆盖了从入门到精通的完整路径。记住核心原则:状态定义在区间两端,遍历顺序按长度递增——这是解决所有区间 DP 问题的万能钥匙。