当前目录
← Back

矩阵连乘(MCM)动态规划


矩阵连乘(MCM)动态规划

核心结论

给定矩阵链 A0A1⋯An−1A_0A_1\cdots A_{n-1},设维度数组为 p=[p0,p1,…,pn]p=[p_0,p_1,\ldots,p_n],其中 AiA_i 的维度为 pi×pi+1p_i\times p_{i+1}。

定义:

dp[i][j]=计算 AiAi+1⋯Aj 所需的最少标量乘法次数dp[i][j] = \text{计算 }A_iA_{i+1}\cdots A_j\text{ 所需的最少标量乘法次数}

状态转移:

dp[i][j]=min⁡i≤k<j(dp[i][k]+dp[k+1][j]+pipk+1pj+1)dp[i][j] = \min_{i\le k<j} \left( dp[i][k] + dp[k+1][j] + p_i p_{k+1} p_{j+1} \right)

最终答案为:

dp[0][n−1]dp[0][n-1]

1. 问题定义

给定 n 个可以连续相乘的矩阵:

A0A1⋯An−1A_0A_1\cdots A_{n-1}

矩阵乘法满足结合律,因此不同的加括号方式得到的最终矩阵相同,但计算量可能不同。

例如:

A0A1A2A_0A_1A_2

可以按以下两种方式计算:

(A0A1)A2(A_0A_1)A_2

或:

A0(A1A2)A_0(A_1A_2)

矩阵连乘问题的目标是:

找到一种加括号方式,使标量乘法次数最少。

MCM 算法只决定最优计算顺序,并不真正执行矩阵乘法。


2. 输入如何表示

设维度数组为:

p=[p0,p1,…,pn]p=[p_0,p_1,\ldots,p_n]

第 i 个矩阵的维度为:

Ai:pi×pi+1A_i:p_i\times p_{i+1}

其中:

0≤i<n0\le i<n

因此:

n=len⁡(p)−1n=\operatorname{len}(p)-1

也就是说:

  • 维度数组有 n+1 个元素;

  • 矩阵链中有 n 个矩阵。

示例

若:

p=[5,4,6,2]p=[5,4,6,2]

则:

A0:5×4A_0:5\times4 A1:4×6A_1:4\times6 A2:6×2A_2:6\times2

所以:

n=4−1=3n=4-1=3

3. 为什么这是区间动态规划

原问题是计算整个矩阵链:

A0A1⋯An−1A_0A_1\cdots A_{n-1}

缩小后的子问题仍然是一段连续矩阵链:

AiAi+1⋯AjA_iA_{i+1}\cdots A_j

一个子问题需要由两个下标确定:

  • i:子链起点;

  • j:子链终点。

因此使用二维状态:

dp[i][j]dp[i][j]

这类状态由连续区间 [i,j] 描述,所以属于区间 DP。


4. dp 数组的状态定义

定义:

dp[i][j]=计算矩阵链 AiAi+1⋯Aj 所需的最少标量乘法次数\boxed{ dp[i][j] = \text{计算矩阵链 }A_iA_{i+1}\cdots A_j \text{ 所需的最少标量乘法次数} }

其中:

0≤i≤j<n0\le i\le j<n

如何口头理解

看到 dp[i][j] 时,应立即翻译成:

“计算从第 i 个矩阵到第 j 个矩阵这一整段矩阵链的最小代价。”

例如:

  • dp[0][0]:计算 A0A_0 的最小代价;
  • dp[1][2]:计算 A1A2A_1A_2 的最小代价;
  • dp[0][n-1]:计算完整矩阵链的最小代价。

dp[i][j] 存储的是一个数值,即最少标量乘法次数;它不存储矩阵本身,也不存储矩阵乘法结果。


5. dp 数组大小与输入的关系

设:

n=len⁡(p)−1n=\operatorname{len}(p)-1

则矩阵编号为:

A0,A1,…,An−1A_0,A_1,\ldots,A_{n-1}

所以 dp 的逻辑大小为:

n×nn\times n

Python 中可以写为:

n = len(p) - 1
dp = [[0] * n for _ in range(n)]

其中:

  • 行下标表示区间起点 i;

  • 列下标表示区间终点 j。

只有满足:

i≤ji\le j

的状态有意义,因此实际只使用 dp 的右上三角区域。

例如当 n=4 时:

dp[i][j]j=0j=1j=2j=3
i=0有效有效有效有效
i=1无效有效有效有效
i=2无效无效有效有效
i=3无效无效无效有效

下三角虽然没有使用,但直接分配完整二维数组可以显著简化实现,而且空间复杂度仍为 Θ(n2)\Theta(n^2)。


