Skip to content

区间与状态机 DP

动态规划 ⭐⭐⭐ 进阶 🔥 高频

💡 核心要点

  • 区间 DP:答案与连续区间相关,大区间由小区间合并/分割得到;遍历顺序必须从短区间到长区间
  • 状态机 DP:每个时刻处于某个"状态",先画状态转移图,图画好了方程自然就写出来了
  • 两类问题都有固定的建模套路,掌握框架后可以快速识别并套用

Part 1:区间 DP

识别信号

遇到以下特征,优先考虑区间 DP:

  • 问题的答案与一段连续区间 [l, r] 相关
  • 大区间的最优解可以通过枚举某个分割点,由若干小区间的最优解合并或分割得到
  • 典型描述:合并石子、戳气球、括号合法性、矩阵链乘法、多边形剖分

通用思考框架

第一步:定义状态

dp[l][r] = 区间 [l, r] 上问题的最优解(最大值/最小值/方案数等)

lr 分别是区间的左右端点(通常下标从 1 开始,方便处理边界)。

第二步:状态转移——枚举分割点 k

区间 DP 的核心操作是枚举分割点 k,将 [l, r] 拆分为 [l, k][k+1, r] 两个子区间:

dp[l][r] = max/min over k in [l, r-1] of { dp[l][k] + dp[k+1][r] + cost(l, k, r) }

有时候更自然的思路是**"最后一次操作"思维**:想象对区间 [l, r] 执行一系列操作,最后一次操作涉及哪个元素/位置?

为什么"最后操作"思维有效?

假设最后操作作用在 k 上:

  • 在执行最后一步之前,k 把区间分成了 [l, k-1][k+1, r] 两部分
  • 这两部分的操作已经相互独立地完成了
  • 因此子问题 dp[l][k-1]dp[k+1][r] 不会相互干扰,可以分别求最优

这是区间 DP 有别于线性 DP 的关键:线性 DP 从左到右逐步扩展,而区间 DP 从内向外层层包裹。

第三步:遍历顺序——区间长度从小到大

java
for (int len = 2; len <= n; len++) {       // 枚举区间长度
    for (int l = 1; l + len - 1 <= n; l++) { // 枚举左端点
        int r = l + len - 1;               // 确定右端点
        for (int k = l; k < r; k++) {      // 枚举分割点
            dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r] + cost);
        }
    }
}

为什么必须从短区间到长区间?

dp[l][r] 的值依赖于 dp[l][k]dp[k+1][r],而这两个子区间的长度都严格小于 r - l + 1。因此,当我们计算长度为 len 的区间时,所有长度小于 len 的区间必须已经计算完毕。

如果按行或按列遍历,会出现计算 dp[l][r] 时子区间还没被填好的情况,导致错误结果。

第四步:初始化

长度为 1 的区间(单个元素)是边界条件,通常直接赋值:

java
for (int i = 1; i <= n; i++) {
    dp[i][i] = base_value; // 根据题意,可能是 0、元素值等
}

第五步:时间与空间复杂度

  • 时间复杂度:O(n³)——三层循环(区间长度 × 左端点 × 分割点)
  • 空间复杂度:O(n²)——二维 dp 数组

典型例题:戳气球

LeetCode 312

题目描述:有 n 个气球,编号 0 到 n-1,每个气球上有数字 nums[i]。戳破气球 i 可以得到 nums[i-1] * nums[i] * nums[i+1] 枚硬币(边界外视为 1)。求戳破所有气球能获得的最多硬币数。

五步法分析

第一步:找到"最后操作"的关键洞察

直觉上我们想枚举"第一个戳哪个气球",但这行不通——戳破气球 i 后,i-1 和 i+1 会变成相邻,子问题边界会动态变化,子问题之间不独立

正确做法:枚举哪个气球最后被戳破

为什么这样有效?如果气球 k 是区间 [l, r]最后一个被戳破的,那么在戳 k 之前,[l, k-1][k+1, r] 的气球都已经戳完了。此时 k 的邻居正好是 nums[l-1](左边界外)和 nums[r+1](右边界外),不再变动。子问题因此独立。

第二步:添加虚拟气球

在两端各添加一个值为 1 的虚拟气球,避免边界讨论:

java
int[] balloons = new int[n + 2];
balloons[0] = balloons[n + 1] = 1;
for (int i = 1; i <= n; i++) balloons[i] = nums[i - 1];

第三步:状态定义

dp[l][r] = 戳破开区间 (l, r) 内所有气球能获得的最大硬币数

注意这里用开区间lr 是不会被戳的边界气球(虚拟或真实)。这样转移时 balloons[l]balloons[r] 始终是固定的边界。

第四步:状态转移

枚举开区间 (l, r)最后一个被戳破的气球 k:

dp[l][r] = max over k in (l, r) of {
    dp[l][k] + balloons[l] * balloons[k] * balloons[r] + dp[k][r]
}

解释:

  • dp[l][k]:先将 (l, k) 内的气球全部戳完
  • balloons[l] * balloons[k] * balloons[r]:最后戳 k,此时左邻是 l,右邻是 r
  • dp[k][r]:再将 (k, r) 内的气球全部戳完

