← 目录 / 算法文档 · 模块十八 动态规划进阶 / 18.1 区间 DP

18.1 区间 DP

状态不再是"第几个数",而是"一整段区间"——枚举一个分割点,把区间拆成两半分别求解,再合并起来。

本页目录
① 什么是区间 DP

13 模块讲过的线性 DP,状态通常是"到第 i 个位置为止";区间 DP 的状态则是一整段区间 [l, r],常见于"把一段序列不断合并/分割,求最优代价"这类问题——比如把一排石子堆不断合并成一堆、把一个字符串通过插入字符变成回文串、给一串矩阵加括号求最少乘法次数。这类问题的共同点是:一段区间的最优解,可以通过枚举一个分割点,拆成两个更小的子区间分别求解,再合并起来。

② 核心思想:枚举分割点,合并两个子区间

dp[l][r] 表示区间 [l, r] 的最优解。枚举一个分割点 kl ≤ k < r),把 [l,r] 拆成 [l,k][k+1,r] 两段,这两段各自的最优解是 dp[l][k]dp[k+1][r](已经算好),再加上"合并这两段"本身需要付出的代价,取所有分割点里最优的一种:

要素区间 DP 里对应什么
状态定义dp[l][r]:区间 [l,r] 的最优解(具体含义因题目而异)
转移方程dp[l][r] = min{ dp[l][k] + dp[k+1][r] + cost(l,r) },枚举 k∈[l,r-1]
初始化dp[i][i] = 0(区间只有一个元素,不需要合并,没有代价)
填表顺序按区间长度从小到大——先算长度为 2 的区间,再算长度为 3 的……(见 ⑥ 陷阱)
③ 图解:石子合并问题的完整填表过程

石子合并:有 n 堆石子排成一排,每次只能合并相邻的两堆,合并的代价等于这两堆石子数之和,合并后的新堆数量就是这两堆之和。反复合并直到只剩一堆,求最小的总代价。用 a = [1, 3, 5, 2](4 堆)演示:

原始石堆
1
1
2
3
3
5
4
2

这里的 cost(l,r)(合并 [l,r] 这一段最后一步的代价)就是这一段石子的总和——用 5.1 节的前缀和可以 O(1) 算出:prefix = [0,1,4,9,11]sum(l,r) = prefix[r]-prefix[l-1]

dp[l][r] 完整填表结果(按区间长度从小到大依次填)
r=1r=2r=3r=4
l=1041322
l=20817
l=307
l=40
对角线 dp[i][i]=0 是初始化的起点。以 dp[1][4](合并全部 4 堆的最小代价)为例,枚举分割点 k=1,2,3k=1: dp[1][1]+dp[2][4]+sum(1,4)=0+17+11=28k=2: dp[1][2]+dp[3][4]+sum(1,4)=4+7+11=22k=3: dp[1][3]+dp[4][4]+sum(1,4)=13+0+11=24。三者取最小,dp[1][4]=22,在 k=2 处取得。对应的实际合并方案是:先把 [1,3,5,2] 分成左边 [1,3] 和右边 [5,2] 两组,各自先合并成一堆(代价 47),再把这两堆合并(代价 4+7=11),总代价 4+7+11=22
④ 完整代码
C++ · 石子合并(区间 DP)
1int a[MAXN], prefix[MAXN], dp[MAXN][MAXN], n;
2
3int Sum(int l, int r) { return prefix[r] - prefix[l-1]; }
4
5int IntervalDP()
6{
7 for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + a[i];
8 // dp[i][i] = 0 全局数组默认已经是 0,不需要手动初始化
9
10 for (int len = 2; len <= n; len++) // ★ 按区间长度从小到大枚举
11 {
12 for (int l = 1; l + len - 1 <= n; l++)
13 {
14 int r = l + len - 1; // 由左端点 l 和长度 len 推出右端点 r
15 dp[l][r] = 1e9;
16 for (int k = l; k < r; k++) // 枚举分割点
17 dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + Sum(l, r));
18 }
19 }
20 return dp[1][n];
21}
💡
三层循环,缺一不可:最外层 len 决定"先算短区间、再算长区间"这个填表顺序;中间层 l 枚举这个长度下所有可能的起点(rl+len-1 直接算出,不需要单独循环);最内层 k 枚举分割点。dp[l][k]dp[k+1][r] 的区间长度都严格小于 len,按外层循环的顺序,这两个值必然已经算好。
⑤ 复杂度分析

状态数是 O(n²)lrn 种取值),每个状态转移要枚举分割点 kO(n)。整体时间复杂度 O(n³)——比线性 DP 慢一个数量级,n 通常不会太大(几百到一千出头比较常见)。

⑥ 常见陷阱
填表顺序写反,直接按 l、r 从小到大双重循环:这是区间 DP 最容易犯的错误——如果直接写 for(l=1;l<=n;l++) for(r=l;r<=n;r++),在算 dp[1][4] 时可能 dp[2][4] 还没被算过(因为 r 的内层循环还没跑到需要的组合),导致用到了未初始化的脏数据。正确做法是按区间长度 len 从小到大作为最外层循环,确保用到的子区间一定是"更短的、已经算好的"。
忘记预处理前缀和,每次现算 cost(l,r):如果 cost(l,r) 本身需要 O(n) 现场累加(而不是像本节这样用前缀和 O(1) 查询),整体复杂度会从 O(n³) 恶化成 O(n⁴),在数据规模稍大时就会超时。
dp 初始值设置不当:第 15 行 dp[l][r] = 1e9 是求最小值时的"正无穷"占位,如果题目求的是最大值,要改成一个足够小的负数;如果忘记初始化、或者初始化成 0minmax 比较时会被这个错误的初始值污染,导致结果不正确。
🏆
接下来:18.2 节的状压 DP会引入完全不同的状态表示方式——用一个整数的二进制位,表示"一组物品分别选没选"这样的组合状态,适合物品数量不多(通常在 20 个以内)、但组合关系复杂的问题。