6. 基本情况

当区间中只有一个矩阵时,不需要做矩阵乘法。

因此:

dp[i][i]=0\boxed{ dp[i][i]=0 }

例如:

dp[2][2]=0dp[2][2]=0

因为计算单个矩阵 A_2 不需要任何标量乘法。


7. 状态转移的推导

考虑子链:

AiAi+1⋯AjA_iA_{i+1}\cdots A_j

它的最后一次矩阵乘法一定会在某个位置 k 处将区间划分成两部分:

(Ai⋯Ak)(Ak+1⋯Aj)(A_i\cdots A_k)(A_{k+1}\cdots A_j)

其中:

i≤k<ji\le k<j

总代价由三部分组成。

7.1 左半部分的最优代价

dp[i][k]dp[i][k]

7.2 右半部分的最优代价

dp[k+1][j]dp[k+1][j]

7.3 两个结果矩阵相乘的代价

左侧结果:

Ai⋯AkA_i\cdots A_k

其维度为:

pi×pk+1p_i\times p_{k+1}

右侧结果:

Ak+1⋯AjA_{k+1}\cdots A_j

其维度为:

pk+1×pj+1p_{k+1}\times p_{j+1}

因此,最后一次矩阵乘法的标量乘法次数为:

pipk+1pj+1p_i p_{k+1} p_{j+1}

所以,在分割点 k 下的总代价为:

dp[i][k]+dp[k+1][j]+pipk+1pj+1dp[i][k] + dp[k+1][j] + p_i p_{k+1} p_{j+1}

枚举所有可能的 k,取最小值:

dp[i][j]=min⁡i≤k<j(dp[i][k]+dp[k+1][j]+pipk+1pj+1)\boxed{ dp[i][j] = \min_{i\le k<j} \left( dp[i][k] + dp[k+1][j] + p_i p_{k+1} p_{j+1} \right) }

8. 为什么转移式中的维度是 p[i] * p[k+1] * p[j+1]

这是 0-based 下标中最容易写错的地方。

对于分割:

(Ai⋯Ak)(Ak+1⋯Aj)(A_i\cdots A_k)(A_{k+1}\cdots A_j)

左侧结果维度:

pi×pk+1p_i\times p_{k+1}

右侧结果维度:

pk+1×pj+1p_{k+1}\times p_{j+1}

因此两个矩阵相乘的代价为:

pipk+1pj+1\boxed{ p_i p_{k+1} p_{j+1} }

可以记忆为:

区间左端维度 × 分割位置右侧维度 × 区间右端的下一个维度。


9. 计算顺序

dp[i][j] 依赖于更短的区间:

dp[i][k]dp[i][k]

和:

dp[k+1][j]dp[k+1][j]

因此必须先计算短区间,再计算长区间。

令区间长度为:

L=j−i+1L=j-i+1

计算顺序为:

L=2,3,…,nL=2,3,\ldots,n

对每个长度 L:

  1. 枚举起点 i;

  2. 计算终点:

    j=i+L−1j=i+L-1
  3. 枚举分割点:

    k=i,i+1,…,j−1k=i,i+1,\ldots,j-1

不能简单按 i、j 从小到大遍历,因为这样不一定保证所有依赖状态都已经计算完成。

区间 DP 的典型顺序是:先枚举区间长度,再枚举区间起点。


10. 精简伪代码

10.1 只求最少乘法次数

MATRIX-CHAIN-ORDER(p)
    n ← length(p) - 1
    dp ← n × n array filled with 0

    for L ← 2 to n
        for i ← 0 to n - L
            j ← i + L - 1
            dp[i][j] ← +∞

            for k ← i to j - 1
                cost ← dp[i][k]
                        + dp[k+1][j]
                        + p[i] × p[k+1] × p[j+1]

                dp[i][j] ← min(dp[i][j], cost)

    return dp[0][n-1]

11. 恢复最优加括号方案

仅使用 dp 可以得到最少乘法次数,但无法直接知道最优括号如何放置。

因此额外使用:

split[i][j]split[i][j]

表示 dp[i][j] 取得最小值时的最优分割位置 k。

11.1 带路径记录的伪代码

MATRIX-CHAIN-ORDER(p)
    n ← length(p) - 1
    dp ← n × n array filled with 0
    split ← n × n array

    for L ← 2 to n
        for i ← 0 to n - L
            j ← i + L - 1
            dp[i][j] ← +∞

            for k ← i to j - 1
                cost ← dp[i][k]
                        + dp[k+1][j]
                        + p[i] × p[k+1] × p[j+1]

                if cost < dp[i][j]
                    dp[i][j] ← cost
                    split[i][j] ← k

    return dp, split

