C++ 区间 DP(Interval DP)

一类经典的区间最值/最优问题解法

1. 区间 DP 概念与状态定义

什么是区间 DP?

区间 DP(Interval Dynamic Programming)是动态规划(DP)的一种重要类型,它专门用于解决涉及 连续子区间 的最优化问题。

区间 DP 的核心思想通常是:

  • 问题的最优解包含其子区间的最优解(满足最优子结构)。
  • 通过合并小区间的最优解来逐步构建大区间的最优解。
  • 状态通常表示一个特定区间 `[i, j]` 的解。

它常用于处理序列、字符串或环形结构上的问题。

区间 DP 的状态定义

最常见的状态定义是使用二维数组 dp[i][j] 来表示区间 [i, j] 上的最优解。

dp[i][j] 的具体含义取决于问题:

  • 求解区间 [i, j]最小值或最大值(例如合并石子)。
  • 求解处理区间 [i, j] 所需的 最小或最大代价(例如矩阵链乘法)。
  • 判断区间 [i, j] 是否满足 某种性质(例如括号匹配)。
  • 计算区间 [i, j] 上的 方案数

边界条件: 通常,长度为 1 的区间 [i, i] 是 DP 的起点。

dp[i][i] 的值往往可以直接根据单个元素 a[i] 确定,或者设为 0 或其他基础值。

例如,在矩阵链乘法中,dp[i][i] = 0,因为单个矩阵不需要乘法。

在合并石子中,dp[i][i] = 0 (代价),而区间和 sum[i][i] = a[i]

区间示例演示

假设我们有一个长度为 5 的数组(或序列)A = [a₀, a₁, a₂, a₃, a₄]

a[0]
a[1]
a[2]
a[3]
a[4]

区间 DP 需要计算所有可能子区间的最优解,例如:

  • 长度为 1 的区间 (边界): [0, 0] (a₀), [1, 1] (a₁), ..., [4, 4] (a₄) → 计算 dp[0][0], dp[1][1], ..., dp[4][4]
  • 长度为 3 的区间: [0, 2] (a₀, a₁, a₂), [1, 3] (a₁, a₂, a₃), [2, 4] (a₂, a₃, a₄) → 计算 dp[0][2], dp[1][3], dp[2][4] (依赖于长度 1 和 2 的区间)
  • 长度为 5 的区间 (最终目标): [0, 4] (整个数组) → 计算 dp[0][4] (依赖于所有更小的区间)
关键在于计算顺序:必须先计算完所有长度为 `L` 的区间,才能开始计算长度为 `L+1` 的区间。

2. 转移方程与计算顺序

典型的状态转移方程

区间 DP 的状态转移方程通常涉及将区间 [i, j] 分割成两个或多个相邻的子区间

最常见的形式是枚举区间 [i, j] 内的一个分割点 k (i ≤ k < j),将区间分为 [i, k][k+1, j]

// 求解最小值的情况
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost(i, k, j));
// 对所有 k 从 i 到 j-1
// 求解最大值的情况
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k+1][j] + cost(i, k, j));
// 对所有 k 从 i 到 j-1

这里的 cost(i, k, j) 代表:

  • 合并子区间 [i, k][k+1, j] 的代价。
  • 这个代价可能依赖于 i, k, j 本身,或者依赖于区间的某些属性(如区间和、端点值等)。
  • 有时,这个合并代价可能是 0。
在实际计算 `dp[i][j]` 之前,通常需要将其初始化为一个极大值(求最小值时)或极小值(求最大值时),以便 `min` 或 `max` 操作能正确找到第一个有效值。

计算顺序:按区间长度递增

由于 dp[i][j] 的计算依赖于比它更短的子区间(如 dp[i][k]dp[k+1][j]),因此必须保证在计算 dp[i][j] 时,所有它依赖的子问题都已经被解决了。

最常用且最可靠的计算顺序是按区间长度 `len` 从小到大进行迭代:

// 假设数组/序列长度为 n (下标从 0 到 n-1 或 1 到 n)
// 这里以 1 到 n 为例
int n = /* 序列长度 */;
vector<vector<int>> dp(n + 1, vector<int>(n + 1, /* 初始化值 */));

// 1. 初始化边界条件 (长度为 1 的区间)
for (int i = 1; i <= n; ++i) {
    dp[i][i] = /* 边界值,例如 0 */;
}

// 2. 按区间长度 len 从 2 到 n 迭代
for (int len = 2; len <= n; ++len) {
    // 3. 枚举区间起点 i
    // 区间为 [i, j],长度为 len,则 j = i + len - 1
    // i 的最大值为 n - len + 1,确保 j <= n
    for (int i = 1; i <= n - len + 1; ++i) {
        int j = i + len - 1; // 计算区间终点
        // 4. 枚举分割点 k (从 i 到 j-1)
        for (int k = i; k < j; ++k) {
            // 计算合并代价 cost(i, k, j)
            int current_cost = calculate_cost(i, k, j); // 视具体问题而定
            // 状态转移
            dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + current_cost); // 或 max
        }
    }
}

