GESP 7级

区间DP

7级 · DP/图论/搜索

📖 一句话理解

有一些排成一排的物品(比如石子堆),每次可以把相邻的两堆合并成一堆,合并代价是两堆的总重量。问怎么合并,能让总代价最小?这类在"区间"上做DP的问题,就叫区间DP。
💡 为什么重要?
区间DP是DP中一种重要的遍历方式:先算小区间,再算大区间。它教会你如何处理"区间上的最优解",是石子合并、矩阵链乘法等经典问题的基础。GESP 7级考试中,区间DP是重要的考点。
📋 前置知识
  • 二维数组:dp[i][j] 这样的写法
  • 前缀和:快速求区间和(如果不懂,本页也有讲解)
  • 三层for循环:区间DP需要三层循环
  • 建议先学完 01背包,理解DP的基本思路

🧠 区间DP的核心思想

🔑 思维方式:先小后大
  1. 小区间先算:长度为1的区间不需要合并,代价是0
  2. 逐步扩大:先算所有长度为2的区间,再算长度为3的,依此类推
  3. 枚举分割点:对于区间 [i, j],在每个位置 k 分割,左半边 [i,k] 和右半边 [k+1,j] 分别已经是最优解了
  4. 合并代价:两半的代价 + 合并这两半的代价(区间总重量)

📐 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]中所有石子的总重量

DP数组怎么来的?

🔢 推导步骤
  1. 定义dp[i][j]:合并[i,j]的最小代价
  2. 初始化:dp[i][i] = 0(只有一堆时不需要合并)
  3. 按区间长度从小到大计算:先算长度2,再算长度3,……,最后算长度n
  4. 对每个区间[i,j],枚举分割点k:k从i到j-1,取最小值
  5. 需要前缀和: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] — 最后算

必须从小区间到大区间,因为大区间的答案依赖于小区间的答案。

🚨 易错点

⚠️ 常见错误汇总
  1. 三层循环的顺序搞错:最外层是区间长度len,中间是左端点i,最内层是分割点k。这个顺序不能乱!
  2. 忘记初始化dp[i][i]=0:长度为1的区间代价是0,如果不初始化会用到随机值。
  3. dp[i][j]初始化为INF:因为要取min,初始值必须是一个很大的数。
  4. 前缀和写错:sum(i,j) = pre[j] - pre[i-1],不是 pre[j] - pre[i]。
  5. 分割点k的范围:k 从 i 到 j-1(不包括j),因为右半边至少要有一个元素。
  6. 数组下标从0还是1开始:区间DP通常从1开始更方便(配合前缀和),注意和0-based数组区分。

🎯 练习建议

📝 循序渐进练习路径
  1. 理解前缀和:如果还不熟悉前缀和,先做几道前缀和的入门题。
  2. 手推小例子:在纸上画出dp表格,手动计算4-5堆石子的合并过程。
  3. 做经典题目:
    • P1880 石子合并(洛谷经典)
    • P1063 能量项链
    • P3205 合唱队形
  4. 注意变形:有些题目是"环形石子合并"(首尾相连),需要把数组复制一遍变成2n长度。