矩阵连乘问题
矩阵连乘问题(最少乘法次数)
1. 问题描述
给定 个矩阵 ,其中 与 是可乘的(即 的列数等于 的行数)。设 的规模为 ,则整个连乘积 的规模为 。
矩阵乘法满足结合律,因此可以通过加括号的方式任意改变相乘顺序。例如 4 个矩阵有 5 种加括号方式:
不同的运算顺序,所需的标量乘法次数可能相差巨大。
问题:给定维度序列 ,求计算 所需的最少标量乘法次数,并给出对应的加括号方案。
1.1 为什么乘法次数会不同?
计算两个矩阵 与 相乘,需要执行 次标量乘法。
以三个矩阵 、、 为例:
| 运算顺序 | 计算过程 | 乘法次数 |
|---|---|---|
| :,得 ;再 : | 7500 | |
| :,得 ;再 : | 75000 |
同一个连乘积,仅因为加括号方式不同,乘法次数相差 10 倍。这正是需要动态规划的原因。
1.2 约定与记号
| 记号 | 含义 |
|---|---|
| 矩阵个数 | |
| 维度数组,长度为 ; 的规模为 | |
| 连乘积 | |
| 计算 所需的最少乘法次数 | |
| 使 取到最小值的划分位置 ,用于还原最优方案 |
2. 为什么不能暴力枚举?
2.1 加括号方案的数量是卡特兰数
设 为 个矩阵连乘的加括号方案数。考虑最后一次相乘:它把序列分成前 个和后 个两部分,于是
这是卡特兰数的递推式,解得
| 1 | 2 | 3 | 4 | 5 | 6 | 10 | 20 | |
|---|---|---|---|---|---|---|---|---|
| 方案数 | 1 | 1 | 2 | 5 | 14 | 42 | 4862 | 1.77×10⁹ |
卡特兰数呈指数增长(),因此穷举所有方案在 稍大时即不可行。
2.2 为什么贪心法不行?
一个直觉的贪心策略是「每次优先合并当前代价最小的相邻两个矩阵」,但它不满足贪心选择性质。
反例:维度序列 ,即四个矩阵
贪心过程(每步合并代价最小的相邻对,各步最小值唯一):
| 步骤 | 当前序列 | 各相邻对代价 | 贪心选择 | 累计代价 |
|---|---|---|---|---|
| 1 | ,, | 合并 (3648) | 3648 | |
| 2 | , | 合并 (5664) | 9312 | |
| 3 | 合并(46728) | 56040 |
最优方案 :
贪心得到 56040,最优是 14604,贪心解是最优解的 3.8 倍。
关键原因:贪心只看当前这一步的局部代价,而合并出的结果矩阵维度会影响后续所有乘法的代价。第 1 步合并 虽然当时最便宜(3648),但它把 的维度 38 与 4 合并成了 4,使第 2 步不得不在 与 之间继续做局部选择,最终把最贵的 留到了最后一步。最优方案则宁可先付 7788 合并 ,让最后一步只做 的廉价乘法。局部最优无法保证全局最优,动态规划则枚举所有划分点 ,从全局取最优。
3. 最优子结构
定理:矩阵连乘问题具有最优子结构。即若 的最优加括号方案在 处断开(),则其中 与 的加括号方案也必定分别是各自的最优方案。
证明(剪贴法反证):设 的最优方案为 。若存在 的另一种加括号方案代价更小,则用它替换后得到的 的方案代价更小,与「 已是最优」矛盾。因此子问题的解必为最优。
有了最优子结构,就可以用子问题的最优解递推原问题的最优解。
4. 建立递归关系
4.1 状态转移方程
三项的含义:
| 项 | 含义 |
|---|---|
| 计算左半部分 的代价 | |
| 计算右半部分 的代价 | |
| 把两个结果矩阵相乘的代价:左结果规模 ,右结果规模 |
最终答案是 。
4.2 重叠子问题
直接按递归关系写递归程序,会产生大量重复计算。以 为例, 的递归树为:
m[1][4]
├── k=1: m[1][1] + m[2][4]
│ └── m[2][4] → m[2][2]+m[3][4] , m[2][3]+m[4][4]
│ └── m[2][3] → ...
├── k=2: m[1][2] + m[3][4]
│ ├── m[1][2] → ...
│ └── m[3][4] → ...
└── k=3: m[1][3] + m[4][4]
└── m[1][3] → m[1][1]+m[2][3] , m[1][2]+m[3][3]
└── m[2][3] → ... ← 与上面的 m[2][3] 重复可以看到 、、 等子问题被反复求解。设暴力递归的调用次数为 ,每次调用枚举 个划分点,每个划分点递归求解两侧(末尾的 表示当前子问题自身的一次调用):
代入小规模数值可以归纳出闭式解:
| 1 | 2 | 3 | 4 | 5 | 6 | 10 | |
|---|---|---|---|---|---|---|---|
| 1 | 3 | 9 | 27 | 81 | 243 | 19683 |
即 ,仍是指数级。
对比: 个矩阵连乘一共有 个不同的子问题 ,但暴力递归却要调用 次——差距全在重复计算上。把每个子问题的解存进表,使每个子问题只求解一次,就能把指数级降到多项式级——这就是动态规划的核心。
5. 自底向上填表
5.1 计算顺序
依赖于比它更短的区间,因此按区间长度 递增的顺序填表:
for r = 2 to n: // 区间长度
for i = 1 to n - r + 1: // 区间起点
j = i + r - 1 // 区间终点
m[i][j] = min{ m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j] } (i <= k < j)时 (单个矩阵无需相乘),是递推的边界。
5.2 手工演算示例
取经典数据 ,即 6 个矩阵:
第 1 步:(两个矩阵相乘)
第 2 步:(三个矩阵)
同理 (),(),()。
第 3 步:
第 4 步:(原问题)
5.3 完整 表与 表
表(下三角无意义,用 - 表示;上三角为 的有效值):
| 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
| 1 | 0 | 15750 | 7875 | 9375 | 11875 | 15125 |
| 2 | - | 0 | 2625 | 4375 | 7125 | 10500 |
| 3 | - | - | 0 | 750 | 2500 | 5375 |
| 4 | - | - | - | 0 | 1000 | 3500 |
| 5 | - | - | - | - | 0 | 5000 |
| 6 | - | - | - | - | - | 0 |
表(记录最优划分点 ):
| 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
| 1 | 0 | 1 | 1 | 3 | 3 | 3 |
| 2 | - | 0 | 2 | 3 | 3 | 3 |
| 3 | - | - | 0 | 3 | 3 | 3 |
| 4 | - | - | - | 0 | 4 | 5 |
| 5 | - | - | - | - | 0 | 5 |
| 6 | - | - | - | - | - | 0 |
结论:最少乘法次数为 15125,最优加括号方案为 。
对比:若按从左到右顺序相乘 ,代价为 ,是最优方案的 2.68 倍。
6. 构造最优解
表只给出了最优值,要得到具体的加括号方案,需要额外的 表:在计算 取到最小值时,同时记录划分点 。之后从 开始自顶向下回溯:
/** 由 s 表自顶向下回溯,输出最优加括号方案 */
private String buildOptimalParens(int i, int j) {
if (i == j) { // 单个矩阵,递归出口
return "A" + i;
}
int k = s[i][j]; // 最优划分点
return "(" + buildOptimalParens(i, k)
+ buildOptimalParens(k + 1, j) + ")";
}回溯过程():
s[1][6] = 3 → ( A1..A3 )( A4..A6 )
s[1][3] = 1 → ( A1 )( A2..A3 )
s[2][3] = 2 → ( A2 )( A3 )
s[4][6] = 5 → ( A4..A5 )( A6 )
s[4][5] = 4 → ( A4 )( A5 )
合并结果:((A1(A2A3))((A4A5)A6))7. Java 完整实现
import java.util.Arrays;
/**
* 矩阵连乘问题:求计算 A1A2...An 所需的最少标量乘法次数,并给出最优加括号方案。
*/
public class MatrixChain {
/** 维度数组 p:第 i 个矩阵 Ai 的规模为 p[i-1] × p[i],长度为 n+1 */
private final int[] p;
/** m[i][j]:计算 Ai..Aj 所需的最少乘法次数,仅用 1..n 下标 */
private final long[][] m;
/** s[i][j]:使 m[i][j] 取到最小值的划分位置 k,用于回溯构造最优解 */
private final int[][] s;
private final int n;
public MatrixChain(int[] p) {
this.p = p;
this.n = p.length - 1;
this.m = new long[n + 1][n + 1];
this.s = new int[n + 1][n + 1];
}
/** 自底向上动态规划:按链长 r 递增填表,时间复杂度 O(n^3),空间复杂度 O(n^2) */
public long solve() {
// 链长为 1 时无需相乘,代价为 0(m 数组默认即为 0)
for (int r = 2; r <= n; r++) { // r 为子链长度
for (int i = 1; i <= n - r + 1; i++) { // i 为子链起点
int j = i + r - 1; // j 为子链终点
m[i][j] = Long.MAX_VALUE;
for (int k = i; k < j; k++) { // 枚举最后一次相乘的划分点
long cost = m[i][k] + m[k + 1][j]
+ (long) p[i - 1] * p[k] * p[j];
if (cost < m[i][j]) {
m[i][j] = cost;
s[i][j] = k;
}
}
}
}
return m[1][n];
}
/** 由 s 表自顶向下回溯,输出最优加括号方案,如 ((A1(A2A3))((A4A5)A6)) */
public String buildOptimalParens() {
return buildOptimalParens(1, n);
}
private String buildOptimalParens(int i, int j) {
if (i == j) {
return "A" + i;
}
int k = s[i][j];
return "(" + buildOptimalParens(i, k) + buildOptimalParens(k + 1, j) + ")";
}
/** 打印 m 表和 s 表的上三角部分,便于人工核对填表过程 */
public void printTables() {
System.out.println("m 表(最少乘法次数):");
for (int i = 1; i <= n; i++) {
StringBuilder line = new StringBuilder();
for (int j = 1; j <= n; j++) {
line.append(j < i ? String.format("%10s", "-") : String.format("%10d", m[i][j]));
}
System.out.println(line);
}
System.out.println("s 表(最优划分点 k):");
for (int i = 1; i <= n; i++) {
StringBuilder line = new StringBuilder();
for (int j = 1; j <= n; j++) {
line.append(j < i ? String.format("%5s", "-") : String.format("%5d", s[i][j]));
}
System.out.println(line);
}
}
/** 备忘录法(自顶向下递归 + 记忆化),与自底向上 DP 结果一致 */
public static long memoized(int[] p) {
int n = p.length - 1;
long[][] memo = new long[n + 1][n + 1];
for (long[] row : memo) {
Arrays.fill(row, -1);
}
return lookup(p, memo, 1, n);
}
private static long lookup(int[] p, long[][] memo, int i, int j) {
if (i == j) {
return 0;
}
if (memo[i][j] >= 0) { // 查表命中,直接返回,避免重复计算
return memo[i][j];
}
long best = Long.MAX_VALUE;
for (int k = i; k < j; k++) {
best = Math.min(best, lookup(p, memo, i, k) + lookup(p, memo, k + 1, j)
+ (long) p[i - 1] * p[k] * p[j]);
}
memo[i][j] = best; // 记录子问题解
return best;
}
public static void main(String[] args) {
// 六个矩阵:A1=30×35, A2=35×15, A3=15×5, A4=5×10, A5=10×20, A6=20×25
int[] p = {30, 35, 15, 5, 10, 20, 25};
MatrixChain chain = new MatrixChain(p);
long min = chain.solve();
System.out.println("最少乘法次数:" + min);
System.out.println("最优加括号方案:" + chain.buildOptimalParens());
System.out.println("备忘录法校验:" + memoized(p));
System.out.println();
chain.printTables();
// 对比:顺序相乘 ((((A1A2)A3)A4)A5)A6) 的代价
long leftToRight = 0;
for (int i = 1; i < p.length - 1; i++) {
leftToRight += (long) p[0] * p[i] * p[i + 1];
}
System.out.println();
System.out.println("从左到右顺序相乘的代价:" + leftToRight);
}
}7.1 运行结果
最少乘法次数:15125
最优加括号方案:((A1(A2A3))((A4A5)A6))
备忘录法校验:15125
m 表(最少乘法次数):
0 15750 7875 9375 11875 15125
- 0 2625 4375 7125 10500
- - 0 750 2500 5375
- - - 0 1000 3500
- - - - 0 5000
- - - - - 0
s 表(最优划分点 k):
0 1 1 3 3 3
- 0 2 3 3 3
- - 0 3 3 3
- - - 0 4 5
- - - - 0 5
- - - - - 0
从左到右顺序相乘的代价:40500输出与第 5 节的手工演算完全一致。
8. 复杂度分析
8.1 时间复杂度
- 外层按链长 循环: 种链长;
- 中层枚举起点 :共 个;
- 内层枚举划分点 :最多 个。
三层循环嵌套,总时间复杂度为
相比暴力递归的 ,是数量级的改进。 时,暴力递归需调用 次,而动态规划的三重循环只需执行
次基本运算。
8.2 空间复杂度
需要两个 的二维数组( 表与 表),故为 。
8.3 可优化点
| 优化方向 | 说明 | 效果 |
|---|---|---|
| 只保留 表 + 滚动数组 | 求最优值时 表可用一维滚动数组按链长递推 | 空间降到 (但还原方案仍需 表,实际意义有限) |
| 四边形不等式优化 | 最优划分点满足 ,可把 的枚举范围收窄到 总量 | 时间降到 |
| 记忆化搜索 | 只计算真正用到的子问题,适合某些子问题不需要求解的场景 | 时间不变,常数更小 |
8.4 备忘录法 vs 自底向上
| 对比项 | 备忘录法(自顶向下) | 自底向上填表 |
|---|---|---|
| 代码风格 | 与递归关系式几乎一一对应,易读 | 需要自己确定填表顺序 |
| 递归开销 | 有函数调用栈开销, 很大时可能栈溢出 | 无 |
| 计算范围 | 只算被用到的子问题 | 全部子问题都要算 |
| 空间优化 | 不易做滚动数组 | 容易做 |
本问题中所有子问题都会被用到,因此两种方式效率相当,结果也完全一致(见运行结果中的「备忘录法校验:15125」)。
9. 考点小结与常见变形
9.1 解题四步
- 状态定义: = 计算 的最少乘法次数;
- 递归关系:;
- 计算顺序:按区间长度 递增;
- 构造最优解:用 表回溯输出加括号方案。
9.2 易错点
- 维度数组下标: 是 ,写成
p[i]*p[j]是常见错误,最后一项应为 。 - 区间长度还是终点:外层循环必须是区间长度 ,不能直接用 ,否则计算 时依赖的子问题尚未求出。
- 乘法溢出: 在维度较大时会超出
int范围,应使用long(代码中已强制转换(long))。 - 只求值不求解:若题目要求给出加括号方案,必须额外维护 表,否则只能得到最小次数。
9.3 同族变形
| 变形 | 与矩阵连乘的关系 |
|---|---|
| 石子合并(环形 / 直线) | 完全相同的区间 DP 结构,代价函数换成两堆石子重量之和 |
| 最优二叉搜索树 | 状态与三重循环结构一致,代价函数换成查找概率加权和 |
| 多边形三角剖分 | 与矩阵连乘一一对应,最小权三角剖分 = 最少乘法次数 |
| 括号匹配加最少括号 | 区间 DP,状态为「区间内最少需要补充的括号数」 |
| 最长回文子序列 | 区间 DP,状态为「区间内最长回文子序列长度」 |
区间 DP 的通用特征:状态为
dp[i][j]表示区间 上的最优解,转移时枚举分割点 ,按区间长度递增填表。矩阵连乘是这类问题最经典的入门模型。
9.4 与软考的联系
在软考中级「软件设计师」下午场算法题中,矩阵连乘属于动态规划的经典考点,常见考法:
- 补全 的递推式与三重循环的填空;
- 给定小规模维度序列,手工填写 表并求最少乘法次数;
- 给出 表,要求写出最优加括号方案。
