动态规划

动态规划的核心是「将大问题拆解为小问题,记录小问题的最优解,避免重复计算」

  1. 最优子结构:整体的最优括号化方案,必然包含其子矩阵链的最优括号化方案(比如 \(A_1 \sim A_3\) 的最优解,必然包含 \(A_1 \sim A_2\)\(A_3\) 的最优解);
  2. 重叠子问题:不同的矩阵链可能包含相同的子矩阵链,动态规划通过存储子问题的最优解,避免了重复计算(比如计算 \(A_1 \sim A_4\) 时,会用到 \(A_1 \sim A_2\)\(A_2 \sim A_4\) 等子问题的结果);
  3. 填表顺序:必须按矩阵链长度从小到大填表,确保计算 \(m[i][j]\) 时,其依赖的 \(m[i][k]\)\(m[k+1][j]\) 已经计算完成。

矩阵链相乘问题

一、问题描述
给定 n 个矩阵:\(A_1, A_2, \dots, A_n\),其中矩阵 \(A_i\) 的维度为 \(p_{i-1} \times p_i\)(即第 \(i\) 个矩阵的行数为 \(p_{i-1}\),列数为 \(p_i\))。矩阵乘法满足结合律,也就是说,对于多个矩阵相乘,不同的括号化顺序不会影响最终结果,但会极大影响乘法运算的总次数。现在需要找到一种最优括号化方案,使得计算所有矩阵相乘的总乘法次数最少。

举个例子,假设有3个矩阵,维度如下:

  • \(A_1: 10 \times 100\)
  • \(A_2: 100 \times 5\)
  • \(A_3: 5 \times 50\)

两种括号化顺序的运算次数对比:

  1. 方案1:\((A_1A_2)A_3\):先计算 \(A_1A_2\),运算次数为 \(10 \times 100 \times 5 = 5000\) 次;再将结果与 \(A_3\) 相乘,运算次数为 \(10 \times 5 \times 50 = 2500\) 次;总次数:\(5000 + 2500 = \boldsymbol{7500}\) 次。
  2. 方案2:\(A_1(A_2A_3)\):先计算 \(A_2A_3\),运算次数为 \(100 \times 5 \times 50 = 25000\) 次;再将 \(A_1\) 与结果相乘,运算次数为 \(10 \times 100 \times 50 = 50000\) 次;总次数:\(25000 + 50000 = \boldsymbol{75000}\) 次。

可见,不同顺序的代价相差10倍,这就是需要寻找最优括号化方案的原因。

递归

暴力递归直接遵循「子问题拆解」思路:计算 \(A_i \sim A_j\) 的最小乘法次数,需枚举所有分割点 \(k\)\(i \le k < j\)),递归计算 \(A_i \sim A_k\)\(A_{k+1} \sim A_j\) 的最小次数,再加上两部分相乘的代价,取所有分割方式的最小值。
本质是「穷举所有括号化顺序」,没有任何优化,会重复计算大量相同子问题。

边界条件与动态规划一致:当 \(i = j\) 时,\(m[i][j] = 0\)(单个矩阵无需乘法)。
与动态规划的状态转移方程完全相同,仅实现方式不同:

\[m(i,j) = \begin{cases} 0 & i = j \\ \min_{i \le k < j} \big\{ m(i,k) + m(k+1,j) + p_{i-1}p_kp_j \big\} & i < j \end{cases}\]

def brute_force_matrix_chain(p, i, j):
    """
    暴力递归求解矩阵链相乘的最小乘法次数
    :param p: 维度数组
    :param i: 矩阵链起始位置(从1开始)
    :param j: 矩阵链结束位置(从1开始)
    :return: 最小乘法次数
    """
    # 边界条件:单个矩阵,无需乘法
    if i == j:
        return 0
    # 初始化最小次数为无穷大
    min_cost = float('inf')
    # 枚举所有分割点k
    for k in range(i, j):
        # 递归计算左半部分、右半部分的最小次数,加上当前分割的代价
        cost = brute_force_matrix_chain(p, i, k) 
			+ brute_force_matrix_chain(p, k+1, j) + p[i-1] * p[k] * p[j]
        # 更新最小次数
        if cost < min_cost:
            min_cost = cost
    return min_cost

# 测试案例
if __name__ == "__main__":
    p = [10, 100, 5, 50]
    # 计算A1~A3的最小乘法次数(i=1, j=3)
    min_cost = brute_force_matrix_chain(p, 1, 3)
    print("暴力递归最小乘法次数:", min_cost)

