好的,我注意到在已讲过的海量题目列表中,虽然包含了许多复杂的变体,但有一个经典的、用于阐释区间DP最优决策分割点思想的入门题目——“矩阵连乘的最小乘法次数问题”(已在列表中),但其一个非常重要的、用于阐释区间DP决策枚举顺序与状态设计优化思想的进阶题目尚未出现。
我们今天来讲这个进阶题目:
区间动态规划例题:矩阵连乘的最小乘法次数问题(带矩阵维度链循环优化版本)
题目描述
假设我们有一系列矩阵 \(A_1, A_2, ..., A_n\),其中矩阵 \(A_i\) 的维度为 \(p_{i-1} \times p_i\)。
我们知道,矩阵乘法满足结合律,但不满足交换律。因此,计算矩阵链乘积 \(A_1 A_2 ... A_n\) 时,不同的加括号(即不同的计算顺序)会导致所需进行的标量乘法次数天差地别。
目标是:找到一种最优的完全加括号方式(即确定一个最优的计算顺序),使得计算整个矩阵链乘积所需的总标量乘法次数最少。
输入:一个整数数组 p,长度为 n+1。其中 p[i-1] 和 p[i] 分别表示矩阵 \(A_i\) 的行数和列数(\(1 \le i \le n\))。
输出:计算矩阵链 \(A_1 A_2 ... A_n\) 所需的最少乘法次数。
示例
输入:p = [30, 35, 15, 5, 10, 20, 25] (n=6)
解释:
- A1: 30x35
- A2: 35x15
- A3: 15x5
- A4: 5x10
- A5: 10x20
- A6: 20x25
输出:15125
解释:最优加括号方式为 ((A1 (A2 A3)) ((A4 A5) A6)),对应最少乘法次数为 15125。
解题过程讲解
这是一个经典的区间动态规划问题。其核心思想是:一个大的、复杂的计算顺序问题,可以分解为两个更小的、相似的子问题,再加上将这两个子结果合并的成本。
第一步:问题分析与状态定义
-
理解乘法次数:
如果矩阵 \(X\) 是 \(a \times b\) 的,矩阵 \(Y\) 是 \(b \times c\) 的,那么计算 \(XY\) 需要 \(a \times b \times c\) 次标量乘法。结果矩阵维度为 \(a \times c\)。 -
寻找最优子结构:
考虑最终计算 \(A_1 A_2 ... A_n\) 的最后一步操作。它一定是将某个前缀链 \((A_i ... A_k)\) 的结果矩阵和某个后缀链 \((A_{k+1} ... A_j)\) 的结果矩阵相乘。
对于区间[i, j]内的矩阵链 \(A_i ... A_j\)(其中1 <= i <= j <= n),我们假设已经找到了计算它的最优方法。这个最优方法的最后一步乘法,一定是在某个位置 \(k\)(i <= k < j)将链断开,先最优地计算 \(A_i ... A_k\),再最优地计算 \(A_{k+1} ... A_j\),最后将这两个结果相乘。
因此,计算[i, j]的最小代价 = 计算[i, k]的最小代价 + 计算[k+1, j]的最小代价 + 将这两部分结果相乘的代价。 -
定义DP状态:
令dp[i][j]表示计算矩阵链 \(A_i ... A_j\)(即从第i个到第j个矩阵)所需的最少乘法次数。
注意:矩阵 \(A_i\) 的维度是p[i-1] x p[i]。所以当我们要计算[i, j]的最后一步乘法(连接[i, k]和[k+1, j])时:- 子链
[i, k]的结果矩阵维度是p[i-1] x p[k]。 - 子链
[k+1, j]的结果矩阵维度是p[k] x p[j]。 - 将它们相乘的额外代价是
p[i-1] * p[k] * p[j]。
- 子链
第二步:状态转移方程
基于以上分析,我们得到状态转移方程:
dp[i][j] = 0, 当 i == j 时(单个矩阵,无需乘法)
dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j]), 当 i < j 时,其中 k 从 i 遍历到 j-1。
解释:对于区间 [i, j],我们枚举所有可能的分割点 k。dp[i][k] 是计算左半部分的最优代价,dp[k+1][j] 是计算右半部分的最优代价,p[i-1] * p[k] * p[j] 是合并这两部分的代价。我们要取所有 k 中代价最小的那个。
第三步:确定计算顺序
这是区间DP的关键。观察状态转移方程,dp[i][j] 依赖于长度更短的区间状态:dp[i][k] 和 dp[k+1][j],其中 k 在 i 和 j 之间。
因此,我们不能简单地按 i 或 j 的顺序计算。一个有效的方法是:按区间长度递增的顺序计算。
- 先计算所有长度为1的区间 (
i=j):dp[i][i] = 0。 - 再计算所有长度为2的区间 (
j = i+1):dp[i][i+1] = p[i-1] * p[i] * p[i+1](只有一种分割方式)。 - 接着计算长度为3、4、...、n的区间。
这样,当计算 dp[i][j] 时,所有比它短的区间状态都已经计算好了。
第四步:算法实现
我们以 p = [30, 35, 15, 5, 10, 20, 25] (n=6) 为例,演示计算过程。
-
初始化:创建一个
(n+1) x (n+1)的二维数组dp(通常从索引1开始使用以对齐矩阵序号,索引0的p[0]是第一个矩阵的行数)。将所有dp[i][i]设为 0。 -
核心循环:
for length in 2 to n: # 枚举区间长度 for i in 1 to n - length + 1: # 枚举区间起点 j = i + length - 1 # 区间终点 dp[i][j] = INFINITY # 初始化为一个大数 for k in i to j-1: # 枚举分割点 cost = dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j] if cost < dp[i][j]: dp[i][j] = cost # 如果需要重构方案,可以在此记录最优分割点 k (s[i][j] = k) -
结果:最终答案在
dp[1][n]中。
手动推导片段(长度=3):
计算 dp[1][3](矩阵链 A1, A2, A3):
k=1: 分割为 (A1) 和 (A2 A3)
代价 =dp[1][1] + dp[2][3] + p[0]*p[1]*p[3]=0 + (35*15*5) + 30*35*5=0 + 2625 + 5250 = 7875k=2: 分割为 (A1 A2) 和 (A3)
代价 =dp[1][2] + dp[3][3] + p[0]*p[2]*p[3]=(30*35*15) + 0 + 30*15*5=15750 + 0 + 2250 = 18000
dp[1][3] = min(7875, 18000) = 7875
第五步:复杂度分析
- 状态数:\(O(n^2)\) 个。
- 对于每个状态
dp[i][j],我们需要枚举O(j-i) ≈ O(n)个分割点k来计算最小值。 - 总时间复杂度为 \(O(n^3)\)。
- 空间复杂度为 \(O(n^2)\),用于存储
dp表。
第六步:重构最优解(如果需要输出加括号方式)
我们可以使用一个额外的二维数组 s[i][j],在更新 dp[i][j] 时,记录下取得最小代价的那个分割点 k。
然后通过递归函数可以打印出最优的加括号方式:
def print_optimal_parenthesis(s, i, j):
if i == j:
print(f"A{i}", end="")
else:
print("(", end="")
print_optimal_parenthesis(s, i, s[i][j])
print_optimal_parenthesis(s, s[i][j]+1, j)
print(")", end="")
调用 print_optimal_parenthesis(s, 1, n)。
对于示例,它会输出 ((A1(A2A3))((A4A5)A6))。
总结:
这个“矩阵连乘”问题是区间DP的典范。它清晰地展示了如何将一个大区间的最优解,通过枚举分割点,分解为两个子区间的最优解,再加上合并代价。按区间长度递增的顺序计算,确保了子问题先于父问题被解决。虽然 \(O(n^3)\) 的复杂度对于大规模 n 来说较高,但对于中等规模(n 在几百量级)的问题,它是非常有效和经典的解法。理解这个问题,就掌握了解决一大类“最优划分/合并”区间问题(如石子合并、多边形剖分等)的核心思想。