GESP 7级
区间DP
7级 · DP/图论/搜索
📖 一句话理解
有一些排成一排的物品(比如石子堆),每次可以把相邻的两堆合并成一堆,合并代价是两堆的总重量。问怎么合并,能让总代价最小?这类在"区间"上做DP的问题,就叫区间DP。
💡 为什么重要?
区间DP是DP中一种重要的遍历方式:先算小区间,再算大区间。它教会你如何处理"区间上的最优解",是石子合并、矩阵链乘法等经典问题的基础。GESP 7级考试中,区间DP是重要的考点。
📋 前置知识
- 二维数组:dp[i][j] 这样的写法
- 前缀和:快速求区间和(如果不懂,本页也有讲解)
- 三层for循环:区间DP需要三层循环
- 建议先学完 01背包,理解DP的基本思路
🧠 区间DP的核心思想
🔑 思维方式:先小后大
- 小区间先算:长度为1的区间不需要合并,代价是0
- 逐步扩大:先算所有长度为2的区间,再算长度为3的,依此类推
- 枚举分割点:对于区间 [i, j],在每个位置 k 分割,左半边 [i,k] 和右半边 [k+1,j] 分别已经是最优解了
- 合并代价:两半的代价 + 合并这两半的代价(区间总重量)
📐 DP思路
状态定义
dp[i][j] = 合并区间 [i, j] 中所有石子堆的最小代价。
转移方程
📐 核心转移方程
dp[i][j] = min(dp[i][k] + dp[k+1][j]) + sum(i,j)
其中 i <= k < j
含义:在k处分割,左半[i,k]最优 + 右半[k+1,j]最优 + 合并代价
sum(i,j) = 区间[i,j]中所有石子的总重量
其中 i <= k < j
含义:在k处分割,左半[i,k]最优 + 右半[k+1,j]最优 + 合并代价
sum(i,j) = 区间[i,j]中所有石子的总重量
DP数组怎么来的?
🔢 推导步骤
- 定义dp[i][j]:合并[i,j]的最小代价
- 初始化:dp[i][i] = 0(只有一堆时不需要合并)
- 按区间长度从小到大计算:先算长度2,再算长度3,……,最后算长度n
- 对每个区间[i,j],枚举分割点k:k从i到j-1,取最小值
- 需要前缀和:sum(i,j) 快速计算区间总重量,避免每次O(n)累加
🔢 手动推演
4堆石子,重量分别是 [3, 1, 4, 2]
📊 逐步计算
长度1:dp[1][1]=0, dp[2][2]=0, dp[3][3]=0, dp[4][4]=0
长度2:
dp[1][2] = 0+0 + (3+1) = 4
dp[2][3] = 0+0 + (1+4) = 5
dp[3][4] = 0+0 + (4+2) = 6
长度3:
dp[1][3] = min(dp[1][1]+dp[2][3], dp[1][2]+dp[3][3]) + 8 = min(0+5, 4+0) + 8 = 12
dp[2][4] = min(dp[2][2]+dp[3][4], dp[2][3]+dp[4][4]) + 7 = min(0+6, 5+0) + 7 = 11
长度4:
dp[1][4] = min(dp[1][1]+dp[2][4], dp[1][2]+dp[3][4], dp[1][3]+dp[4][4]) + 10
= min(0+11, 4+6, 12+0) + 10 = 10 + 10 = 20
💻 完整代码
💻 石子合并(相邻两堆合并最小代价)
#include <iostream>
#include <algorithm>
#include <climits> // INT_MAX
using namespace std;
const int N = 105;
const int INF = 1e9; // 一个很大的数,代表"无穷大"
int a[N]; // a[i] = 第i堆石子的重量
int pre[N]; // pre[i] = 前i堆的总重量(前缀和)
int dp[N][N]; // dp[i][j] = 合并区间[i,j]的最小代价
int main() {
int n;
cin >> n; // 读入石子堆数
// 读入每堆石子的重量,并计算前缀和
for (int i = 1; i <= n; i++) {
cin >> a[i];
pre[i] = pre[i-1] + a[i]; // 前缀和:pre[i] = a[1]+a[2]+...+a[i]
}
// 求区间[i,j]的总重量的辅助函数(也可以直接用前缀和)
// sum(i,j) = pre[j] - pre[i-1]
// 区间DP:按区间长度从小到大计算
// len = 区间长度,从2开始(长度1不需要合并)
for (int len = 2; len <= n; len++) {
// i = 区间左端点
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1; // j = 区间右端点
dp[i][j] = INF; // 初始化为无穷大
// 枚举分割点k(在k和k+1之间切一刀)
for (int k = i; k < j; k++) {
// 左半边[i,k]的最小代价 + 右半边[k+1,j]的最小代价
// + 合并这两半的代价(区间总重量)
int cost = dp[i][k] + dp[k+1][j] + (pre[j] - pre[i-1]);
dp[i][j] = min(dp[i][j], cost);
}
}
}
cout << "最小合并代价: " << dp[1][n] << endl; // 输出答案
return 0;
}📊 遍历顺序图解
🔑 为什么按长度从小到大?
想象你在填一个表格:
- dp[1][1] dp[2][2] dp[3][3] dp[4][4] — 对角线,全部为0
- dp[1][2] dp[2][3] dp[3][4] — 对角线上方第一行,先算
- dp[1][3] dp[2][4] — 再上一行
- dp[1][4] — 最后算
必须从小区间到大区间,因为大区间的答案依赖于小区间的答案。
🚨 易错点
⚠️ 常见错误汇总
- 三层循环的顺序搞错:最外层是区间长度len,中间是左端点i,最内层是分割点k。这个顺序不能乱!
- 忘记初始化dp[i][i]=0:长度为1的区间代价是0,如果不初始化会用到随机值。
- dp[i][j]初始化为INF:因为要取min,初始值必须是一个很大的数。
- 前缀和写错:sum(i,j) = pre[j] - pre[i-1],不是 pre[j] - pre[i]。
- 分割点k的范围:k 从 i 到 j-1(不包括j),因为右半边至少要有一个元素。
- 数组下标从0还是1开始:区间DP通常从1开始更方便(配合前缀和),注意和0-based数组区分。
🎯 练习建议
📝 循序渐进练习路径
- 理解前缀和:如果还不熟悉前缀和,先做几道前缀和的入门题。
- 手推小例子:在纸上画出dp表格,手动计算4-5堆石子的合并过程。
- 做经典题目:
- P1880 石子合并(洛谷经典)
- P1063 能量项链
- P3205 合唱队形
- 注意变形:有些题目是"环形石子合并"(首尾相连),需要把数组复制一遍变成2n长度。