
目录
[一、区间 DP 的核心思想与解题框架](#一、区间 DP 的核心思想与解题框架)
[1.1 什么是区间 DP?](#1.1 什么是区间 DP?)
[1.2 区间 DP 的解题四步曲](#1.2 区间 DP 的解题四步曲)
[步骤 1:定义状态dp[i][j]](#步骤 1:定义状态dp[i][j])
[步骤 2:推导状态转移方程](#步骤 2:推导状态转移方程)
[步骤 3:初始化 DP 表](#步骤 3:初始化 DP 表)
[步骤 4:确定填表顺序](#步骤 4:确定填表顺序)
[1.3 区间 DP 的时间复杂度分析](#1.3 区间 DP 的时间复杂度分析)
[2.1 例题 1:回文字串(洛谷 P1435)------ 基于端点的区间 DP](#2.1 例题 1:回文字串(洛谷 P1435)—— 基于端点的区间 DP)
[步骤 1:状态定义](#步骤 1:状态定义)
[步骤 2:状态转移方程](#步骤 2:状态转移方程)
[步骤 3:初始化](#步骤 3:初始化)
[步骤 4:填表顺序](#步骤 4:填表顺序)
[C++ 代码实现](#C++ 代码实现)
[2.2 例题 2:Treats for the Cows(洛谷 P2858)------ 两端选择的区间 DP](#2.2 例题 2:Treats for the Cows(洛谷 P2858)—— 两端选择的区间 DP)
[步骤 1:状态定义](#步骤 1:状态定义)
[步骤 2:状态转移方程](#步骤 2:状态转移方程)
[步骤 3:初始化](#步骤 3:初始化)
[步骤 4:填表顺序](#步骤 4:填表顺序)
[C++ 代码实现](#C++ 代码实现)
[2.3 例题 3:石子合并(弱化版,洛谷 P1775)------ 基于分割点的区间 DP](#2.3 例题 3:石子合并(弱化版,洛谷 P1775)—— 基于分割点的区间 DP)
[步骤 1:状态定义](#步骤 1:状态定义)
[步骤 2:状态转移方程](#步骤 2:状态转移方程)
[步骤 3:初始化](#步骤 3:初始化)
[步骤 4:填表顺序](#步骤 4:填表顺序)
[C++ 代码实现](#C++ 代码实现)
[2.4 例题 4:248(洛谷 P3146)------ 合并相等元素的区间 DP](#2.4 例题 4:248(洛谷 P3146)—— 合并相等元素的区间 DP)
[步骤 1:状态定义](#步骤 1:状态定义)
[步骤 2:状态转移方程](#步骤 2:状态转移方程)
[步骤 3:初始化](#步骤 3:初始化)
[步骤 4:填表顺序](#步骤 4:填表顺序)
[C++ 代码实现](#C++ 代码实现)
[三、区间 DP 的常见变形与优化技巧](#三、区间 DP 的常见变形与优化技巧)
[3.1 常见变形](#3.1 常见变形)
[1. 环形区间 DP](#1. 环形区间 DP)
[2. 多维约束的区间 DP](#2. 多维约束的区间 DP)
[3. 区间 DP 求方案数](#3. 区间 DP 求方案数)
[3.2 优化技巧](#3.2 优化技巧)
[1. 前缀和 / 后缀和优化](#1. 前缀和 / 后缀和优化)
[2. 状态压缩](#2. 状态压缩)
[3. 四边形不等式优化](#3. 四边形不等式优化)
前言
在动态规划的大家族中,区间 DP 绝对是 "看似复杂,实则有章可循" 的典范。它不像线性 DP 那样按部就班地从左到右递推,也不像背包问题那样围绕 "选或不选" 做文章,而是以 "区间" 为核心,通过拆分大区间、求解小区间,最终拼凑出全局最优解。
如果你曾被 "回文串最少插入次数"、"石子合并最小代价" 这类问题难住,如果你想掌握一种能解决所有区间相关最优解问题的通用思路,那么区间 DP 就是你必须攻克的技能。它的核心思想极其优雅 ------大区间的解依赖于小区间的解,就像搭积木一样,先搭好小块,再用小块拼成大块。
本文将从区间 DP 的基本原理出发,结合 4 个经典例题(回文字串、Treats for the Cows、石子合并、248),手把手教你从状态定义到代码实现的完整流程。下面就让我们正式开始吧!
一、区间 DP 的核心思想与解题框架
1.1 什么是区间 DP?
**区间 DP(Interval Dynamic Programming)**是动态规划的一种特殊形式,其状态通常以区间的左右端点来定义,即dp[i][j]表示区间[i, j]上的最优解(最大 / 最小值、方案数等)。
它的核心逻辑源于分治思想:对于一个长度为len = j - i + 1的大区间[i, j],我们可以通过枚举分割点k(i ≤ k < j),将其拆分为两个小区间[i, k]和[k+1, j],然后利用这两个小区间的最优解,结合当前区间的约束条件,推导出大区间[i, j]的最优解。
形象地说,区间 DP 就像切蛋糕:要吃完一块大蛋糕(大区间),我们可以先把它切成两块小蛋糕(小区间),吃完小蛋糕后,再把结果整合起来(状态转移)。
1.2 区间 DP 的解题四步曲
无论什么区间 DP 问题,都可以遵循以下四个核心步骤,堪称 "万能框架":
步骤 1:定义状态dp[i][j]
这是区间 DP 的灵魂,必须明确**dp[i][j]**表示的具体含义。常见的定义有:
- dp[i][j]:区间
[i, j]变成回文串的最小插入次数(回文字串问题)- dp[i][j]:合并区间
[i, j]的石子所需的最小代价(石子合并问题)- dp[i][j]:取完区间
[i, j]的零食能获得的最大收益(Treats for the Cows 问题)
关键原则:状态定义必须能覆盖 "区间" 的核心属性,并且让小区间的解能支撑大区间的推导。
步骤 2:推导状态转移方程
这是区间 DP 的核心难点,需要根据问题的具体约束,分析大区间如何从小区间推导而来。常见的推导方式有两种:
- 基于区间端点 :根据区间左右端点
i和j的关系直接推导(如回文字串问题中s[i]与s[j]是否相等)- 基于分割点 :枚举分割点
k,将区间[i, j]拆分为[i, k]和[k+1, j],再整合两者的结果(如石子合并问题)
关键原则:转移方程必须保证 "无后效性",即小区间的解一旦确定,就不会被后续操作修改。
步骤 3:初始化 DP 表
由于区间 DP 是从小区间向大区间推导,需要先初始化长度为 1 的区间(i == j),因为长度为 1 的区间是最小的单位,其解通常是已知的:
- 回文字串问题:dp[i][i] = 0(单个字符本身就是回文串,无需插入字符)
- 石子合并问题:dp[i][i] = 0(单堆石子无需合并,代价为 0)
- 零食问题:dp[i][i] = a[i] * n(最后一天取这包零食,乘数为总天数
n)
步骤 4:确定填表顺序
区间 DP 的填表顺序非常关键,必须保证在计算dp[i][j]时,所有依赖的小区间都已经计算完成。最常用的填表顺序是:
- 按区间长度
len从小到大枚举(len从 2 到n)- 对于每个长度
len,枚举所有可能的左端点i- 计算右端点j = i + len - 1(确保区间长度为
len)- 填充**dp[i][j]**的值
这种顺序能确保:当计算长度为len的区间时,所有长度小于len的小区间都已经计算完毕,完全符合 "小区间支撑大区间" 的逻辑。
1.3 区间 DP 的时间复杂度分析
假设问题的序列长度为n,则:
- 枚举区间长度:
O(n)(len从 1 到n)- 枚举左端点:
O(n)(i从 1 到n - len + 1)- 枚举分割点(如需):
O(n)(k从i到j-1)
因此,区间 DP 的时间复杂度通常为O(n³)。对于n ≤ 300的问题(如石子合并),300³ = 27,000,000,完全在时间限制内;对于n ≤ 1000的问题(如回文字串),1000³ = 1e9,需要优化(但大部分题目会通过约束让n控制在 300 以内)。
二、经典例题实战:从理论到代码
2.1 例题 1:回文字串(洛谷 P1435)------ 基于端点的区间 DP
题目链接:https://www.luogu.com.cn/problem/P1435

题目描述
任意给定一个字符串,通过插入若干字符,将其变成回文词,求最少需要插入的字符数。注意区分大小写,例如Ab3bd需要插入 2 个字符变成回文串。
解题分析
步骤 1:状态定义
dp[i][j]:将字符串区间[i, j]变成回文串所需的最小插入次数。
步骤 2:状态转移方程
- 若s[i] == s[j]:此时
[i, j]的回文性由[i+1, j-1]决定,插入次数与[i+1, j-1]相同,即dp[i][j] = dp[i+1][j-1]- 若s[i] != s[j]:有两种选择:
- 在
i左侧插入s[j],此时需要先将[i, j-1]变成回文串,再插入 1 个字符,代价为dp[i][j-1] + 1- 在
j右侧插入s[i],此时需要先将[i+1, j]变成回文串,再插入 1 个字符,代价为dp[i+1][j] + 1取两种选择的最小值,即dp[i][j] = min(dp[i][j-1], dp[i+1][j]) + 1
步骤 3:初始化
dp[i][i] = 0(单个字符无需插入);对于i > j的非法区间,dp[i][j] = 0(空区间也是回文串)。
dp表如下所示:

步骤 4:填表顺序
按区间长度len从小到大枚举,len从 2 到n,再枚举左端点i,计算右端点j = i + len - 1。
C++ 代码实现
cpp
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
const int N = 1010;
int dp[N][N]; // dp[i][j]表示区间[i,j]变成回文串的最小插入次数
int main() {
string s;
cin >> s;
int n = s.size();
s = " " + s; // 字符串从1开始索引,方便区间表示
// 初始化:长度为1的区间无需插入
for (int i = 1; i <= n; ++i) {
dp[i][i] = 0;
}
// 按区间长度从小到大枚举
for (int len = 2; len <= n; ++len) {
// 枚举左端点i
for (int i = 1; i + len - 1 <= n; ++i) {
int j = i + len - 1; // 计算右端点j
if (s[i] == s[j]) {
dp[i][j] = dp[i+1][j-1];
} else {
dp[i][j] = min(dp[i][j-1], dp[i+1][j]) + 1;
}
}
}
cout << dp[1][n] << endl;
return 0;
}
代码解释
- 字符串预处理:将原字符串前面加一个空格,让索引从 1 开始,这样区间
[i, j]的表示更直观,避免出现i=0的边界问题。- 填表顺序:严格按照 "长度从小到大" 的顺序,确保计算
dp[i][j]时,dp[i+1][j-1]、dp[i][j-1]、dp[i+1][j]都已计算完成。- 时间复杂度:
O(n²)(无需枚举分割点,仅枚举长度和端点),对于n=1000的字符串,1e6次运算完全可行。
2.2 例题 2:Treats for the Cows(洛谷 P2858)------ 两端选择的区间 DP
题目链接:https://www.luogu.com.cn/problem/P2858

题目描述
有N份零食排成一列,每天可以从两端取一份零食出售。第i份零食的初始价值为V_i,如果在第a天出售,售价为V_i × a。求所有零食售出后的最大收益。
解题分析
步骤 1:状态定义
dp[i][j]:取完区间[i, j]的所有零食能获得的最大收益。
步骤 2:状态转移方程
区间[i, j]的长度为len = j - i + 1,已经取了len份零食,剩余n - len份零食未取,因此当前是第(n - len + 1)天(因为总天数为n)。
取零食有两种选择:
- 取左端点
i:收益为V[i] × (n - len + 1) + dp[i+1][j](取当前零食的收益 + 取剩余区间[i+1, j]的最大收益)- 取右端点
j:收益为V[j] × (n - len + 1) + dp[i][j-1](取当前零食的收益 + 取剩余区间[i, j-1]的最大收益)
取两种选择的最大值,即:dp[i][j] = max(V[i] × cnt + dp[i+1][j], V[j] × cnt + dp[i][j-1]),其中cnt = n - len + 1。
步骤 3:初始化
dp[i][i] = V[i] × n(长度为 1 的区间,最后一天取,乘数为n)。
步骤 4:填表顺序
按区间长度len从小到大枚举,len从 2 到n,确保计算dp[i][j]时,dp[i+1][j]和dp[i][j-1]已计算完成。
C++ 代码实现
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 2010;
int dp[N][N]; // dp[i][j]表示取完区间[i,j]零食的最大收益
int V[N]; // 零食的初始价值
int n; // 零食总数
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> V[i];
dp[i][i] = V[i] * n; // 初始化:最后一天取这包零食
}
// 按区间长度从小到大枚举
for (int len = 2; len <= n; ++len) {
int cnt = n - len + 1; // 当前是第几天(取第len包零食)
for (int i = 1; i + len - 1 <= n; ++i) {
int j = i + len - 1; // 右端点
// 取左端点或右端点,取最大值
dp[i][j] = max(V[i] * cnt + dp[i+1][j], V[j] * cnt + dp[i][j-1]);
}
}
cout << dp[1][n] << endl;
return 0;
}
代码解释
- 天数计算:cnt = n - len + 1是关键推导,例如
len=1时,cnt=n(最后一天);len=2时,cnt=n-1(倒数第二天),符合 "每天取一份" 的逻辑。- 状态转移:无需枚举分割点,只需考虑两端的选择,时间复杂度为
O(n²),对于n=2000,4e6次运算完全可控。- 贪心陷阱:本题不能用贪心(每次取两端较小的),例如序列
4,1,5,3,贪心会得到 34,而区间 DP 能得到 35,因为贪心会 "鼠目寸光",忽略后续更大的收益。
2.3 例题 3:石子合并(弱化版,洛谷 P1775)------ 基于分割点的区间 DP
题目链接:https://www.luogu.com.cn/problem/P1775

题目描述
N堆石子排成一排,每次只能合并相邻的两堆,合并代价为两堆石子质量之和。求将所有石子合并成一堆的最小代价。
解题分析
步骤 1:状态定义
dp[i][j]:合并区间[i, j]的所有石子所需的最小代价。
步骤 2:状态转移方程
合并区间[i, j]的最后一步,必然是将两个相邻的子区间[i, k]和[k+1, j]合并(i ≤ k < j)。此时的代价为:
- 合并
[i, k]的代价:dp[i][k]- 合并
[k+1, j]的代价:dp[k+1][j]- 合并这两个子区间的代价:区间
[i, j]的总质量(因为合并两堆的代价是它们的质量和)
因此,状态转移方程为:dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum[i][j])
其中**sum[i][j]是区间[i, j]的石子总质量,为了快速计算,我们可以用前缀和数组pre_sum**预处理:sum[i][j] = pre_sum[j] - pre_sum[i-1]。
步骤 3:初始化
dp[i][i] = 0(单堆石子无需合并);其他位置初始化为无穷大(0x3f3f3f3f),表示初始状态下无法合并。
步骤 4:填表顺序
按区间长度len从小到大枚举,len从 2 到n,枚举左端点i,计算右端点j,再枚举分割点k(i ≤ k < j),更新dp[i][j]。
C++ 代码实现
cpp
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 310;
const int INF = 0x3f3f3f3f;
int dp[N][N]; // dp[i][j]表示合并[i,j]石子的最小代价
int pre_sum[N]; // 前缀和数组,pre_sum[i]表示前i堆石子的总质量
int n; // 石子堆数
int main() {
cin >> n;
memset(dp, INF, sizeof(dp)); // 初始化所有状态为无穷大
// 输入石子质量并计算前缀和
for (int i = 1; i <= n; ++i) {
int x;
cin >> x;
pre_sum[i] = pre_sum[i-1] + x;
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; // 右端点
int sum = pre_sum[j] - pre_sum[i-1]; // [i,j]的总质量
// 枚举分割点k
for (int k = i; k < j; ++k) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum);
}
}
}
cout << dp[1][n] << endl;
return 0;
}
代码解释
- 前缀和优化:
sum[i][j]用前缀和计算,时间复杂度从O(n)降到O(1),避免重复计算区间和。- 初始化:
dp数组初始化为无穷大,确保未计算的状态不会影响最终结果,只有dp[i][i]设为 0(合法状态)。- 时间复杂度:
O(n³),对于n=300,300³=27e6次运算,完全在时间限制内。- 拓展:如果题目是环形石子合并(洛谷 P1880),可以用 "倍增数组" 技巧,将数组长度翻倍(
s[i+n] = s[i]),然后在2n长度的数组上做区间 DP,最后取dp[i][i+n-1](i=1~n)的最小值和最大值。
2.4 例题 4:248(洛谷 P3146)------ 合并相等元素的区间 DP
题目链接:https://www.luogu.com.cn/problem/P3146

题目描述
给定一个包含N个正整数的序列,每次可以选择两个相邻且相等的数,将它们替换为一个比原数大 1 的数(例如两个 7 合并为 8)。求最终能生成的最大整数。
解题分析
步骤 1:状态定义
dp[i][j]:将区间[i, j]合并成一个元素后,能得到的最大数值;若无法合并成一个元素,dp[i][j] = 0。
步骤 2:状态转移方程
要将[i, j]合并成一个元素,必须存在分割点k(i ≤ k < j),使得:
[i, k]能合并成一个元素(dp[i][k] != 0)[k+1, j]能合并成一个元素(dp[k+1][j] != 0)- 两个元素相等(
dp[i][k] == dp[k+1][j])
此时,[i, j]能合并成dp[i][k] + 1,因此状态转移方程为:dp[i][j] = max(dp[i][j], dp[i][k] + 1)(满足上述三个条件时)
步骤 3:初始化
dp[i][i] = a[i](长度为 1 的区间,合并后就是自身)。
步骤 4:填表顺序
按区间长度len从小到大枚举,len从 2 到n,确保计算dp[i][j]时,所有dp[i][k]和dp[k+1][j]都已计算完成。
C++ 代码实现
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 255;
int dp[N][N]; // dp[i][j]表示区间[i,j]合并成一个元素的最大数值(0表示无法合并)
int a[N]; // 原始序列
int n; // 序列长度
int max_val; // 记录最终的最大数值
int main() {
cin >> n;
memset(dp, 0, sizeof(dp));
max_val = 0;
// 初始化:长度为1的区间
for (int i = 1; i <= n; ++i) {
cin >> a[i];
dp[i][i] = a[i];
max_val = max(max_val, a[i]); // 初始最大数值为单个元素的最大值
}
// 按区间长度从小到大枚举
for (int len = 2; len <= n; ++len) {
for (int i = 1; i + len - 1 <= n; ++i) {
int j = i + len - 1; // 右端点
// 枚举分割点k
for (int k = i; k < j; ++k) {
// 只有两个子区间都能合并成一个元素,且数值相等时,才能合并
if (dp[i][k] != 0 && dp[k+1][j] != 0 && dp[i][k] == dp[k+1][j]) {
dp[i][j] = max(dp[i][j], dp[i][k] + 1);
max_val = max(max_val, dp[i][j]); // 更新最大数值
}
}
}
}
cout << max_val << endl;
return 0;
}
代码解释
- 状态含义:dp[i][j] = 0表示该区间无法合并成一个元素,这是本题的关键约束,避免无效合并。
- 最大数值更新:由于并非所有区间都能合并成一个元素,因此需要用
max_val实时记录所有合法dp[i][j]的最大值。
三、区间 DP 的常见变形与优化技巧
3.1 常见变形
1. 环形区间 DP
如环形石子合并,核心技巧是 "倍增数组" ,将环形问题转化为线性问题。例如,将数组s扩展为s[1..2n],其中s[i+n] = s[i],然后在2n长度的数组上做区间 DP,最后取dp[i][i+n-1](i=1~n)的最优解。
2. 多维约束的区间 DP
如区间 DP 结合背包约束,此时状态定义会增加维度,例如dp[i][j][k]表示区间[i,j]在花费k的情况下的最优解,但本质还是遵循 "小区间支撑大区间" 的逻辑。
3. 区间 DP 求方案数
如 "摆花" 问题(洛谷 P1077),状态定义为dp[i][j]表示前i种花摆j盆的方案数,转移方程为dp[i][j] += dp[i-1][j-k](k为第i种花的盆数),本质是区间 DP 与多重背包的结合。
3.2 优化技巧
1. 前缀和 / 后缀和优化
如石子合并问题中,用前缀和快速计算区间和,避免重复计算,降低时间复杂度。
2. 状态压缩
对于某些区间 DP 问题,状态可以压缩维度。例如,当dp[i][j]只依赖于dp[i+1][j]和dp[i][j-1]时,可以用一维数组压缩,但大部分区间 DP 问题需要二维状态。
3. 四边形不等式优化
对于满足四边形不等式的区间 DP 问题(如石子合并),可以将时间复杂度从O(n³)优化到O(n²)。核心思想是通过记录最优分割点,减少分割点的枚举范围,但实现较为复杂,适合n较大的场景。
总结
区间 DP 是动态规划中最具规律性的分支之一,它的核心思想 "小区间支撑大区间" 简单而优雅。无论问题多么复杂,只要遵循 "定义状态→推导转移方程→初始化→按长度填表" 的四步曲,就能迎刃而解。
本文通过 4 个经典例题,详细讲解了区间 DP 的解题流程,从基于端点的简单问题到基于分割点的复杂问题,覆盖了区间 DP 的主要应用场景。希望大家能通过本文的学习,彻底掌握区间 DP,在遇到区间类问题时能够游刃有余。
最后,送给大家一句话:动态规划的本质是 "记住过去的答案,避免重复计算",而区间 DP 则是将这句话发挥到了极致 ------ 记住所有小区间的答案,最终拼凑出大区间的最优解。