// 最终答案通常是 dp[1][n]
关键循环顺序:
  1. 外层循环:区间长度 `len` (从小到大)。
  2. 中层循环:区间起点 `i`。
  3. 内层循环:分割点 `k`。
这个顺序保证了 `dp[i][k]` (长度 < `len`) 和 `dp[k+1][j]` (长度 < `len`) 在计算 `dp[i][j]` (长度 `len`) 之前都已计算完毕。

计算顺序可视化演示 (n=5)

考虑一个长度为 5 的序列,下标 1 到 5。我们将演示 DP 表格的填充顺序。

DP 表格 (dp[i][j]):

点击按钮,观察 DP 表格中对应长度区间的单元格被计算(高亮显示)。

3. 典型示例——矩阵链乘法

问题描述:矩阵链乘法

给定一个 n 个矩阵的序列(链)<A₁, A₂, ..., Aₙ>,我们希望计算它们的乘积 A₁A₂...Aₙ。

矩阵乘法满足结合律,因此可以按不同顺序进行计算,例如 (A₁A₂)A₃ 或 A₁(A₂A₃)。

计算两个矩阵 P (维度 p×q) 和 Q (维度 q×r) 的乘积 PQ 需要进行 p × q × r 次标量乘法操作。

目标:找到一种加括号的方式(即确定乘法顺序),使得计算整个矩阵链乘积所需的总标量乘法次数最少

输入:一个整数数组 `p`,其中 `p[i-1]` 和 `p[i]` 分别是矩阵 Aᵢ 的行数和列数 (i=1...n)。数组 `p` 的长度为 n+1。

例子: A₁ (10×20), A₂ (20×30), A₃ (30×40)。输入 `p = [10, 20, 30, 40]` (n=3)。

  • (A₁A₂)A₃:
    • 计算 A₁A₂ (10×20×30 = 6000 次) → 结果是 10×30 矩阵 M₁₂
    • 计算 M₁₂A₃ (10×30×40 = 12000 次)
    • 总计:6000 + 12000 = 18000
  • A₁(A₂A₃):
    • 计算 A₂A₃ (20×30×40 = 24000 次) → 结果是 20×40 矩阵 M₂₃
    • 计算 A₁M₂₃ (10×20×40 = 8000 次)
    • 总计:24000 + 8000 = 32000

显然,第一种顺序更优。如何用区间 DP 系统地找到最优解?

矩阵链乘法:状态定义与转移方程

我们定义状态 dp[i][j] 为:

计算矩阵子链 AᵢAᵢ₊₁...Aⱼ 的最少标量乘法次数。

我们的最终目标是求解 dp[1][n]

(
Aᵢ
p[i-1]×p[i]
...
...
Ak
p[k-1]×p[k]
) × (
Ak+1
p[k]×p[k+1]
...
...
Aⱼ
p[j-1]×p[j]
)

↑ 考虑最后一次乘法发生在分割点 k 处

边界条件:

// 计算单个矩阵 Aᵢ 不需要乘法
dp[i][i] = 0;  // for all i from 1 to n

状态转移方程:

要计算 dp[i][j],我们考虑所有可能的最后一次乘法发生的位置。假设最后一次乘法是将 (Aᵢ...Ak) 和 (Ak+1...Aⱼ) 的结果相乘,其中 i ≤ k < j。

此时的成本 = 计算 (Aᵢ...Ak) 的成本 + 计算 (Ak+1...Aⱼ) 的成本 + 最后一次合并的成本。

  • 计算 (Aᵢ...Ak) 的最优成本是 dp[i][k]。结果是一个 p[i-1] × p[k] 的矩阵。
  • 计算 (Ak+1...Aⱼ) 的最优成本是 dp[k+1][j]。结果是一个 p[k] × p[j] 的矩阵。
  • 合并这两个结果矩阵需要 p[i-1] * p[k] * p[j] 次标量乘法。

我们需要尝试所有可能的分割点 k,并取最小值:

dp[i][j] = min( dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j] );
// 对所有 k 满足 i ≤ k < j

(在实际计算前,dp[i][j] 应初始化为无穷大)

矩阵链乘法:算法演示 (p = [10, 20, 30, 40], n=3)

矩阵:A₁ (10×20), A₂ (20×30), A₃ (30×40)。我们需要计算 dp[1][3]

DP 表格 (dp[i][j]):

dp[i][j]j=1j=2j=3
i=1???
i=2-??
i=3--?
点击按钮查看计算过程。

矩阵链乘法:C++ 代码实现

下面是使用区间 DP 解决矩阵链乘法问题的 C++ 实现:

时间复杂度

  • 三个嵌套循环:
    • `len` 从 2 到 n (O(n))
    • `i` 从 1 到 n-len+1 (O(n))
    • `k` 从 i 到 j-1 (O(n))
  • 总复杂度为 O(n³)

空间复杂度

  • 使用二维 DP 表 dp[n+1][n+1]
  • 需要存储维度数组 `p[n+1]`
  • 总空间复杂度为 O(n²)

这个算法效率对于典型的竞赛题目(n ≤ 500 左右)是可接受的。

4. 状态转移图与优化思考

状态转移图可视化

我们可以将 DP 表格看作一个状态转移图。计算 dp[i][j] 依赖于其左侧 (dp[i][k]) 和下方 (dp[k+1][j]) 的单元格。

以下是 n=5 时 DP 表格的依赖关系示意图(上三角部分):

鼠标悬停在某个单元格 dp[i][j] 上,查看其计算依赖的子问题 dp[i][k]dp[k+1][j]

这个图清晰地展示了为什么必须按区间长度递增(或按对角线)的顺序计算,确保依赖项总是先被计算出来。

复杂度回顾与优化思考

时间复杂度 O(n³)

对于标准的区间 DP 问题,如矩阵链乘法、合并石子等,状态数为 O(n²),每个状态的计算需要 O(n) 的时间(枚举分割点 k),因此总时间复杂度通常是 O(n³)

空间复杂度 O(n²)

需要一个二维数组 dp[n][n] 来存储所有区间的解,空间复杂度为 O(n²)

优化可能性?

对于某些特定的区间 DP 问题,可能存在优化方法:

  • 四边形不等式优化: 如果状态转移函数满足四边形不等式(一种关于成本函数的性质),可以将枚举分割点 k 的过程优化到均摊 O(1),使得总时间复杂度降至 O(n²)。典型的例子是优化版的石子合并。这是一种较高级的技巧。
  • Garsia-Wachs 算法: 用于解决最优二叉搜索树(与某些区间 DP 问题相关)等问题,可以在 O(n log n) 甚至 O(n) 时间内完成。
  • 数据结构优化: 在某些情况下,查找最优分割点 k 的过程可以通过线段树、单调队列等数据结构加速,但这不常见于基础区间 DP。
对于大多数基础的区间 DP 问题,O(n³) 的复杂度是标准且通常足够应对竞赛中的数据范围 (n ≤ 500 ~ 1000)。 O(n²) 的空间复杂度也通常是可接受的。

5. 练习题与编程实践

5.1 选择题小测验

问题 1/5

区间 DP 的计算顺序通常是按区间长度递增,这是为了确保什么?

5.2 编程练习

练习 1:矩阵链乘法实现

任务: 编写一个 C++ 函数 `int matrixChainOrder(const vector& p)`,接收表示矩阵维度的数组 `p` (长度 n+1),返回计算矩阵链 A₁...Aₙ 的最少乘法次数。假定矩阵下标从 1 开始,Aᵢ 的维度是 p[i-1]×p[i]。

分析该实现的时间和空间复杂度。


练习 2:最优括号匹配

任务: 给定一个只包含 '(' 和 ')' 的字符串 `s`。你需要删除最少的括号,使得剩余的字符串成为一个合法的括号序列。返回需要删除的最少括号数量。

合法的括号序列定义:

  • 空字符串是合法的。
  • 如果 A 是合法的,则 (A) 是合法的。
  • 如果 A 和 B 都是合法的,则 AB 是合法的。

例如: 输入 "())(",输出 2 (删除第一个 ')' 和最后的 '(',得到 "()")。

提示: 考虑用 `dp[i][j]` 表示使子串 `s[i...j]` 合法所需删除的最少括号数。

6. 区间 DP 实现注意事项

在编写区间 DP 代码时,注意以下几点可以避免常见错误:

1. DP 数组大小与下标

  • 确保 DP 数组(通常是二维 dp[N][N])的大小足够容纳所有可能的区间。如果序列长度为 n,下标从 1 开始,则需要 dp[n+1][n+1]dp[n+2][n+2] (留一些余量更安全)。
  • 注意题目给定的下标是从 0 开始还是从 1 开始,并在代码中保持一致。当使用 1-based 下标时,访问 `p[i-1]` 等需要小心边界。
  • 有时为了处理环形问题(如环形石子合并),需要将序列复制一遍接在后面,此时 DP 数组大小需要开到 dp[2*N][2*N]

