前言
上一篇 LIS、LCS 与编辑距离 里,最长回文子序列已经用到了一种特殊的状态—— 表示”区间 上的最优解”。这种按区间定义状态、从小区间推导大区间的 DP,就是区间 DP。
区间 DP 的应用场景很广。比如:把一串数字按相邻对删掉,最小代价是多少?给一串括号,最少补几个才能匹配?把若干矩阵相乘,怎样加括号使得乘法次数最少?这些问题都共享同一个结构——在某处切一刀,左半边和右半边各自最优,再合并。
问题的本质
为什么普通 DP 不够用?
之前遇到的 DP,状态都是”前 个”或”容量 “——状态沿着一个方向增长。但有些问题的状态天然是一个区间。比如回文子序列, 和 能不能匹配取决于两端字符,不是”前缀”能描述的。
区间 DP 的核心思想:先解决所有小区间的最优解,再逐步扩大区间长度。计算顺序是按区间长度从小到大,不是按位置从左到右。
通用框架
= 区间 上的最优值。枚举分割点 :
// 初始化:长度为 1 的区间
for (int i = 0; i < N; i++) dp[i][i] = 初始值;
// 按区间长度从小到大枚举
for (int len = 2; len <= N; len++) // ① 先算短区间
for (int l = 0; l + len - 1 < N; l++) { // ② 枚举左端点
int r = l + len - 1; // ③ 右端点
for (int k = l; k < r; k++) // ④ 枚举分割点
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + cost);
}
逐行解析:
- ① 区间长度必须从小到大。因为计算长度为 的区间时,需要用到所有长度 的区间的结果。
- ②③ 给定长度 和左端点 ,右端点 就确定了。
- ④ 在 内枚举分割点 ,把区间分成 和 两半。两半各自已经算好了,加上合并代价就是总代价。
时间复杂度 ( 个左端点 个右端点 个分割点)。 以内可以接受。
理论 + 代码
模型一:相邻配对删除
给定 个数排成一排。每次删相邻两个数,代价是 。删完所有数的最小总代价?
这不是简单的贪心——每次删最小代价的相邻对可能导致后续配对变差。
状态: = 把区间 中的数全部删掉的最小代价。
转移:枚举 和谁配对。如果 和 配对(),代价是 ,然后 和 各自删完:
注意 的奇偶性: 和 之间有奇数个元素时才能全部配对,所以 必须是奇数。
模型二:回文子序列(区间 DP 版)
上一篇已经讲过。 = 子串 的最长回文子序列长度。
- :
- :
模型三:矩阵链乘法
个矩阵 , 大小 ,且 。求最少乘法次数。
状态: = 矩阵 到 相乘的最少乘法次数。
转移:最后一步一定是某次乘法把 和 合并:
其中 是最后那次矩阵乘法的代价。
例题
例题 1:T90 019 — Pick Two(★6,相邻配对删除)
题目:长度 的数列 。每次选相邻两个数删掉,代价 。重复 次删完所有数。求最小总代价。
数据范围:,
分析:, 的区间 DP 绰绰有余。
状态 = 把 全部删完的最小代价。 必须是偶数(每次删 2 个)。
转移:枚举 和谁配对。配对后,中间和右边各自独立删除。
输入样例:
3
6 2 3 9 8 6
输出:2
代码:
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int INF = 1e9;
int A[36], dp[36][36];
int main() {
int N;
scanf("%d", &N);
for (int i = 0; i < 2 * N; i++) scanf("%d", &A[i]);
for (int i = 0; i < 2 * N; i++)
for (int j = 0; j < 2 * N; j++) dp[i][j] = INF;
for (int i = 0; i < 2 * N; i++) dp[i][i] = 0; // ① 单个元素无法删除
// ② 按区间长度枚举(只考虑偶数长度)
for (int len = 2; len <= 2 * N; len += 2)
for (int l = 0; l + len - 1 < 2 * N; l++) {
int r = l + len - 1;
// ③ 枚举 l 和谁配对
for (int k = l + 1; k <= r; k += 2) {
int cost = abs(A[l] - A[k]);
int mid = (l + 1 <= k - 1) ? dp[l+1][k-1] : 0;
int right = (k + 1 <= r) ? dp[k+1][r] : 0;
dp[l][r] = min(dp[l][r], cost + mid + right);
}
}
printf("%d\n", dp[0][2*N-1]);
}
逐行解析:
- ① 单个元素不可能被删除(每次删 2 个),但设为 0 作为递推基础。
- ② 区间长度只枚举偶数——奇数长度的区间不可能被全部删完。
- ③ 和 配对, 为奇数。
abs(A[l]-A[k])是配对代价,dp[l+1][k-1]是中间部分删完的代价,dp[k+1][r]是右边删完的代价。
模拟:()。
长度 2 的区间:, , , , 。
长度 4 的区间:
- : 配+ → ;或 配+ → 。取 4。
- : 配+ → ;或 配+ → 。取 2。
- : 配+ → ;或 配+ → 。取 4。
长度 6 的区间 :
- 配+ →
- 配+ →
- 配+ →
答案 = 2。✓
例题 2:TB B21 — Longest Subpalindrome(最长回文子序列)
题目:长度 的字符串 ,求最长回文子序列的长度。
数据范围:
分析:教材 4.6 节的区间 DP。状态 = 子串 中最长回文子序列长度。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int dp[1009][1009];
int main() {
int N;
char S[1009];
scanf("%d%s", &N, S);
// ① 长度 1:单字符回文
for (int i = 0; i < N; i++) dp[i][i] = 1;
// ② 长度 2:相邻两字符
for (int i = 0; i < N - 1; i++)
dp[i][i+1] = (S[i] == S[i+1]) ? 2 : 1;
// ③ 按区间长度递增
for (int len = 2; len < N; len++)
for (int l = 0; l + len < N; l++) {
int r = l + len;
if (S[l] == S[r])
dp[l][r] = dp[l+1][r-1] + 2; // ④ 两端匹配
else
dp[l][r] = max(dp[l+1][r], dp[l][r-1]); // ⑤ 去掉一端
}
printf("%d\n", dp[0][N-1]);
}
逐行解析:
- ①② 边界:单字符回文长 1,双字符看是否相同。
- ③ 区间 DP 的核心——按长度递增计算。
- ④ 时两端同时选入回文,中间部分的最优解来自 。
- ⑤ 两端不同,至少有一端不选,取去掉左端或右端的较大值。
模拟: = “character”()。
| len | 关键更新 |
|---|---|
| 1 | dp[i][i] = 1 |
| 2 | dp[2][3]=‘ra’不匹配→1, dp[4][5]=‘cc’匹配→2 |
| 3 | dp[0][2]=‘cha’→max(dp[1][2],dp[0][1])=1, dp[4][6]=‘cac’→dp[5][5]+2=3 |
| … | … |
| 8 | dp[0][8] → 最长回文子序列 “caac” 或 “caaac” 等长度 |
最终 dp[0][8] = 5(如 “caaac”)。具体回文内容取决于字符串,但算法正确性不依赖具体还原。
例题 3:括号匹配——最少补几个括号
场景:给定长度 的括号序列 (只含
(和)),求最少需要插入多少个括号才能使序列合法。数据范围:
分析:等价于求最长合法括号子序列。合法括号序列要求:(A)B 形式,其中 和 也是合法的。
状态: = 子串 中最长合法括号子序列的长度。
转移:
- 如果 和 匹配(
(和)), - 枚举分割点:
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int dp[509][509];
int main() {
int N;
char S[509];
scanf("%d%s", &N, S);
// ① 按区间长度递增
for (int len = 2; len <= N; len++)
for (int l = 0; l + len - 1 < N; l++) {
int r = l + len - 1;
// ② 如果两端匹配
if (S[l] == '(' && S[r] == ')')
dp[l][r] = dp[l+1][r-1] + 2;
// ③ 枚举分割点
for (int k = l; k < r; k++)
dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r]);
}
// ④ 需要补充的括号数 = 总长度 - 最长合法子序列长度
printf("%d\n", N - dp[0][N-1]);
}
逐行解析:
- ② 两端是
(和)时可以配对,长度 +2。 - ③ 枚举分割点,取最优的切法。
- ④ 最长合法子序列不需要补充,其余每个字符需要补一个对应的括号。
例题 4:矩阵链乘法
场景: 个矩阵 , 的大小为 。求最少乘法次数。
数据范围:
分析:经典的区间 DP。 = 矩阵 连乘的最少乘法次数。
转移:。
最后合并时,左边结果是 的矩阵,右边是 的矩阵,乘法次数 。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
const long long INF = 1e18;
long long dp[509][509];
int R[509];
int main() {
int N;
scanf("%d", &N);
for (int i = 0; i <= N; i++) scanf("%d", &R[i]); // R[0]..R[N]
for (int i = 1; i <= N; i++) dp[i][i] = 0; // ① 单个矩阵不用乘
for (int len = 2; len <= N; len++)
for (int l = 1; l + len - 1 <= N; l++) {
int r = l + len - 1;
dp[l][r] = INF;
for (int k = l; k < r; k++)
dp[l][r] = min(dp[l][r],
dp[l][k] + dp[k+1][r] + 1LL * R[l-1] * R[k] * R[r]); // ② 合并代价
}
printf("%lld\n", dp[1][N]);
}
逐行解析:
- ① 单个矩阵不需要乘法,代价 0。
- ② 的结果是 , 的结果是 ,两者相乘需要 次乘法。
模拟:,矩阵大小 ,,。。
。 。 :
- :
- :
答案 = 7500。先算前两个再算最后一个更优。✓
参考文献
教材讲解 — 競技プログラミングの鉄則 第 4 章
系统练习 — 競技プログラミングの鉄則
实战练习 — 競プロ典型 90 問
- 019 Pick Two(★6,相邻配对删除 区间 DP)【例题】
- 088 Similar but Different Ways(★6,子集和重复判定)
- 089 Partitions and Inversions(★7,区间分割计数 逆序数)
- 090 Tenkei90’s Last Problem(★7,区间 min×len 条件计数)
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