Day2 区间DP 学生讲义_预习
Day2 区间DP 学生讲义
今日目标:掌握区间 DP 的核心思想与套路,能独立识别区间 DP 题型,写出状态转移方程并 AC
一、什么是区间 DP?
1.1 用一张表来算合并石子
有 4 堆石子:[2, 5, 3, 1]。我们用 dp[l][r] 表示把第 l 堆到第 r 堆合并成一堆的最小代价。
先算最简单的情况——相邻两堆直接合并(长度=2):
dp[1][2]= 合并 2 和 5 = 2+5 = 7dp[2][3]= 合并 5 和 3 = 5+3 = 8dp[3][4]= 合并 3 和 1 = 3+1 = 4
对角线(长度=1)都是 0(一堆不需要合并):
dp[l][r] |
1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 0 | 7 | ? | ? |
| 2 | 0 | 8 | ? | |
| 3 | 0 | 4 | ||
| 4 | 0 |
现在算长度=3 的区间 [1,3](石子 2,5,3)。最后一次合并,中间一定有个分割点 k,把区间切成两块:
- 切在 k=1:
[1]+[2,3]→dp[1][1] + dp[2][3] + 总和(2+5+3=10) = 0 + 8 + 10 = 18 - 切在 k=2:
[1,2]+[3]→dp[1][2] + dp[3][3] + 总和(10) = 7 + 0 + 10 = 17
取最小,dp[1][3] = 17。同理算 dp[2][4](石子 5,3,1):
- 总和 = 5+3+1 = 9
- k=2:
0 + dp[3][4] + 9 = 0 + 4 + 9 = 13 - k=3:
dp[2][3] + 0 + 9 = 8 + 0 + 9 = 17 dp[2][4] = 13
填表更新:
dp[l][r] |
1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 1 | 0 | 7 | 17 | ? |
| 2 | 0 | 8 | 13 | |
| 3 | 0 | 4 | ||
| 4 | 0 |
最后算总长度 [1,4](全部4堆,总和=11):
- k=1:
[1]+[2,4]→ 0 + 13 + 11 = 24 - k=2:
[1,2]+[3,4]→ 7 + 4 + 11 = 22 ← 最小! - k=3:
[1,3]+[4]→ 17 + 0 + 11 = 28
dp[1][4] = 22,这就是最小总代价(先合3和1得4,再合2和5得7,最后合7和4得11,总 4+7+11=22)。
1.2 发现规律
每次算 dp[l][r],都要把区间在某点 k 切成 [l,k] 和 [k+1,r],加上这段的总和:
\(dp[l][r] = min( dp[l][k] + dp[k+1][r] ) + sum(l 到 r)\)
重要发现:算 dp[1][4] 时,我们用到了 dp[1][1]、dp[2][4]、dp[1][2] 等——这些都是长度更短的区间!所以必须先算小区间,再算大区间。
这就引出了区间 DP 的标准套路:外层按长度从小到大枚举。
1.3 标准代码模板
// n堆石子,前缀和数组 s[i] 方便快速求 sum(l,r) = s[r]-s[l-1]
for (int i = 1; i <= n; i++) dp[i][i] = 0; // 长度为1代价为0
// 外层:枚举区间长度(从2开始)
for (int len = 2; len <= n; len++) {
// 中层:枚举左端点 l
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1; // 算出右端点
dp[l][r] = 很大的数; // 初始化
int total = s[r] - s[l-1]; // 区间总和
// 内层:枚举分割点 k
for (int k = l; k < r; k++) {
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + total);
}
}
}
cout << dp[1][n]; // 全部合并的最小代价
为什么外层必须是长度?
因为
dp[l][r]依赖的两个子区间[l,k]和[k+1,r]长度都比len小。只有从小到大枚举长度,才能保证算大区间时,小区间都已经算好了。如果乱序枚举,可能用到还没算出来的 0/无穷大,结果就错了。
1.4 三要素总结
| 要素 | 含义 | 例子(石子合并) |
|---|---|---|
| 状态 | dp[l][r] 表示什么 |
区间 [l,r] 合并成一堆的最小代价 |
| 转移 | 怎么切分 | 枚举分割点 k,左右相加再加区间总和 |
| 顺序 | 先小后大 | 外层枚举长度 len,从小到大 |
二、讲解例题
例题 1:P1775 石子合并(弱化版)
一句话题意
N 堆石子排成一排,每次只能合并相邻两堆,代价为两堆质量之和。求合并为一堆的最小总代价。\(N \le 300\),每堆质量 \(\le 1000\)。
思考过程
引入:这是区间 DP 的"Hello World",几乎所有区间 DP 的入门题。
核心洞察:考虑最后一次合并——无论前面怎么合并,最后一步一定是将"左边一堆"和"右边一堆"合并成最终的一堆。左边那堆是由 [l, k] 合并而来的,右边那堆是由 [k+1, r] 合并而来的。
状态定义:dp[l][r] = 将第 l 到 r 堆石子合并为一堆的最小代价。
转移方程:
\(dp[l][r] = min(dp[l][k] + dp[k+1][r]) + sum(l, r)\)
其中 k 是分割点(\(l \le k < r\)),sum(l, r) 是区间 [l, r] 所有石子的总质量(即最后一次合并的代价)。
如何快速求 sum(l, r)? 用前缀和!\(sum(l, r) = pre[r] - pre[l-1]\),O(1) 查询。
初始化:
- \(dp[i][i] = 0\)(只有一堆,不需要合并,代价为 0)
- 其他 \(dp[l][r] = +\infty\)(因为求最小值)
枚举顺序:按区间长度从小到大,确保算大区间时小区间已算完。
答案:dp[1][n]
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 305;
const int INF = 1e9;
int m[N], pre[N]; // m[i] 第 i 堆质量,pre[i] 前缀和
int dp[N][N]; // dp[l][r] 合并 l~r 的最小代价
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> m[i];
pre[i] = pre[i - 1] + m[i]; // 前缀和
}
// 初始化:求最小值,先设为正无穷
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dp[i][j] = INF;
// 长度为 1 的区间,不需要合并,代价为 0
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; // 右端点
// 枚举分割点
for (int k = l; k < r; k++) {
int cost = pre[r] - pre[l - 1]; // 区间总质量(前缀和 O(1))
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r] + cost);
}
}
}
cout << dp[1][n] << endl;
return 0;
}
时间复杂度:O(n³),n ≤ 300 → 约 2700 万次运算,完全可行。
例题 2:P1880 [NOI1995] 石子合并
一句话题意
N 堆石子围成一圈,每次只能合并相邻两堆,代价为两堆石子数之和。求合并成一堆的最小得分和最大得分。\(N \le 100\),每堆石子数 \(\le 20\)。
思考过程
引入:这道题和例题 1 几乎一样,唯一的区别是——石子围成了一个环。
问题:环意味着第 1 堆和第 N 堆也是相邻的,但我们的 dp[l][r] 只能处理线性的区间,怎么办?
破环成链:这是处理环形问题的经典技巧。把环从某处"剪开",变成一条长度为 N 的链。但环可以从任意位置开始合并,所以我们需要枚举起点。
巧妙做法:将原来的数组复制一份接在后面,得到长度为 2N 的数组。这样,任何一个长度为 N 的连续区间就对应了环上从某个起点开始的 N 堆石子。
例如原数组 [4, 5, 9, 4],复制后变成 [4, 5, 9, 4, 4, 5, 9, 4]:
[4,5,9,4](起点0):原封不动[5,9,4,4](起点1):从第2堆开始绕一圈[9,4,4,5](起点2):从第3堆开始绕一圈[4,4,5,9](起点3):从第4堆开始绕一圈
状态定义:
f[l][r]= 合并区间[l, r]的最小得分g[l][r]= 合并区间[l, r]的最大得分
转移方程(和例题 1 完全一样):
f[l][r] = min(f[l][k] + f[k+1][r]) + sum(l, r) // 求最小
g[l][r] = max(g[l][k] + g[k+1][r]) + sum(l, r) // 求最大
答案:
- 最小得分 = \(\min(f[i][i+N-1])\),其中 \(1 \le i \le N\)
- 最大得分 = \(\max(g[i][i+N-1])\),其中 \(1 \le i \le N\)
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 210; // 2倍大小
const int INF = 1e9;
int a[N], pre[N]; // a[i] 石子数,pre[i] 前缀和
int f[N][N], g[N][N]; // f 最小,g 最大
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
a[i + n] = a[i]; // 复制一份,破环成链
}
// 前缀和(长度为 2n)
for (int i = 1; i <= 2 * n; i++)
pre[i] = pre[i - 1] + a[i];
// 初始化
for (int i = 1; i <= 2 * n; i++) {
for (int j = 1; j <= 2 * n; j++) {
f[i][j] = INF;
g[i][j] = -INF;
}
f[i][i] = g[i][i] = 0; // 单堆不需要合并
}
// 区间 DP(长度只枚举到 n,因为我们只关心长度为 n 的区间)
for (int len = 2; len <= n; len++) {
for (int l = 1; l + len - 1 <= 2 * n; l++) {
int r = l + len - 1;
for (int k = l; k < r; k++) {
int cost = pre[r] - pre[l - 1];
f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + cost);
g[l][r] = max(g[l][r], g[l][k] + g[k + 1][r] + cost);
}
}
}
// 枚举所有起点,找最优
int minAns = INF, maxAns = -INF;
for (int i = 1; i <= n; i++) {
minAns = min(minAns, f[i][i + n - 1]);
maxAns = max(maxAns, g[i][i + n - 1]);
}
cout << minAns << endl;
cout << maxAns << endl;
return 0;
}
核心技巧:环形 → 复制数组 → 长度 2N 的链 → 枚举所有长度为 N 的区间。
例题 3:P1063 能量项链
一句话题意
N 颗珠子围成一圈,每颗珠子有两个值(头标记 h 和尾标记 t)。相邻两颗珠子可以合并,得分 = h₁ × t₁ × t₂(其中 t₁ = h₂,因为相邻珠子首尾相连)。合并后新珠子的头 = h₁,尾 = t₂。求释放的最大总能量。\(N \le 100\),标记值 \(\le 100\)。
思考过程
引入:这道题和例题 2 非常相似——也是环形结构,也是合并相邻两个,也是求最大得分。区别在于合并的得分计算方式不同。
举例:珠子 A(1, 2)、B(2, 3)、C(3, 1) 排成一圈。
- 先合并 A 和 B:得分 = 1×2×3 = 6,新珠子 A'(1, 3)
- 再合并 A' 和 C:得分 = 1×3×1 = 3,新珠子 A''(1, 1)
- 总分 = 6 + 3 = 9
破环成链:和例题 2 一样,将珠子数组复制一份。如果有 N 颗珠子,我们需要合并 N-1 次才能变成一颗。所以区间长度枚举到 N。
状态定义:dp[l][r] = 将区间 [l, r] 的珠子合并为一颗的最大能量。
转移方程:枚举分割点 k,将 [l, r] 分成 [l, k] 和 [k+1, r]:
\(dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r] + h[l] * t[k] * t[r])\)
其中 h[l] 是区间最左珠子的头标记,t[k] 是左半部分最后一颗珠子的尾标记,t[r] 是右半部分最后一颗珠子的尾标记。
关键:合并
[l,k]后,这颗大珠子的尾标记就是原来第 k 颗珠子的尾标记。合并[k+1,r]后,这颗大珠子的头标记就是原来第 k+1 颗珠子的头标记 = 第 k 颗珠子的尾标记。所以最后一步合并的得分 = \(h[l] \times t[k] \times t[r]\)。
初始化:\(dp[i][i] = 0\)(一颗珠子无法合并,能量为 0)
答案:max(dp[i][i+N-1]),其中 \(1 \le i \le N\)
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 205; // 2倍大小
int h[N], t[N]; // h[i] 头标记,t[i] 尾标记
int dp[N][N]; // dp[l][r] 合并 l~r 的最大能量
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> h[i];
h[i + n] = h[i]; // 复制
}
// 构造头尾标记
// 第 i 颗珠子的头 = h[i],尾 = h[i+1](因为相邻珠子首尾相连)
for (int i = 1; i <= 2 * n; i++) {
t[i] = h[i + 1];
}
// 初始化
for (int i = 1; i <= 2 * n; i++)
for (int j = 1; j <= 2 * n; j++)
dp[i][j] = 0;
// 区间 DP,长度从 2 到 n(合并 n-1 次)
for (int len = 2; len <= n; len++) {
for (int l = 1; l + len - 1 <= 2 * 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] + h[l] * t[k] * t[r]);
}
}
}
// 找最大值
int ans = 0;
for (int i = 1; i <= n; i++)
ans = max(ans, dp[i][i + n - 1]);
cout << ans << endl;
return 0;
}
对比 P1880:同样是环形合并,P1880 的合并代价是区间和,P1063 的合并代价是头×尾×尾。状态定义和枚举方式完全一样,只是
cost的计算不同。
例题 4:P1435 [IOI2000] 回文字串
一句话题意
给定一个字符串,每次可以在任意位置插入一个字符,求使其变成回文串的最少插入次数。区分大小写。\(|S| \le 1000\)。
思考过程
引入:回文串的特点是"正着读和反着读一样"。比如 "Ab3bd" 不是回文,但插入 2 个字符可以变成 "Adb3bdA" 或 "A3bdb3A" 等回文串。
区间 DP 视角:考虑区间 [l, r] 对应的子串,怎样用最少插入变成回文?
状态定义:dp[l][r] = 将子串 s[l..r] 变成回文串的最少插入次数。
转移方程:看两端字符 s[l] 和 s[r] 是否相等。
-
s[l] == s[r]:两端已经对称,不需要为这两端插入任何字符。问题缩小为s[l+1..r-1]:
\(dp[l][r] = dp[l+1][r-1]\) -
s[l] != s[r]:两端不对称,需要在某一端插入一个字符来匹配另一端:- 在右端插入
s[l]→ 左端匹配了,问题变成s[l+1..r],操作数 +1 - 在左端插入
s[r]→ 右端匹配了,问题变成s[l..r-1],操作数 +1
\(dp[l][r] = min(dp[l+1][r], dp[l][r-1]) + 1\)
- 在右端插入
初始化:
- \(dp[i][i] = 0\)(单个字符本身就是回文,不需要插入)
- 其他 \(dp[l][r] = +\infty\)
枚举顺序:从小到大枚举长度(转移用到的是更短区间)。
答案:dp[1][n]
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 1010;
char s[N];
int dp[N][N]; // dp[l][r] 将 s[l..r] 变成回文的最少插入次数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> (s + 1); // 下标从 1 开始
int n = strlen(s + 1);
// 初始化:所有区间设为一个很大的数
memset(dp, 0x3f, sizeof(dp));
// 长度为 1 的子串已经是回文
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;
if (s[l] == s[r]) {
// 两端相等:内部区间如果是空或已处理,直接继承内部结果
if (l + 1 <= r - 1)
dp[l][r] = dp[l + 1][r - 1];
else
dp[l][r] = 0; // 区间长度 ≤ 2 且两端相等,已是回文
} else {
// 两端不等:在左端或右端插入一个字符
dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]) + 1;
}
}
}
cout << dp[1][n] << endl;
return 0;
}
另一种思路:最长回文子序列(LPS)
核心洞察:要在原串 S 中插入最少的字符使之成为回文串,等价于保留原串中最长的回文子序列,然后在缺失的位置补上对应的字符。因此:
最少插入次数 = 原串长度 - 最长回文子序列的长度
例如 S = "Ab3bd",最长回文子序列可以是 "b3b"(长度3),那么需要插入 \(5-3=2\) 个字符。
如何求最长回文子序列(LPS)?
同样使用区间DP:
- 状态定义:
lps[l][r]= 子串s[l..r]中最长回文子序列的长度。 - 转移:
- 若
s[l] == s[r]:lps[l][r] = lps[l+1][r-1] + 2 - 否则:
lps[l][r] = max(lps[l+1][r], lps[l][r-1])
- 若
- 初始化:
lps[i][i] = 1(单个字符是长度为1的回文子序列) - 答案:
n - lps[1][n]
代码实现:
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
char s[N];
int lps[N][N]; // 最长回文子序列长度
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> (s + 1);
int n = strlen(s + 1);
// 初始化:长度为1的子串
for (int i = 1; i <= n; i++)
lps[i][i] = 1;
// 枚举区间长度
for (int len = 2; len <= n; len++) {
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
if (s[l] == s[r]) {
// 两端字符相等,可以同时加入回文子序列
lps[l][r] = lps[l + 1][r - 1] + 2;
} else {
// 两端不等,取去掉左端或右端的较大者
lps[l][r] = max(lps[l + 1][r], lps[l][r - 1]);
}
}
}
int ans = n - lps[1][n];
cout << ans << endl;
return 0;
}
两种思路对比:
- 第一种(直接求最少插入次数)更符合直觉,转移时
dp[l][r] = min(dp[l+1][r], dp[l][r-1]) + 1。 - 第二种(LPS)思维转换后代码更简洁,本质上是同一个问题的两种视角,时间复杂度均为 O(n²)。
例题 5:P4170 [CQOI2007] 涂色
一句话题意
给定一个目标字符串(仅大写字母),每次可以将连续一段涂成同一种颜色,后涂的颜色覆盖先涂的。求涂成目标字符串的最少涂色次数。\(|S| \le 50\)。
思考过程
引入:想象你有一块空白木板,每次可以选一段涂成某种颜色。比如目标串 "RGBGR",你可以:先涂全段为 'R'(RRRRR),再涂中间三段为 'G'(RGGGR),再涂第 3 段为 'B'(RGBGR),共 3 次。
状态定义:dp[l][r] = 将区间 [l, r] 涂成目标颜色(即 s[l..r])的最少涂色次数。
转移方程:
s[l] == s[r]:两端颜色相同。第一次涂色时可以把整个区间涂成这个颜色,然后只需要处理内部s[l+1..r-1]的涂色。等价于:
\(dp[l][r] = min(dp[l+1][r], dp[l][r-1])\)
为什么?因为第一次涂色覆盖
[l, r]时,两端同时被涂好了。也可以先涂[l, r-1](此时s[r]顺便被涂对了),再处理内部。取两种情况的最小值。
s[l] != s[r]:两端颜色不同,最后一次涂色不可能同时覆盖两端。必须找一个分割点 k,分成两段分别涂:
\(dp[l][r] = min(dp[l][k] + dp[k+1][r]),其中 l ≤ k < r\)
初始化:
- \(dp[i][i] = 1\)(单个字符涂 1 次即可)
- 其他 \(dp[l][r] = +\infty\)
答案:dp[1][n]
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 55;
char s[N];
int dp[N][N]; // dp[l][r] 将 l~r 涂成目标颜色的最少次数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> (s + 1);
int n = strlen(s + 1);
// 初始化
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dp[i][j] = 1e9;
// 单个字符涂 1 次
for (int i = 1; i <= n; i++)
dp[i][i] = 1;
// 枚举区间长度
for (int len = 2; len <= n; len++) {
for (int l = 1; l + len - 1 <= n; l++) {
int r = l + len - 1;
if (s[l] == s[r]) {
// 两端颜色相同:第一次涂色可以一起涂
dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]);
}
// 无论两端是否相同,都可以尝试分割
for (int k = l; k < r; k++) {
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
}
}
}
cout << dp[1][n] << endl;
return 0;
}
和石子合并的区别:石子合并的分割转移是"必须分割",涂色的分割转移是"可以选择分割"。而且涂色多了一个
s[l]==s[r]的特殊优化。

浙公网安备 33010602011771号