区间动态规划是动态规划中一类非常经典且重要的问题类型。它主要处理在一段区间上进行操作或决策,最终求解最优解的问题。这类问题的特点是:状态定义与区间有关,通常以区间端点作为状态变量,通过合并子区间信息来求解整个区间。
一、什么是区间DP?
1.1 核心思想
区间DP的核心思想是:
将一个大区间的问题分解为若干个小区间的问题,然后利用小区间的解来推导出大区间的解。
这与分治法有相似之处,但关键区别在于:区间DP会记录所有子问题的解(即状态),避免重复计算,从而实现动态规划的“记忆化”或“填表”过程。
1.2 适用场景
- 给定一个序列(数组、字符串等),需要在某个区间
[i, j]上进行操作。 - 操作通常是合并、删除、分割、配对等。
- 目标是求某种“最优值”(最小代价、最大得分、最少步数等)。
- 满足最优子结构和重叠子问题性质。
二、区间DP的基本结构
2.1 状态定义
最常见的状态定义是:
dp[i][j] = 在区间 [i, j] 上操作所能得到的最优值
其中 i 是左端点,j 是右端点,i <= j。
2.2 状态转移方程
通常,我们会枚举一个分割点 k(i <= k < j),将区间 [i, j] 分成 [i, k] 和 [k+1, j] 两部分:
dp[i][j] = min/max(dp[i][k] + dp[k+1][j] + cost(i, j, k))
这里的 cost(i, j, k) 是将两个子区间合并时产生的额外代价或收益。
注意:有些问题的
cost只与i和j有关,与k无关,比如石子合并中的sum(i, j)。
2.3 初始化
- 单个元素的区间:
dp[i][i] = 初始值(通常是0或该元素的值) - 对于
i > j的情况,dp[i][j]无意义,可设为0或INF。
2.4 遍历顺序
由于 dp[i][j] 依赖于更短的区间 [i, k] 和 [k+1, j],我们必须按区间长度从小到大进行遍历。
即:
for (int len = 2; len <= n; len++) { // 区间长度从2开始(1已初始化)
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
for (int k = i; k < j; k++) {
dp[i][j] = min/max(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
}
}
}
三、经典例题详解
3.1 石子合并(最小代价版)
题目描述:
有n堆石子排成一排,每次可以将相邻的两堆合并成一堆,合并的代价是这两堆石子的总数。求将所有石子合并成一堆的最小总代价。
3.1.1 分析
- 状态定义:
dp[i][j]表示将第i到第j堆石子合并成一堆的最小代价。 - 转移方程:枚举最后一步合并的位置
k,即先合并[i,k]和[k+1,j],然后合并这两堆:
其中dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j])sum[i][j]是从第i堆到第j堆的石子总数。 - 初始化:
dp[i][i] = 0(单堆不需要合并) - 目标:
dp[1][n]
3.1.2 前缀和优化
为了快速计算 sum[i][j],我们可以预处理前缀和数组 prefix:
prefix[0] = 0;
for (int i = 1; i <= n; i++) {
prefix[i] = prefix[i-1] + a[i];
}
sum[i][j] = prefix[j] - prefix[i-1];
3.1.3 C++ 实现
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
const int MAXN = 300;
const int INF = 0x3f3f3f3f;
int main() {
int n;
cin >> n;
vector<int> a(n + 1);
vector<int> prefix(n + 1, 0);
for (int i = 1; i <= n; i++) {
cin >> a[i];
prefix[i] = prefix[i-1] + a[i];
}
// dp[i][j] 表示合并第i到第j堆的最小代价
vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
// 初始化:单个石子堆代价为0
for (int i = 1; i <= n; i++) {
dp[i][i] = 0;
}
// 枚举区间长度
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
dp[i][j] = INF; // 初始化为无穷大
// 枚举分割点k
for (int k = i; k < j; k++) {
int cost = prefix[j] - prefix[i - 1]; // sum[i][j]
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
}
}
}
cout << "最小合并代价: " << dp[1][n] << endl;
return 0;
}
3.1.4 复杂度分析
- 时间复杂度:O(n³)
- 空间复杂度:O(n²)
3.2 回文串分割(最小分割次数)
题目描述:
给定一个字符串s,将其分割成若干子串,使得每个子串都是回文串。求最小分割次数。
3.2.1 分析
这个问题可以转化为区间DP:
- 预处理:先用DP判断所有子串是否为回文。
isPal[i][j] = true表示s[i..j]是回文。
- 主DP:
dp[i]表示前i个字符的最小分割次数。- 转移:枚举最后一个回文串的起始位置
j,如果s[j..i]是回文,则:dp[i] = min(dp[i], dp[j-1] + 1) - 特殊:如果
s[1..i]本身就是回文,则dp[i] = 0。
注意:这其实是一个“线性DP + 区间判断”的组合,但回文判断部分是典型的区间DP。
3.2.2 C++ 实现
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
int minCut(string s) {
int n = s.size();
if (n == 0) return 0;
// 步骤1:预处理回文数组 isPal[i][j]
vector<vector<bool>> isPal(n, vector<bool>(n, false));
// 长度为1
for (int i = 0; i < n; i++) {
isPal[i][i] = true;
}
// 长度为2
for (int i = 0; i < n - 1; i++) {
if (s[i] == s[i+1]) {
isPal[i][i+1] = true;
}
}
// 长度 >= 3
for (int len = 3; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
if (s[i] == s[j] && isPal[i+1][j-1]) {
isPal[i][j] = true;
}
}
}
// 步骤2:主DP,dp[i] 表示前i个字符的最小分割次数
vector<int> dp(n, 0);
for (int i = 0; i < n; i++) {
if (isPal[0][i]) {
dp[i] = 0; // 整个前缀是回文,无需分割
} else {
dp[i] = i; // 最坏情况:每个字符都分割
for (int j = 1; j <= i; j++) {
if (isPal[j][i]) {
dp[i] = min(dp[i], dp[j-1] + 1);
}
}
}
}
return dp[n-1];
}
int main() {
string s;
cin >> s;
cout << "最小分割次数: " << minCut(s) << endl;
return 0;
}
3.3 多边形三角剖分(最大得分)
题目描述:
一个n边凸多边形,顶点按顺时针编号为0到n-1,每个顶点有一个值。将其三角剖分,每个三角形的得分为三个顶点值的乘积。求最大总得分。
3.3.1 分析
- 状态定义:
dp[i][j]表示从顶点i到j构成的多边形部分的最大得分。 - 转移:枚举一个中间点
k(i < k < j),形成三角形(i,k,j),然后递归处理[i,k]和[k,j]。dp[i][j] = max(dp[i][k] + dp[k][j] + val[i]*val[k]*val[j]) - 边界:当
j - i < 2时,无法构成三角形,dp[i][j] = 0。
3.3.2 C++ 实现(简化版)
#include <vector>
#include <algorithm>
using namespace std;
int maxScore(vector<int>& values) {
int n = values.size();
if (n < 3) return 0;
vector<vector<int>> dp(n, vector<int>(n, 0));
// len: 从3到n(三角形至少3个点)
for (int len = 3; len <= n; len++) {
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
for (int k = i + 1; k < j; k++) {
int score = dp[i][k] + dp[k][j] + values[i] * values[k] * values[j];
dp[i][j] = max(dp[i][j], score);
}
}
}
return dp[0][n-1];
}
四、区间DP的常见优化
4.1 四边形不等式优化
对于形如:
dp[i][j] = min_{i<k<j} { dp[i][k] + dp[k][j] } + w(i,j)
如果权重函数 w(i,j) 满足四边形不等式且单调性,则可以使用决策单调性优化,将时间复杂度从 O(n³) 降到 O(n²)。
但该优化较复杂,竞赛中较少要求手写,此处略过。
4.2 前缀和/后缀和优化
如石子合并中,用前缀和快速计算区间和。
4.3 预处理辅助数组
如回文串问题中,先用区间DP预处理 isPal[i][j]。
五、区间DP的模板总结
// 通用模板框架
for (int len = 2; len <= n; len++) { // 区间长度
for (int i = 1; i <= n - len + 1; i++) { // 左端点
int j = i + len - 1; // 右端点
dp[i][j] = 初始化值(如INF或0);
for (int k = i; k < j; k++) { // 分割点
dp[i][j] = merge(dp[i][k], dp[k+1][j], cost);
}
}
}
六、注意事项
- 初始化要正确:特别是
dp[i][i]和dp[i][i+1]。 - 遍历顺序必须正确:按长度从小到大。
- 边界条件处理:如
i > j或长度不足。 - 空间优化:有时可用滚动数组,但因依赖多个子区间,通常难以优化。
- 调试技巧:打印
dp数组中间状态,观察小数据的填表过程。
七、练习题推荐
- 石子合并(经典)
- 能量项链(NOIP 提高组)
- 加分二叉树(NOIP)
- 括号匹配(最长合法子串)
- 乘积最大(数字串分割)
- 多边形游戏(博弈+区间DP)
八、总结
区间DP是一种基于“分治+记忆化”的动态规划方法,关键在于:
- 正确定义状态
dp[i][j] - 找到合理的分割点
k - 设计正确的转移方程
- 控制遍历顺序(按长度)
掌握区间DP,不仅能解决经典问题,还能为更复杂的树形DP、环形DP打下基础。
记住口诀:
“区间DP按长枚,左右端点随之走,分割枚举k在中,子区合并得最优。”
4600

被折叠的 条评论
为什么被折叠?