11.2 输出最优括号

PRINT-OPTIMAL-PARENS(split, i, j)
    if i = j
        print "A"i
        return

    k ← split[i][j]

    print "("
    PRINT-OPTIMAL-PARENS(split, i, k)
    PRINT-OPTIMAL-PARENS(split, k+1, j)
    print ")"

初始调用:

PRINT-OPTIMAL-PARENS(split, 0, n-1)

12. 完整 Python 实现

from math import inf


def matrix_chain_order(p: list[int]) -> tuple[int, str]:
    """
    返回:
    1. 最少标量乘法次数
    2. 最优加括号方案

    p 的长度为 n + 1,
    第 i 个矩阵 A_i 的维度为 p[i] × p[i + 1]。
    """
    if len(p) < 2:
        raise ValueError("维度数组 p 至少需要包含两个元素")

    n = len(p) - 1
    dp = [[0] * n for _ in range(n)]
    split = [[-1] * n for _ in range(n)]

    # L 表示矩阵子链长度
    for L in range(2, n + 1):
        for i in range(0, n - L + 1):
            j = i + L - 1
            dp[i][j] = inf

            for k in range(i, j):
                cost = (
                    dp[i][k]
                    + dp[k + 1][j]
                    + p[i] * p[k + 1] * p[j + 1]
                )

                if cost < dp[i][j]:
                    dp[i][j] = cost
                    split[i][j] = k

    def build_parenthesization(i: int, j: int) -> str:
        if i == j:
            return f"A{i}"

        k = split[i][j]
        left = build_parenthesization(i, k)
        right = build_parenthesization(k + 1, j)
        return f"({left}{right})"

    return dp[0][n - 1], build_parenthesization(0, n - 1)


if __name__ == "__main__":
    dimensions = [5, 4, 6, 2]
    min_cost, order = matrix_chain_order(dimensions)

    print("最少标量乘法次数:", min_cost)
    print("最优加括号方式:", order)

预期输出:

最少标量乘法次数: 88
最优加括号方式: (A0(A1A2))

13. 具体示例

设:

p=[5,4,6,2]p=[5,4,6,2]

则:

A0:5×4A_0:5\times4 A1:4×6A_1:4\times6 A2:6×2A_2:6\times2

13.1 长度为 1 的区间

dp[0][0]=0dp[0][0]=0 dp[1][1]=0dp[1][1]=0 dp[2][2]=0dp[2][2]=0

13.2 长度为 2 的区间

对于 A0A1A_0A_1:

dp[0][1]=5×4×6=120dp[0][1] = 5\times4\times6 = 120

对于 A1A2A_1A_2:

dp[1][2]=4×6×2=48dp[1][2] = 4\times6\times2 = 48

13.3 长度为 3 的区间

计算:

dp[0][2]dp[0][2]

在 k=0 处分割

A0(A1A2)A_0(A_1A_2)

总代价:

dp[0][0]+dp[1][2]+p0p1p3dp[0][0] + dp[1][2] + p_0p_1p_3 =0+48+5×4×2= 0+48+5\times4\times2 =88=88

在 k=1 处分割

(A0A1)A2(A_0A_1)A_2

总代价:

dp[0][1]+dp[2][2]+p0p2p3dp[0][1] + dp[2][2] + p_0p_2p_3 =120+0+5×6×2= 120+0+5\times6\times2 =180=180

因此:

dp[0][2]=min⁡(88,180)=88dp[0][2]=\min(88,180)=88

最优方案为:

A0(A1A2)\boxed{ A_0(A_1A_2) }

14. dp 表示例

对于:

p=[5,4,6,2]p=[5,4,6,2]

dp 表为:

dp[i][j]j=0j=1j=2
i=0012088
i=1无效048
i=2无效无效0

最终答案:

dp[0][2]=88dp[0][2]=88

15. 正确性说明

15.1 最优子结构

假设 Ai⋯AjA_i\cdots A_j 的最优方案最后在 k 处分割:

(Ai⋯Ak)(Ak+1⋯Aj)(A_i\cdots A_k)(A_{k+1}\cdots A_j)

那么左半部分 Ai⋯AkA_i\cdots A_k 必须采用最优方案。

否则,如果左半部分存在更小代价的计算方法,用它替换当前左半部分,就能得到一个更优的整体方案,与原方案最优矛盾。

右半部分同理。

因此该问题具有最优子结构。

15.2 完备性

对于区间 [i,j][i,j],最后一次乘法的分割点必然满足:

i≤k<ji\le k<j

算法枚举了所有可能的 k,所以不会遗漏任何加括号方案。