2. 初始化

  • 边界条件 `dp[i][i]`: 必须正确初始化长度为 1 的区间。通常是 0 或与 `a[i]` 相关的值。
  • 非边界状态 `dp[i][j]` (i < j): 在进行 `min` 或 `max` 聚合之前,必须将这些状态初始化为一个合适的值:
    • 求最小值问题:初始化为 正无穷大 (如 `0x3f3f3f3f` 或 `LLONG_MAX`)。
    • 求最大值问题:初始化为 负无穷大 (如 `-0x3f3f3f3f` 或 `LLONG_MIN`) 或 0 (如果结果非负)。
    • 判断可行性/计数问题:可能初始化为 `false`, -1, 或 0。
    确保初始值不会影响第一次有效的状态转移。
  • 对于无效区间(例如 i > j),可以不初始化,或者有时为了代码统一性初始化为 0 或无穷大,但要确保它们不参与有效计算。

3. 循环顺序与边界

  • 严格遵守按区间长度 `len` 从小到大的循环顺序。
  • 内部循环的起点 `i` 和终点 `j` 的计算要精确:j = i + len - 1。确保 `i` 的范围使得 `j` 不会越界(如 `i <= n - len + 1`)。
  • 分割点 `k` 的范围通常是 `i <= k < j`。确保 `k+1` 不会越界并且 `k` 的取值覆盖所有可能的分割方式。

4. 转移方程中的代价计算

  • cost(i, k, j) 的计算必须准确无误,它通常是问题的关键所在。
  • 注意代价计算中可能涉及的区间和、端点值等辅助信息,这些信息可能需要预处理(如使用前缀和)。

5. 数据类型与溢出

  • 如果计算结果或中间代价可能很大,使用 long long 防止整数溢出。
  • 无穷大的设定也要考虑数据类型,int 的无穷大用 0x3f3f3f3flong long 的无穷大用类似 0x3f3f3f3f3f3f3f3fLL

7. 知识延伸与其他应用

其他典型的区间 DP 问题

除了矩阵链乘法,区间 DP 还可以解决许多其他问题:

  • 合并石子 / 合并木棍: 将相邻的石子堆(或木棍)合并,每次合并有代价(通常是两堆石子数量之和),求最小或最大总代价。线性版本和环形版本。
  • 括号匹配与计数: 判断括号序列是否合法,计算最少添加/删除多少括号使其合法,计算合法括号序列的数量等。
  • 最长回文子序列 / 子串: 求解字符串中的最长回文子序列(不要求连续)或子串(要求连续)。子序列问题是典型的区间 DP。
  • 凸多边形三角剖分: 将一个凸多边形分割成若干个三角形,使得所有边的总长度(或其他度量)最小/最大。
  • 最优二叉搜索树: 给定一组键及其访问频率,构造一棵二叉搜索树使得平均搜索时间(期望搜索代价)最小。
  • 戳气球 (LeetCode 312): 给定 n 个气球,每次戳破一个气球 i 可以获得 `nums[i-1]*nums[i]*nums[i+1]` 的分数(边界处假设有值为 1 的虚拟气球),求能获得的最大总分数。(逆向思维,考虑最后戳破哪个气球,转化为区间 DP)。
  • 区间染色 / 区间覆盖: 用最少的操作覆盖某个区间,或计算某种染色方案数等。

区间 DP 的泛化应用

区间 DP 的核心思想——通过合并子区间的解来构建大区间解——可以应用于:

  • 区间最值问题: 求解区间内的最大/最小值、最大/最小代价。
  • 最优分割问题: 将一个序列或区间分割成若干部分,使得某种度量最优。
  • 区间计数问题: 计算满足特定条件的区间数量或方案数。
  • 区间属性判断: 判断一个区间是否具有某种性质。

结合记忆化搜索(递归实现)

区间 DP 也可以使用递归 + 记忆化搜索 (Memoization) 的方式实现,这有时更符合思维习惯:

vector<vector<int>> memo; // 记忆化数组,初始化为 -1或其他标记
vector<int> p; // 输入数据

int solve(int i, int j) {
    // 基本情况:区间长度为 1
    if (i == j) {
        return 0;
    }
    // 查表:如果已经计算过,直接返回结果
    if (memo[i][j] != -1) {
        return memo[i][j];
    }

    // 初始化为无穷大(求最小值时)
    int min_cost = 0x3f3f3f3f;

    // 尝试所有分割点 k
    for (int k = i; k < j; ++k) {
        int cost = solve(i, k) + solve(k + 1, j) + p[i - 1] * p[k] * p[j]; // 矩阵链乘法为例
        min_cost = min(min_cost, cost);
    }

    // 存表并返回
    return memo[i][j] = min_cost;
}

int main() {
    int n = /* ... */;
    p = /* ... */;
    memo.assign(n + 1, vector<int>(n + 1, -1));
    cout << solve(1, n) << endl;
    return 0;
}

记忆化搜索的优点是代码结构可能更清晰,且只计算实际需要的状态。缺点是递归深度可能较大,常数开销可能略高于迭代版本。

区间 DP 是一种强大而灵活的技术,掌握其核心思想和实现模式对于解决一系列算法问题至关重要。