复杂度分析

  • 时间复杂度:\(\boldsymbol{O(2^n)}\),递归过程中会产生大量重复子问题,比如计算 \(m(1,4)\) 时,会重复计算 \(m(1,2)\)\(m(2,3)\) 等多次,随着n增大,效率急剧下降。
  • 空间复杂度:\(\boldsymbol{O(n)}\),递归调用栈的深度为矩阵链长度n(最坏情况下为n)。

暴力递归仅适用于n极小的场景(如n≤10),n≥15时,递归次数会爆炸式增长,无法在合理时间内得到结果。

递归优化

在递归过程中,用一个二维数组存储已经计算过的子问题结果(\(m[i][j]\)),当再次需要计算该子问题时,直接从二维数组中读取,避免重复计算。它与动态规划的核心思想一致(利用重叠子问题),区别在于:动态规划是「自底向上」填表(从最小子问题开始计算),备忘录递归是「自顶向下」递归(从最大问题开始拆解,遇到子问题先查备二维数组)。

以下面 3 个矩阵(A₁:10×100、A₂:100×5、A₃:5×50)为例
image

  • 根节点:代表核心问题「计算A₁~A₃的最小乘法次数」,需枚举2个分割点(k=1和k=2),对应2个分支;
  • 中间节点:代表子问题(如A₂A₃、A₁A₂),每个子问题同样枚举所有可能的分割点(如A₂~A₃仅能分割k=2);
  • 叶子节点:代表单个矩阵(如A₁A₁、A₂A₂),代价为0,是递归的终止条件;
  • 备忘录递归的核心优化:当子问题(如A₃~A₃) 被多次访问时,仅计算一次,存入备忘录,避免解空间树中重复节点的重复计算(对应树中重复的A₃~A₃节点仅计算1次)。

实现步骤

  1. 初始化一个二维数组,用于存储已计算的 \(m[i][j]\),初始值为-1(表示未计算);
  2. 递归计算 \(m[i][j]\) 时,先检查二维数组:若已计算,直接返回结果;若未计算,按递归公式计算,并存入二维数组;
  3. 递归终止条件与暴力递归、动态规划一致。
def memoization_matrix_chain(p, i, j, memo):
    """
    备忘录递归求解矩阵链相乘的最小乘法次数
    :param p: 维度数组
    :param i: 矩阵链起始位置(从1开始)
    :param j: 矩阵链结束位置(从1开始)
    :param memo: 备忘录数组(二维数组,存储已计算的子问题结果)
    :return: 最小乘法次数
    """
    # 边界条件:单个矩阵,无需乘法
    if i == j:
        return 0
    # 检查备忘录:若已计算,直接返回结果
    if memo[i][j] != -1:
        return memo[i][j]
    # 初始化最小次数为无穷大
    min_cost = float('inf')
    # 枚举所有分割点k
    for k in range(i, j):
        cost = memoization_matrix_chain(p, i, k, memo) 
		+ memoization_matrix_chain(p, k+1, j, memo) + p[i-1] * p[k] * p[j]
        if cost < min_cost:
            min_cost = cost
    # 将计算结果存入备忘录,避免重复计算
    memo[i][j] = min_cost
    return min_cost

# 测试案例
if __name__ == "__main__":
    p = [10, 100, 5, 50]
    n = len(p) - 1
    # 初始化备忘录:(n+1)×(n+1),初始值为-1(索引从1开始)
    memo = [[-1]*(n+1) for _ in range(n+1)]
    min_cost = memoization_matrix_chain(p, 1, 3, memo)
    print("备忘录递归最小乘法次数:", min_cost)

复杂度分析

  • 时间复杂度:\(\boldsymbol{O(n^3)}\),与动态规划一致。每个子问题 \(m[i][j]\) 仅计算一次,共 \(O(n^2)\) 个子问题,每个子问题需枚举 \(O(n)\) 个分割点,总时间为 \(O(n^3)\)
  • 空间复杂度:\(\boldsymbol{O(n^2)}\),主要用于存储备忘录数组,递归调用栈的深度为 \(O(n)\),可忽略不计。

动态规划解法

状态定义:定义两个二维数组,分别存储「最小运算次数」和「最优分割点」:

  • \(m[i][j]\):表示计算矩阵链 \(A_i \sim A_j\)(从第 \(i\) 个矩阵到第 \(j\) 个矩阵)所需的最小乘法次数。
  • \(s[i][j]\):表示计算 \(A_i \sim A_j\) 时的最优分割点 \(k\),即最后一步乘法是在 \(A_k\)\(A_{k+1}\) 之间进行(将 \(A_i \sim A_j\) 拆分为 \(A_i \sim A_k\)\(A_{k+1} \sim A_j\) 两部分)。

边界条件:当矩阵链中只有一个矩阵时(\(i = j\)),不需要进行任何乘法运算,因此:

\[m[i][i] = 0, \quad i = 1, 2, \dots, n \]

状态转移方程

对于矩阵链 \(A_i \sim A_j\)\(i < j\)),需要枚举所有可能的分割点 \(k\)\(i \le k < j\)),计算每种分割方式的总运算次数,取最小值作为 \(m[i][j]\) 的值。
总运算次数由三部分组成:

  • 计算 \(A_i \sim A_k\) 的最小次数:\(m[i][k]\)
  • 计算 \(A_{k+1} \sim A_j\) 的最小次数:\(m[k+1][j]\)
  • 将两部分的结果矩阵相乘的次数:\(p_{i-1} \times p_k \times p_j\)(因为 \(A_i \sim A_k\) 的维度是 \(p_{i-1} \times p_k\)\(A_{k+1} \sim A_j\) 的维度是 \(p_k \times p_j\),矩阵相乘次数为行数×中间数×列数)
    因此,状态转移方程为:

\[m[i][j] = \min_{i \le k < j} \big\{ m[i][k] + m[k+1][j] + p_{i-1}p_kp_j \big\} \]

计算顺序

由于 \(m[i][j]\) 依赖于更小的矩阵链(\(A_i \sim A_k\)\(A_{k+1} \sim A_j\)),因此需要按「矩阵链长度 \(l\)」从小到大计算:

  • 先计算 \(l=2\)(两个矩阵相乘)的所有情况;
  • 再计算 \(l=3\)(三个矩阵相乘)的所有情况;
  • 依次类推,直到计算 \(l=n\)(所有矩阵相乘),最终 \(m[1][n]\) 就是我们要求的最小总运算次数。

示例解法如下:

def matrix_chain(p):
    """
    计算矩阵链相乘的最小乘法次数和最优分割点
    :param p: 维度数组,p[i-1]×p[i]是第i个矩阵的维度
    :return: m(最小次数矩阵), s(最优分割点矩阵)
    """
    n = len(p) - 1  # 矩阵的个数 = 维度数组长度 - 1
    # 初始化m和s矩阵,索引从1开始(符合数学推导中的下标)
    m = [[0] * (n + 1) for _ in range(n + 1)]
    s = [[0] * (n + 1) for _ in range(n + 1)]

    # l为矩阵链长度,从2到n(l=1时为单个矩阵,次数为0,无需计算)
    for l in range(2, n + 1):
        # i为矩阵链的起始位置,范围是1到n-l+1
        for i in range(1, n - l + 2):
            j = i + l - 1  # j为矩阵链的结束位置
            m[i][j] = float('inf')  # 初始化为无穷大,用于后续取最小值
            # 枚举所有可能的分割点k(从i到j-1)
            for k in range(i, j):
                # 计算当前分割方式的总代价
                cost = m[i][k] + m[k+1][j] + p[i-1] * p[k] * p[j]
                # 更新最小代价和最优分割点
                if cost < m[i][j]:
                    m[i][j] = cost
                    s[i][j] = k
    return m, s

def print_optimal(s, i, j):
    """
    递归输出最优括号化方案
    :param s: 最优分割点矩阵
    :param i: 矩阵链起始位置
    :param j: 矩阵链结束位置
    """
    if i == j:
        print(f"A{i}", end="")  # 单个矩阵直接输出
    else:
        print("(", end="")
        # 递归输出左半部分(i到k)
        print_optimal(s, i, s[i][j])
        # 递归输出右半部分(k+1到j)
        print_optimal(s, s[i][j] + 1, j)
        print(")", end="")

# 测试案例(对应文中3个矩阵的例子)
if __name__ == "__main__":
    # 维度数组p:A1(10×100), A2(100×5), A3(5×50)
    p = [10, 100, 5, 50]
    m, s = matrix_chain(p)
    print("最小乘法次数:", m[1][3])
    print("最优括号化方案:", end="")
    print_optimal(s, 1, 3)

时间复杂度:代码中包含三重循环:

  • 外层循环(矩阵链长度 \(l\)):从2到n,共 \(n-1\) 次;
  • 中层循环(起始位置 \(i\)):每次循环次数为 \(n-l+1\),总次数为 \(O(n^2)\)
  • 内层循环(分割点 \(k\)):每次循环次数为 \(j-i = l-1\),总次数为 \(O(n)\)
    因此,总时间复杂度为 \(\boldsymbol{O(n^3)}\)

空间复杂度:使用了两个 \(n \times n\) 的二维数组(\(m\)\(s\)),用于存储最小运算次数和最优分割点,因此空间复杂度为 \(\boldsymbol{O(n^2)}\)

posted @ 2026-05-17 23:01  vonlinee  阅读(9)  评论(0)    收藏  举报