CC++ & Algorithm

区间DP

较难9
语言版本:C++
概述:解决“一个区间内的问题”,通过枚举分割点,合并子区间得到答案,就像把一段绳子分成小段处理。

区间DP:把大问题切成小段,像吃面包一样简单

想象你有一根很长的法棍面包,想吃掉它。最方便的办法不是一口吞,而是先在中间掰开,分成两段,然后每段再掰开,直到变成一口能吃下的小块。这就是区间DP的核心思想:把一个大区间(比如数组的一段)分成两个小区间,分别解决,再把结果合并起来。它专门用来解决“在一个连续区间上做选择,最终要得到整个区间的最优解”的问题。


核心思想:为什么要把大区间切成小区间?

普通动态规划通常按顺序走(比如从左到右选物品),而区间DP是按区间长度从小到大处理。先算长度为1的区间(只有1个元素),再算长度为2的,慢慢扩大,直到算出整个区间。就像搭积木:先搭好最小的积木块,再用它们拼成更大的结构。

关键步骤只有两个:

  1. 枚举分割点:在区间 [l, r] 中选一个位置 kl <= k < r),把区间切成 [l, k][k+1, r] 两段。
  2. 合并结果:两段各自的最优解加起来,再加上“合并这两段”需要的代价,就是当前区间的候选解。取所有分割点中的最小值。

这种套路能解决很多问题:合并石子、括号匹配、最优二叉搜索树……而且代码长得特别像。


生活例子:合并石子(现场推演)

你有一排石子堆,每堆有若干个石子。每次只能合并相邻的两堆,消耗的体力等于这两堆石子的总数。问把所有石子合并成一堆,最少需要多少体力?

比如三堆:1、2、3

  • 方案1:先合并1和2,得到3,消耗3;现在有三堆:3、3,再合并得到6,消耗6,总消耗9。
  • 方案2:先合并2和3,得到5,消耗5;现在有两堆:1、5,再合并得到6,消耗6,总消耗11。

显然方案1更省力。区间DP能自动算出这个最小值。

推广到更一般的情况

假设有4堆:4、1、3、2。手动模拟会有点复杂,但计算机可以用区间DP轻松算出来。我们只需要定义好状态和转移方程。

  • 状态定义dp[l][r] 表示合并第 l 堆到第 r 堆(这些堆是连续的)成一堆所需的最小体力。

  • 边界条件dp[i][i] = 0,因为一堆石子不需要合并。

  • 转移方程:在 [l, r] 中找一个分割点 kl <= k < r),先合并左区间 [l, k],再合并右区间 [k+1, r],最后把左右两堆(即整个区间)合并在一起,消耗 sum[l][r](区间内石子总数)。所以:

    dp[l][r] = min( dp[l][r] , dp[l][k] + dp[k+1][r] + sum[l][r] )
    其中 sum[l][r] 可以用前缀和快速得到:sum[l][r] = sum[r] - sum[l-1]sum[i] 表示前i堆的总数)。


新手常犯的3个错误

  1. 忘记初始化 dp 数组

    • 如果不把 dp 设为很大的数(比如 0x3f3f3f3f),min 比较时会出错。
    • 正确做法:用 memset(dp, 0x3f, sizeof(dp)) 初始化为一个足够大的数,再把 dp[i][i] 设为0。
  2. 循环顺序搞反

    • 必须先枚举区间长度,再枚举左端点,最后枚举分割点
    • 如果反过来(先枚举左端点再枚举长度),大区间在计算时,它依赖的小区间可能还没算好。原因:区间DP要求“小区间先算完”,只有按长度从小到大才能保证这一点。
  3. 分割点漏掉边界

    • 分割点 k 必须从 lr-1,不能等于 r(否则右区间为空)。
    • 同样,左区间不能为空,所以 k 不能小于 l

完整代码:合并石子(附详细注释)

#include <iostream>
#include <algorithm>   // 为了用 min()
#include <cstring>     // 为了用 memset()
using namespace std;

