区间 DP(Interval Dynamic Programming)是动态规划(DP)的一种重要类型,它专门用于解决涉及 连续子区间 的最优化问题。
区间 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₄]
:
区间 DP 需要计算所有可能子区间的最优解,例如:
dp[0][0], dp[1][1], ..., dp[4][4]
dp[0][2], dp[1][3], dp[2][4]
(依赖于长度 1 和 2 的区间)
dp[0][4]
(依赖于所有更小的区间)
区间 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
本身,或者依赖于区间的某些属性(如区间和、端点值等)。由于 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]
考虑一个长度为 5 的序列,下标 1 到 5。我们将演示 DP 表格的填充顺序。
DP 表格 (dp[i][j]):
给定一个 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)。
显然,第一种顺序更优。如何用区间 DP 系统地找到最优解?
我们定义状态 dp[i][j]
为:
计算矩阵子链 AᵢAᵢ₊₁...Aⱼ 的最少标量乘法次数。
我们的最终目标是求解 dp[1][n]
。
↑ 考虑最后一次乘法发生在分割点 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ⱼ) 的成本 + 最后一次合并的成本。
dp[i][k]
。结果是一个 p[i-1] × p[k] 的矩阵。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]
应初始化为无穷大)
矩阵:A₁ (10×20), A₂ (20×30), A₃ (30×40)。我们需要计算 dp[1][3]
。
DP 表格 (dp[i][j]):
dp[i][j] | j=1 | j=2 | j=3 |
---|---|---|---|
i=1 | ? | ? | ? |
i=2 | - | ? | ? |
i=3 | - | - | ? |
下面是使用区间 DP 解决矩阵链乘法问题的 C++ 实现:
dp[n+1][n+1]
这个算法效率对于典型的竞赛题目(n ≤ 500 左右)是可接受的。
我们可以将 DP 表格看作一个状态转移图。计算 dp[i][j]
依赖于其左侧 (dp[i][k]
) 和下方 (dp[k+1][j]
) 的单元格。
以下是 n=5 时 DP 表格的依赖关系示意图(上三角部分):
dp[i][j]
上,查看其计算依赖的子问题 dp[i][k]
和 dp[k+1][j]
。
这个图清晰地展示了为什么必须按区间长度递增(或按对角线)的顺序计算,确保依赖项总是先被计算出来。
对于标准的区间 DP 问题,如矩阵链乘法、合并石子等,状态数为 O(n²),每个状态的计算需要 O(n) 的时间(枚举分割点 k),因此总时间复杂度通常是 O(n³)。
需要一个二维数组 dp[n][n]
来存储所有区间的解,空间复杂度为 O(n²)。
对于某些特定的区间 DP 问题,可能存在优化方法:
区间 DP 的计算顺序通常是按区间长度递增,这是为了确保什么?
在大多数区间 DP 问题中,状态 `dp[i][i]`(表示长度为 1 的区间)通常初始化为什么?
在矩阵链乘法 `dp[i][j] = min(dp[i][k] + dp[k+1][j] + cost)` 中,`cost = p[i-1]*p[k]*p[j]` 代表什么?
标准区间 DP 算法的时间复杂度通常是多少(n 为序列长度)?
下列哪种问题类型最不适合使用区间 DP 解决?
任务: 编写一个 C++ 函数 `int matrixChainOrder(const vector
分析该实现的时间和空间复杂度。
任务: 给定一个只包含 '(' 和 ')' 的字符串 `s`。你需要删除最少的括号,使得剩余的字符串成为一个合法的括号序列。返回需要删除的最少括号数量。
合法的括号序列定义:
例如: 输入 "())(",输出 2 (删除第一个 ')' 和最后的 '(',得到 "()")。
提示: 考虑用 `dp[i][j]` 表示使子串 `s[i...j]` 合法所需删除的最少括号数。
在编写区间 DP 代码时,注意以下几点可以避免常见错误:
dp[N][N]
)的大小足够容纳所有可能的区间。如果序列长度为 n,下标从 1 开始,则需要 dp[n+1][n+1]
或 dp[n+2][n+2]
(留一些余量更安全)。dp[2*N][2*N]
。j = i + len - 1
。确保 `i` 的范围使得 `j` 不会越界(如 `i <= n - len + 1`)。cost(i, k, j)
的计算必须准确无误,它通常是问题的关键所在。long long
防止整数溢出。int
的无穷大用 0x3f3f3f3f
,long long
的无穷大用类似 0x3f3f3f3f3f3f3f3fLL
。除了矩阵链乘法,区间 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;
}
记忆化搜索的优点是代码结构可能更清晰,且只计算实际需要的状态。缺点是递归深度可能较大,常数开销可能略高于迭代版本。