因此,取所有分割方案中的最小值即可得到最优解。


16. 复杂度分析

时间复杂度

三层循环分别枚举:

  1. 区间长度 L;

  2. 区间起点 i;

  3. 分割点 k。

因此:

T(n)=Θ(n3)\boxed{ T(n)=\Theta(n^3) }

空间复杂度

dp 和 split 都是 n×nn\times n 数组:

S(n)=Θ(n2)\boxed{ S(n)=\Theta(n^2) }

恢复括号方案的递归深度最多为:

O(n)O(n)

17. 常见错误

错误 1:把矩阵数量写成

len(p) 正确关系是:

n=len⁡(p)−1n=\operatorname{len}(p)-1

错误 2:错误定义状态

dp[i][j] 不是矩阵 AiA_i 与 AjA_j 相乘的代价,而是整个连续子链:

AiAi+1⋯AjA_iA_{i+1}\cdots A_j

错误 3:写错最后一次乘法的维度

在 0-based 下标下,正确代价为:

pipk+1pj+1p_i p_{k+1} p_{j+1}

不是 pi−1pkpjp_{i-1}p_kp_j。后者对应 1-based 矩阵编号。

错误 4:按普通二维数组顺序填写

区间 DP 必须优先保证短区间已经完成,因此应先枚举区间长度。

错误 5:分割点枚举到

j 分割点范围应为:

i≤k<ji\le k<j

Python 中对应:

for k in range(i, j):

错误 6:认为

dp 存储矩阵计算结果 dp 仅存储最小标量乘法次数。真实矩阵结果不需要保存。


18. 状态设计检查清单

遇到类似动态规划问题时,可以按以下顺序思考:

18.1 原问题缩小后是什么

完整矩阵链:

A0⋯An−1A_0\cdots A_{n-1}

缩小后仍然是连续矩阵子链:

Ai⋯AjA_i\cdots A_j

18.2 需要哪些参数确定子问题

需要起点 i 和终点 j,因此使用二维状态:

dp[i][j]dp[i][j]

18.3 状态保存什么

问题要求最小计算量,所以保存:

最少标量乘法次数\text{最少标量乘法次数}

18.4 如何从更小状态转移

枚举最后一次乘法的分割点 k:

[i,k]+[k+1,j][i,k]+[k+1,j]

18.5 最终答案在哪里

完整区间是:

[0,n−1][0,n-1]

因此答案为:

dp[0][n−1]dp[0][n-1]

19. 一句话记忆

引用

dp[i][j] 表示计算连续矩阵链 Ai⋯AjA_i\cdots A_j 的最少标量乘法次数;枚举最后一次乘法的分割点 k,由左右两个子区间的最优代价加上最后一次矩阵乘法代价完成转移。


20. 参考资料

  • Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms, 3rd Edition. Chapter 15: Dynamic Programming, Section 15.2: Matrix-chain multiplication.

  • MIT Introduction to Algorithms, Dynamic Programming lecture materials.

文章大纲
  1. 矩阵连乘(MCM)动态规划
  2. 1. 问题定义
  3. 2. 输入如何表示
  4. 示例
  5. 3. 为什么这是区间动态规划
  6. 4. dp 数组的状态定义
  7. 5. dp 数组大小与输入的关系
  8. 6. 基本情况
  9. 7. 状态转移的推导
  10. 7.1 左半部分的最优代价
  11. 7.2 右半部分的最优代价
  12. 7.3 两个结果矩阵相乘的代价
  13. 8. 为什么转移式中的维度是 p[i] * p[k+1] * p[j+1]
  14. 9. 计算顺序
  15. 10. 精简伪代码
  16. 10.1 只求最少乘法次数
  17. 11. 恢复最优加括号方案
  18. 11.1 带路径记录的伪代码
  19. 11.2 输出最优括号
  20. 12. 完整 Python 实现
  21. 13. 具体示例
  22. 13.1 长度为 1 的区间
  23. 13.2 长度为 2 的区间
  24. 13.3 长度为 3 的区间
  25. 14. dp 表示例
  26. 15. 正确性说明
  27. 15.1 最优子结构
  28. 15.2 完备性
  29. 16. 复杂度分析
  30. 时间复杂度
  31. 空间复杂度
  32. 17. 常见错误
  33. 18. 状态设计检查清单
  34. 18.1 原问题缩小后是什么
  35. 18.2 需要哪些参数确定子问题
  36. 18.3 状态保存什么
  37. 18.4 如何从更小状态转移
  38. 18.5 最终答案在哪里
  39. 19. 一句话记忆
  40. 20. 参考资料