int main() {
    // ---------- 输入 ----------
    int n;              // 石子堆数
    cin >> n;
    int stones[105];    // 每堆石子数量,索引从1开始
    for (int i = 1; i <= n; i++) {
        cin >> stones[i];
    }

    // ---------- 前缀和 ----------
    int prefix_sum[105] = {0};       // prefix_sum[i] = 前i堆石子总数
    for (int i = 1; i <= n; i++) {
        prefix_sum[i] = prefix_sum[i-1] + stones[i];
    }

    // ---------- DP数组 ----------
    int dp[105][105];                // dp[l][r]:合并[l,r]的最小体力
    memset(dp, 0x3f, sizeof(dp));    // 初始化为一个很大的数(约10^9)
    for (int i = 1; i <= n; i++) {
        dp[i][i] = 0;                // 一堆石子不需要消耗体力
    }

    // ---------- 核心DP:枚举区间长度 ----------
    for (int len = 2; len <= n; len++) {           // 区间长度从2开始
        for (int left = 1; left + len - 1 <= n; left++) {   // 左端点
            int right = left + len - 1;            // 右端点
            // 尝试所有分割点 k
            for (int k = left; k < right; k++) {
                // 左区间 [left, k] 的体力 + 右区间 [k+1, right] 的体力
                // + 本次合并这两大堆的体力(区间内石子总数)
                int cost = dp[left][k] + dp[k+1][right] 
                         + (prefix_sum[right] - prefix_sum[left-1]);
                dp[left][right] = min(dp[left][right], cost);
            }
        }
    }

    // ---------- 输出结果 ----------
    cout << dp[1][n] << endl;   // 把第1堆到第n堆合并成一堆的最小体力
    return 0;
}

运行示例
输入:

4
4 1 3 2

计算过程:

  • 长度2: (4,1) 合并需要5, (1,3) 合并需要4, (3,2) 合并需要5
  • 长度3: (4,1,3) 最小是 min( 0+4+8, 5+0+8 ) = 12(1,3,2) 最小是 min(0+5+6, 4+0+6)=10
  • 长度4: (4,1,3,2) 枚举k=1: 0+10+10=20;k=2: 5+5+10=20;k=3: 12+0+10=22 → 最小20
    输出:
20

相关知识点拓展

学完了区间DP的基本套路,你可以挑战这些类似的问题:

  1. 环形石子合并:石子堆排成一个环,这时需要把环拆成链,再使用区间DP(比如复制一份数组,跑两倍长度的区间DP)。
  2. 括号匹配:给一个只含 ()[] 的字符串,问最少添加几个括号能让它合法。这也是区间DP经典题,状态 dp[l][r] 表示让区间合法的最小添加数。
  3. 最优二叉搜索树:给定一些单词和它们的查找概率,构造一棵二叉搜索树使得总查找代价最小。同样可以用区间DP,把区间看作连续的关键字。
  4. 能量项链(NOIP2006):类似合并石子,但合并时费用计算方式略有不同。

掌握了“枚举长度、枚举左端点、枚举分割点”这个三部曲,你就掌握了区间DP的钥匙。以后再遇到“一段区间内进行合并、分割”的问题,都可以试试这个思路。

例题精讲

1单选题

区间DP通常通过枚举什么来求解问题?

A枚举区间长度和起点
B枚举起点和终点
C枚举分割点和区间长度
D枚举所有子数组的和
2判断题

区间DP的时间复杂度通常为O(n^3),其中n为区间长度。

3填空题
以下是用区间DP求解石子合并最小代价的代码片段,请填补空白处。
int n, a[105], dp[105][105], sum[105];
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        sum[i] = sum[i-1] + a[i];
    }
    // 初始化dp为无穷大
    memset(dp, 0x3f, sizeof(dp));
    for (int i = 1; i <= n; i++) dp[i][i] = 0;
    // 区间DP
    for (int len = 2; len <= n; len++) {
        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(dp[i][j], dp[i][k] + dp[___][j] + sum[j] - sum[i-1]);
            }
        }
    }
    cout << dp[1][n] << endl;
    return 0;
}
4单选题

下列哪类问题最适合用区间DP解决?

A求一个数组中连续子数组的最大和
B求在一条直线上放置邮局的最小距离和
C求将一段字符串分割成若干回文子串的最小分割次数
D求从起点到终点的最短路径
5判断题

在区间DP的初始化中,通常将长度为1的区间状态设为0或具体值。