第五步:遍历顺序与初始化

  • 初始化:dp[i][i+1] = 0(开区间内没有气球,硬币为 0),已由数组初始化覆盖
  • 遍历:区间长度从 2 到 n+1(开区间长度),从小到大

Java 实现:

java
class Solution {
    public int maxCoins(int[] nums) {
        int n = nums.length;
        // 添加虚拟气球
        int[] b = new int[n + 2];
        b[0] = b[n + 1] = 1;
        for (int i = 1; i <= n; i++) b[i] = nums[i - 1];

        // dp[l][r] = 开区间 (l, r) 内所有气球戳完的最大硬币
        int[][] dp = new int[n + 2][n + 2];

        // 枚举开区间长度(至少为 2,即内部有气球)
        for (int len = 2; len <= n + 1; len++) {
            for (int l = 0; l + len <= n + 1; l++) {
                int r = l + len;
                // 枚举最后一个被戳破的气球 k
                for (int k = l + 1; k < r; k++) {
                    int coins = b[l] * b[k] * b[r] + dp[l][k] + dp[k][r];
                    dp[l][r] = Math.max(dp[l][r], coins);
                }
            }
        }

        return dp[0][n + 1];
    }
}

复杂度分析:

  • 时间:O(n³)——三层枚举
  • 空间:O(n²)——dp 数组

延伸题目

题目链接与典型题的区别关键技巧
石子合并经典题(POJ 1995 等)合并型:将相邻堆合并为一堆,求最小/最大代价分割点枚举,合并代价 = 两堆总数量(用前缀和 O(1) 查询)
多边形三角剖分最低得分LC 1039三角形划分:固定一条边,枚举第三个顶点 k环形结构→固定顶点 0,枚举区间 [l, r] 和三角形顶点 k,dp[l][r] = min(dp[l][k] + dp[k][r] + val[l]*val[k]*val[r])

Part 2:状态机 DP

识别信号

遇到以下特征,优先考虑状态机 DP:

  • 问题涉及一系列决策(如买卖、持有、冷却等),每个时刻处于某个有限状态之一
  • 不同状态之间有明确的转移规则(哪些状态可以互相切换,切换的条件和代价)
  • 典型描述:股票买卖系列、任务调度、游戏状态切换

通用思考框架

第一步:先画状态转移图

这是状态机 DP 最核心的一步,图画好了,方程自然就写出来了

  • 节点 = 所有可能的状态
  • 有向边 = 合法的状态转移
  • 边上的标签 = 转移的代价(收益为正,损失为负)

示例(通用股票状态机):

        buy(-price)          sell(+price)
not_holding  ──────────→  holding  ──────────→  not_holding
     ↑                                               │
     └───────────────────── rest ───────────────────┘

画图的过程迫使你枚举所有状态所有合法转移,不会遗漏。

第二步:状态定义

dp[i][s] = 处理到第 i 天,处于状态 s 时,能获得的最大/最小收益

s 是状态编号,通常用常量或枚举表示,使代码可读。

第三步:建模技巧——将约束转化为状态或转移代价

约束类型建模方法
冷冻期(卖出后 k 天不能买)增加"冷冻"状态,冷冻期结束后才能转移到"可买"状态
交易次数限制(最多 k 次)增加交易次数维度:dp[i][j][s],j 表示已完成 j 次交易
每次交易有手续费在"卖出"转移的代价上减去 fee
必须先买后卖由"持有"状态的存在自然保证,不需要额外约束

第四步:股票系列统一框架

所有股票问题都是以下通用状态机的特殊情形

核心状态:

  • holding:当前持有股票
  • not_holding:当前不持有股票,且可以买入
  • frozen(可选):刚卖出,处于冷冻期,不能立即买入

核心转移:

not_holding[i] = max(
    not_holding[i-1],          // rest:继续不持有
    frozen[i-1]                // 冷冻期结束,恢复可买(可选)
)

holding[i] = max(
    holding[i-1],              // rest:继续持有
    not_holding[i-1] - price[i] // buy:花钱买入
)

frozen[i] = holding[i-1] + price[i]  // sell:卖出,进入冷冻(可选)

not_holding[i] = max(
    not_holding[i-1],
    holding[i-1] + price[i]   // sell(无冷冻时)
)

通过调整这个框架的参数,即可覆盖所有股票变体。


典型例题:买卖股票含冷冻期

LeetCode 309

题目描述:每天可以买入或卖出一支股票,卖出后第二天进入冷冻期,不能买入。求最大利润。

五步法分析

第一步:画状态转移图

三个状态:

  • 0not_holding——不持股,且可以买入
  • 1holding——持有股票
  • 2frozen——冷冻期(刚卖出,今天不能买)
        buy(-price)
   0  ─────────────→  1
   ↑                  │
   │    sell(+price)  │
   │  ←──────────────  
   2  ←──────────────
        sell(+price)
   
   0 → 0 (rest)
   1 → 1 (rest)
   2 → 0 (冷冻期结束)

文字描述转移关系:

  • not_holdingholding:买入,花费 price[i]
  • holdingfrozen:卖出,获得 price[i]
  • frozennot_holding:冷冻结束,无代价
  • not_holdingnot_holding:休息
  • holdingholding:休息

第二步:状态定义

dp[i][0] = 第 i 天结束后,处于 not_holding 状态的最大利润
dp[i][1] = 第 i 天结束后,处于 holding 状态的最大利润
dp[i][2] = 第 i 天结束后,处于 frozen 状态的最大利润

第三步:状态转移方程

根据状态图直接写出:

dp[i][0] = max(dp[i-1][0], dp[i-1][2])   // 继续不持有 或 冷冻期结束
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])  // 继续持有 或 今天买入
dp[i][2] = dp[i-1][1] + prices[i]         // 今天卖出,进入冷冻

第四步:初始化

dp[0][0] = 0        // 第 0 天不持有,利润为 0
dp[0][1] = -prices[0]  // 第 0 天买入,利润为负
dp[0][2] = Integer.MIN_VALUE / 2  // 第 0 天不可能处于冷冻状态

第五步:答案

最终答案是 max(dp[n-1][0], dp[n-1][2])(不持有股票的两种状态取最大)。

Java 实现:

java
class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        // dp[i][0]: not_holding, dp[i][1]: holding, dp[i][2]: frozen
        int[][] dp = new int[n][3];

        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        dp[0][2] = Integer.MIN_VALUE / 2; // 不可达状态

        for (int i = 1; i < n; i++) {
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][2]); // rest 或 冷冻解除
            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]); // rest 或 买入
            dp[i][2] = dp[i-1][1] + prices[i]; // 卖出
        }

        return Math.max(dp[n-1][0], dp[n-1][2]);
    }
}

空间优化到 O(1):

注意 dp[i] 只依赖 dp[i-1],可以用滚动变量替代数组:

java
class Solution {
    public int maxProfit(int[] prices) {
        int notHold = 0;
        int hold = -prices[0];
        int frozen = Integer.MIN_VALUE / 2;

        for (int i = 1; i < prices.length; i++) {
            int newNotHold = Math.max(notHold, frozen);
            int newHold    = Math.max(hold, notHold - prices[i]);
            int newFrozen  = hold + prices[i];
            notHold = newNotHold;
            hold    = newHold;
            frozen  = newFrozen;
        }

        return Math.max(notHold, frozen);
    }
}

注意:必须用临时变量保存旧值,不能原地更新,否则会用到本轮已更新的状态。

复杂度分析:

  • 时间:O(n)
  • 空间:O(1)(优化后)

延伸题目

题目链接与典型题的区别关键技巧
买卖股票 ILC 121只能交易一次状态机退化为两状态;也可简化为维护前缀最小值 O(n)
买卖股票 IILC 122无限次交易,无冷冻两状态(持有/不持有),无冷冻无次数限制
买卖股票 IIILC 123最多 2 次交易增加交易次数维度:dp[i][k][s],k ∈
买卖股票 IVLC 188最多 k 次交易III 的泛化;当 k ≥ n/2 时退化为无限次(按 II 处理,防止 TLE)
含手续费LC 714每次卖出有手续费在"卖出"转移时减去 fee 即可:notHold = max(notHold, hold + price - fee)

常见陷阱与调试

💡 高频错误总结

区间 DP:

  1. 遍历顺序错误:按行/列遍历而非按区间长度遍历,导致依赖的子区间尚未计算。

    • 调试方法:打印 dp 数组,检查小区间是否先于大区间被填充。
  2. 分割点范围越界k 的范围是 [l, r-1](闭区间分割)或 (l, r)(开区间分割),混淆会导致空区间计算。

    • 记住:分割点必须使两个子区间都非空
  3. 忽略添加虚拟边界:戳气球类题目不加边界气球,转移时越界访问。

    • 凡是涉及"相邻元素乘积"的区间 DP,考虑在两端补虚拟元素。
  4. 前缀和未预处理:石子合并类题目每次计算区间和是 O(n),导致总复杂度变成 O(n⁴)。

    • 提前计算前缀和,让区间和查询降至 O(1)。

状态机 DP:

  1. 不可达状态初始化为 0:例如第 0 天冷冻状态不可能出现,应初始化为极小值(Integer.MIN_VALUE / 2),否则会产生非法转移路径。

    • MIN_VALUE / 2 而非 MIN_VALUE,防止加法时整数溢出。
  2. 空间优化时原地更新:用滚动变量优化空间时,直接覆盖旧值导致同一轮循环内后续状态用到了已更新的值。

    • 始终用临时变量保存本轮所有旧值,更新完毕后再赋值。
  3. 买卖次数定义模糊:"一次交易"是指一次买+一次卖,不要把"买"和"卖"分别计数。

  4. 忘记处理 k ≥ n/2 的退化情形(LC 188):k 很大时按通用框架会超时,应特判为无限次交易。