准备工作¶
import time # 使用 time.time() 进行计时
from collections import defaultdict
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import patches # 使用图形绘制模块
plt.rcParams['font.sans-serif'] = ['Noto Sans CJK SC']
计算斐波那契数列¶
斐波那契数列的定义¶
斐波那契数列定义如下:
$$F(1) = 1, \quad F(2) = 1, \quad F(n) = F(n-1) + F(n-2) \text{ 当 } n > 2$$
数列的前几项:1, 1, 2, 3, 5, 8, 13, 21, 34, 55, ...
如何计算斐波那契数列的第 $n$ 项?
方法一:朴素递归¶
最简单的实现方法是直接按数学定义公式进行计算。
def fib_naive(n):
"""朴素的递归算法"""
if n <= 2:
return 1
return fib_naive(n - 1) + fib_naive(n - 2)
print("朴素递归结果:")
for i in range(1, 11):
print(f"F({i}) = {fib_naive(i)}")
朴素递归结果: F(1) = 1 F(2) = 1 F(3) = 2 F(4) = 3 F(5) = 5 F(6) = 8 F(7) = 13 F(8) = 21 F(9) = 34 F(10) = 55
这里,fib_naive 函数中又调用了该函数本身。
递归与调用栈¶
当函数调用自身时,程序需要记住每一层调用的状态(参数值、局部变量、返回位置等)。这是通过调用栈(Call Stack)来实现的。
栈的特点:
- 栈是一种后进先出(LIFO, Last In First Out)的数据结构
- 每次函数调用时,会在栈顶压入一个新的栈帧(Stack Frame)
- 函数返回时,栈帧从栈顶弹出
以计算 fib_naive(5) 为例:
fib_naive(5) 调用 fib_naive(4) 和 fib_naive(3)
└─ fib_naive(4) 调用 fib_naive(3) 和 fib_naive(2)
└─ fib_naive(3) 调用 fib_naive(2) 和 fib_naive(1)
└─ fib_naive(2) 返回 1(终止条件)
└─ fib_naive(1) 返回 1(终止条件)
调用栈的变化(简化示意):
调用 fib_naive(5): [fib_naive(5)]
调用 fib_naive(4): [fib_naive(5), fib_naive(4)]
调用 fib_naive(3): [fib_naive(5), fib_naive(4), fib_naive(3)]
调用 fib_naive(2): [fib_naive(5), fib_naive(4), fib_naive(3), fib_naive(2)]
fib_naive(2) 返回: [fib_naive(5), fib_naive(4), fib_naive(3)]
...依此类推
栈溢出:如果递归层次太深(没有终止条件或终止条件不合理),栈空间会被耗尽,导致 RecursionError。
print("朴素递归计算大数所需时间:")
for n in [20, 25, 30, 35, 40]:
start = time.time()
result = fib_naive(n)
elapsed = time.time() - start
print(f"F({n}) = {result}, 耗时: {elapsed:.3f}秒")
朴素递归计算大数所需时间: F(20) = 6765, 耗时: 0.000秒 F(25) = 75025, 耗时: 0.003秒 F(30) = 832040, 耗时: 0.039秒 F(35) = 9227465, 耗时: 0.421秒 F(40) = 102334155, 耗时: 4.654秒
朴素递归的效率分析¶
朴素递归当规模变大时,速度明显变慢。下面分析原因。
例如计算 F(5) 时:
- F(5) 需要 F(4) 和 F(3)
- F(4) 需要 F(3) 和 F(2)
- F(3) 被重复计算了两次
这种重复计算随着 $n$ 的增长会急剧变多,可以通过代码来统计。
def count_fib(n, display=False):
def count_calls(n, calls_counter):
"""统计每个 F(k) 被调用的次数"""
calls_counter[n] += 1
if n <= 2:
return 1
return count_calls(n - 1, calls_counter) + count_calls(n - 2, calls_counter)
# 统计 F(n) 的调用次数
calls_counter = defaultdict(int)
result = count_calls(n, calls_counter)
total = sum(calls_counter.values())
if display:
print(f"计算 F({n}) 时各函数被调用的次数:")
for k, counter in calls_counter.items():
print(f"F({k}) 被调用了 {counter} 次")
print(f"总调用次数: {total}")
return total
count_fib(10, True); # 结尾的分号用于忽略前面表达式的值
计算 F(10) 时各函数被调用的次数: F(10) 被调用了 1 次 F(9) 被调用了 1 次 F(8) 被调用了 2 次 F(7) 被调用了 3 次 F(6) 被调用了 5 次 F(5) 被调用了 8 次 F(4) 被调用了 13 次 F(3) 被调用了 21 次 F(2) 被调用了 34 次 F(1) 被调用了 21 次 总调用次数: 109
x = range(1, 21)
y = [count_fib(i) for i in x]
fig, ax = plt.subplots()
ax.plot(x, y)
ax.set_title('调用总次数的增长速度')
ax.set_xlabel('n')
ax.set_ylabel('调用总次数')
#ax.set_yscale('log') # 使用对数的 y 坐标轴
ax.grid(alpha=0.3)
plt.show()
重叠子问题¶
从上述统计可以看出,导致计算性能不好的原因是有大量的重复计算。
这些重复计算构成了重叠子问题。
时间复杂度分析:
- 朴素递归的时间复杂度是指数级的,记作 $O(\varphi^n)$,其中 $\varphi = \frac{1 + \sqrt{5}}{2} \approx 1.618$(黄金分割比)
- 这意味着计算 $F(n)$ 所需的时间随 $n$ 指数增长
- 例如,$F(40)$ 需要约 $1.618^{40} \approx 10^8$ 次递归调用
空间复杂度分析:
- 空间复杂度为 $O(n)$,因为递归深度最多为 $n$ 层
- 每一层递归调用占用一个栈帧
为什么指数时间是不可接受的?
- 当 $n$ 从 40 增加到 50,计算时间增加约 $\varphi^{10} \approx 123$ 倍
- 当 $n = 50$ 时,朴素递归需要数分钟甚至更长时间
- 指数时间算法在实际应用中通常不可用
渐近复杂度¶
为了衡量算法效率,我们使用渐近复杂度(Asymptotic Complexity)来描述算法运行时间(或空间需求)随输入规模增长的变化趋势。
大 O 记号:$O(f(n))$ 表示算法在最坏情况下的时间(或空间)上界。
常见的复杂度类别(从快到慢):
| 复杂度 | 名称 | 示例 |
|---|---|---|
| $O(1)$ | 常数时间 | 访问数组元素 |
| $O(\log n)$ | 对数时间 | 二分查找 |
| $O(n)$ | 线性时间 | 遍历数组 |
| $O(n \log n)$ | 线性对数时间 | 归并排序 |
| $O(n^2)$ | 平方时间 | 冒泡排序 |
| $O(2^n)$ | 指数时间 | 穷举所有子集 |
| $O(n!)$ | 阶乘时间 | 穷举所有排列 |
方法二:记忆化递归(自顶向下)¶
为了避免重复计算子问题的结果,可以把子问题的结果保存下来,形成记忆化递归方法。
def fib_memo(n, memo=None):
"""
记忆化递归实现斐波那契数列
使用字典存储已计算的结果
"""
if memo is None:
memo = {}
# 如果已经计算过,直接返回
if n in memo:
return memo[n]
# 基础情况
if n <= 2:
return 1
# 计算并存储
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
print("记忆化递归结果:")
for i in [20, 25, 30, 35, 40]:
start = time.time()
result = fib_memo(i)
elapsed = time.time() - start
print(f"F({i}) = {result}, 耗时: {elapsed:.3f}秒")
记忆化递归结果: F(20) = 6765, 耗时: 0.000秒 F(25) = 75025, 耗时: 0.000秒 F(30) = 832040, 耗时: 0.000秒 F(35) = 9227465, 耗时: 0.000秒 F(40) = 102334155, 耗时: 0.000秒
速度提升的原因:每个 F(k) 只计算一次并记录在 memo 中,以后需要时直接从 memo 中读取。
- 空间复杂度(额外占用的存储空间):与 $n$ 的大小成正比,记为 $O(n)$
- 时间复杂度:$O(n)$(从指数降到线性)
动态规划的核心思想:每个子问题只计算一次,保存结果供后续使用。
方法三:动态规划(自底向上)¶
斐波那契数列计算时,可以严格按照 $n$ 从小到大依次计算,这样能够将递归改成循环。
def fib_dp(n):
"""
动态规划实现斐波那契数列
自底向上构建解
"""
# f[i] 存储 F(i)
f = [0] * (n + 1)
f[1] = 1
f[2] = 1
# 从小到大依次计算
for i in range(3, n + 1):
f[i] = f[i - 1] + f[i - 2]
return f[n]
def fib_dp_optimized(n):
"""
空间优化的动态规划
只存储最近两个值
"""
a, b = 1, 1
for i in range(3, n + 1):
a, b = b, a + b
return b
print("动态规划结果:")
for i in [20, 25, 30, 35, 40]:
start = time.time()
result = fib_dp_optimized(i)
elapsed = time.time() - start
print(f"F({i}) = {result}, 耗时: {elapsed:.3f}秒")
动态规划结果: F(20) = 6765, 耗时: 0.000秒 F(25) = 75025, 耗时: 0.000秒 F(30) = 832040, 耗时: 0.000秒 F(35) = 9227465, 耗时: 0.000秒 F(40) = 102334155, 耗时: 0.000秒
以上算法的复杂度:
- 时间复杂度:$O(n)$
- 空间复杂度:$O(n)$ 或 $O(1)$
方法小结¶
计算斐波那契数列展示了动态规划的核心概念:
| 概念 | 说明 |
|---|---|
| 重叠子问题 | F(k) 被多次计算,这是效率低下的根源 |
| 记忆化/存储 | 保存已计算的结果,避免重复计算 |
| 用空间换时间 | 额外的存储空间带来时间的节省 |
| 自底向上 | 从基础情况开始,逐步构建更大问题的解 |
三种方法的对比:
| 方法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 朴素递归 | $O(\varphi^n)$ | $O(n)$ | 简单直接,但效率极低 |
| 记忆化递归 | $O(n)$ | $O(n)$ | 自顶向下,自然递归 |
| 动态规划 | $O(n)$ | $O(n)$ 或 $O(1)$ | 自底向上,更高效 |
关于空间复杂度的补充说明¶
朴素递归的空间复杂度:
- 空间复杂度为 $O(n)$,这是因为递归调用深度最多为 $n$ 层
- 每一层递归调用都会在调用栈上创建一个栈帧
- 栈帧存储:参数值、局部变量、返回地址等
- 虽然时间复杂度是指数级,但空间复杂度是线性的
记忆化递归的空间复杂度:
- 递归深度:$O(n)$
- 记忆化存储(memo 字典):$O(n)$
- 总空间复杂度:$O(n)$
动态规划的空间复杂度:
- 基本版本:存储所有中间结果,$O(n)$
- 优化版本:只存储最近两个值,$O(1)$
- 动态规划避免了递归调用,因此不需要栈空间
路径求和问题¶
考虑以下问题:
- 给定一个矩阵,从上到下沿着一条路径行走。
- 路径从顶部的某个方格开始,只能向下移动,即东南、正南或西南方向。
- 将沿途访问的数字相加,目标是找到具有最小和的路径。
这是一个优化问题:在所有可能的路径集合中,想要找到一条最小化访问方格之和的路径。
方法一:枚举法¶
首先创建一个矩阵,指定它的大小:
n = 8 # 矩阵大小
# 生成随机矩阵(0-9)
np.random.seed(42) # 设置随机种子以便复现
M = np.random.randint(0, 10, size=(n, n))
print(f"矩阵 M ({n}x{n}):")
print(M)
矩阵 M (8x8): [[6 3 7 4 6 9 2 6] [7 4 3 7 7 2 5 4] [1 7 5 1 4 0 9 5] [8 0 9 2 6 3 8 2] [4 2 6 4 8 6 1 3] [8 1 9 8 9 4 1 3] [6 7 2 0 3 1 7 3] [1 5 5 9 3 5 1 9]]
解决这个问题的一种方法是朴素算法,即枚举所有路径,计算每条路径的和,然后取最小值。这种方法被称为枚举法,也叫做暴力枚举(brute-force)。
def generate_all_paths(n):
"""
生成所有可能的路径
路径用一个列表表示,path[i] 表示第 i 行的列索引
从顶部开始,每一步可以向南、东南或西南移动
"""
# 生成所有可能的路径
all_paths = []
def generate_paths_recursive(row, current_path):
"""递归生成所有路径"""
if row == n:
all_paths.append(current_path[:])
return
if row == 0:
# 第一行可以选择任意列
for col in range(n):
current_path.append(col)
generate_paths_recursive(row + 1, current_path)
current_path.pop()
else:
# 后续行只能选择与上一行列索引差不超过1的列
prev_col = current_path[-1]
for col in range(max(0, prev_col - 1), min(n, prev_col + 2)):
current_path.append(col)
generate_paths_recursive(row + 1, current_path)
current_path.pop()
generate_paths_recursive(0, [])
return all_paths
# 生成所有路径
paths = generate_all_paths(n)
numpaths = len(paths)
print(f"共有 {numpaths} 条路径需要检查。")
共有 11814 条路径需要检查。
# 显示几条示例路径
print("\n前5条路径示例:")
for i, path in enumerate(paths[:5]):
print(f"路径 {i+1}: {path}")
前5条路径示例: 路径 1: [0, 0, 0, 0, 0, 0, 0, 0] 路径 2: [0, 0, 0, 0, 0, 0, 0, 1] 路径 3: [0, 0, 0, 0, 0, 0, 1, 0] 路径 4: [0, 0, 0, 0, 0, 0, 1, 1] 路径 5: [0, 0, 0, 0, 0, 0, 1, 2]
路径求和演示¶
在生成所有路径之后,可以依次计算每条路径上的数字之和,并得出最小的结果。
def path_sum(M, path):
"""计算路径上的和"""
return sum(M[i, path[i]] for i in range(len(path)))
# 找到最小和路径
path_sums = [path_sum(M, p) for p in paths]
winnernum = np.argmin(path_sums)
winner = paths[winnernum]
winnertotal = path_sums[winnernum]
print(f"获胜路径编号: {winnernum + 1}")
print(f"获胜路径: {winner}")
print(f"最小和: {winnertotal}")
获胜路径编号: 10072 获胜路径: [6, 5, 5, 5, 6, 6, 5, 6] 最小和: 11
可视化矩阵和路径¶
def setup_board(M, ax):
"""设置棋盘"""
n = M.shape[0]
# 绘制棋盘格子
for i in range(n):
for j in range(n):
if (i + j) % 2 == 0:
rect = patches.Rectangle((j, n - 1 - i), 1, 1,
linewidth=1, edgecolor='gray',
facecolor='lightcoral', alpha=0.3)
ax.add_patch(rect)
else:
rect = patches.Rectangle((j, n - 1 - i), 1, 1,
linewidth=1, edgecolor='gray',
facecolor='white', alpha=0.3)
ax.add_patch(rect)
# 添加数字
ax.text(j + 0.5, n - 1 - i + 0.5, str(M[i, j]),
ha='center', va='center', fontsize=12, fontweight='bold')
ax.set_xlim(0, n)
ax.set_ylim(0, n)
ax.set_aspect('equal')
ax.axis('off')
def draw_path(ax, path, indices=None, color='blue', linewidth=3):
"""绘制路径"""
n = len(path)
if indices is None:
indices = range(n)
# 绘制路径线
x_coords = [path[i] + 0.5 for i in indices]
y_coords = [n - 1 - i + 0.5 for i in indices]
ax.plot(x_coords, y_coords, color=color, linewidth=linewidth, marker='o',
markersize=10, markerfacecolor='white', markeredgecolor=color)
def path_text(M, path):
"""生成路径文本"""
values = [M[i, path[i]] for i in range(len(path))]
return f"{' + '.join(map(str, values))} = {sum(values)}"
# 可视化某条路径
fig, ax = plt.subplots(figsize=(10, 10))
whichpath = 1
path = paths[whichpath]
setup_board(M, ax)
# 绘制获胜路径(浅色)
draw_path(ax, winner, color='lightcoral', linewidth=5)
# 绘制当前路径
draw_path(ax, path, color='blue', linewidth=3)
ax.set_title(f"当前路径: {path_text(M, path)}\n获胜路径: {path_text(M, winner)}", fontsize=14)
plt.tight_layout()
plt.show()
枚举法所需要的时间复杂度是 $O(n \cdot 3^n)$,当 $n$ 较大时会非常慢。
思考如何优化?
方法二:动态规划解法¶
固定一个给定的点 $(i, j)$,只关注所有经过 $(i, j)$ 的路径。
# 设置固定点
fixi, fixj = 3, 4 # 可以修改这些值
# 找出经过固定点的所有路径
fixedpaths = [p for p in paths if p[fixi] == fixj]
number_of_fixedpaths = len(fixedpaths)
print(f"经过固定点 ({fixi}, {fixj}) 的路径数量 = {number_of_fixedpaths}")
# 选择一条固定路径查看
whichfixedpath = 0 # 可以修改这个值来查看不同的固定路径
经过固定点 (3, 4) 的路径数量 = 2160
# 可视化固定路径
fig, ax = plt.subplots(figsize=(10, 10))
if number_of_fixedpaths > 0:
path = fixedpaths[whichfixedpath % number_of_fixedpaths]
setup_board(M, ax)
# 标记固定点
ax.add_patch(patches.Circle((fixj + 0.5, n - 1 - fixi + 0.5), 0.3,
facecolor='red', edgecolor='red', alpha=0.7))
# 绘制路径(固定点之前用黑色,之后用蓝色)
draw_path(ax, path, indices=range(fixi + 1), color='black', linewidth=3)
draw_path(ax, path, indices=range(fixi, n), color='blue', linewidth=3)
ax.set_title(f"经过固定点 ({fixi}, {fixj}) 的路径\n{path_text(M, path)}", fontsize=14)
else:
setup_board(M, ax)
ax.set_title(f"没有路径经过固定点 ({fixi}, {fixj})", fontsize=14)
plt.tight_layout()
plt.show()
假设我们将点固定在倒数第二行(最后一行的前一行)。当我们查看固定值下方的路径时,我们在重复计算相同的内容。反复重新计算这些内容似乎不太合理。当我们把固定点向上移动时,同样的道理也适用。
所以我们不再"向前"计算,而是对每个方格查看其下方的最小值。
找到重叠子问题¶
这个问题的关键点是存在重叠子问题:有些计算不需要重复。
动态规划的思想是记住这些子问题的解,从而在计算速度上获得指数级的提升。
def dynamic_programming_solve(M):
"""
使用动态规划求解最小路径和问题
从底部向上计算,每个位置存储到达该位置的最小和
"""
n = M.shape[0]
# dp[i][j] 表示从顶到底部经过 (i, j) 的最小和
dp = np.zeros((n, n))
# 第一行:直接是矩阵第一行的值
dp[0, :] = M[0, :]
# 逐行向下计算
for i in range(1, n):
for j in range(n):
# 可以来自上一行的 j-1, j, j+1 位置
possible_sources = []
if j > 0:
possible_sources.append(dp[i-1, j-1])
possible_sources.append(dp[i-1, j])
if j < n - 1:
possible_sources.append(dp[i-1, j+1])
dp[i, j] = M[i, j] + min(possible_sources)
return dp
# 运行动态规划
dp_result = dynamic_programming_solve(M)
print("动态规划结果矩阵(每个位置的最小和):")
print(dp_result)
print(f"\n最小和: {dp_result[-1, :].min()}")
动态规划结果矩阵(每个位置的最小和): [[ 6. 3. 7. 4. 6. 9. 2. 6.] [10. 7. 6. 11. 11. 4. 7. 6.] [ 8. 13. 11. 7. 8. 4. 13. 11.] [16. 8. 16. 9. 10. 7. 12. 13.] [12. 10. 14. 13. 15. 13. 8. 15.] [18. 11. 19. 21. 22. 12. 9. 11.] [17. 18. 13. 19. 15. 10. 16. 12.] [18. 18. 18. 22. 13. 15. 11. 21.]] 最小和: 11.0
def reconstruct_path(M, dp):
"""
从动态规划结果中重建最小路径
"""
n = M.shape[0]
path = []
# 从最后一行最小值开始
j = np.argmin(dp[-1, :])
path.append(j)
# 向上回溯
for i in range(n - 2, -1, -1):
# 检查可能的来源
candidates = [(j, dp[i, j])]
if j > 0:
candidates.append((j - 1, dp[i, j - 1]))
if j < n - 1:
candidates.append((j + 1, dp[i, j + 1]))
# 选择最小值对应的列
j = min(candidates, key=lambda x: x[1])[0]
path.append(j)
path.reverse()
return path
# 重建路径
dp_path = reconstruct_path(M, dp_result)
print(f"动态规划找到的最小路径: {dp_path}")
print(f"路径和: {path_sum(M, dp_path)}")
print(f"\n与暴力解法结果比较: {'一致' if dp_path == winner else '路径不同但和相同'}")
动态规划找到的最小路径: [np.int64(6), np.int64(5), np.int64(5), np.int64(5), np.int64(6), np.int64(6), np.int64(5), np.int64(6)] 路径和: 11 与暴力解法结果比较: 一致
# 可视化动态规划结果
fig, axes = plt.subplots(1, 2, figsize=(16, 8))
# 左图:原始矩阵和路径
setup_board(M, axes[0])
draw_path(axes[0], dp_path, color='green', linewidth=3)
axes[0].set_title(f"动态规划找到的最小路径\n{path_text(M, dp_path)}", fontsize=12)
# 右图:动态规划结果矩阵
im = axes[1].imshow(dp_result, cmap='YlOrRd', aspect='equal')
axes[1].set_title('动态规划结果矩阵\n(颜色越深表示和越大)', fontsize=12)
# 在右图上标注数字
for i in range(n):
for j in range(n):
text_color = 'white' if dp_result[i, j] > dp_result.max() * 0.5 else 'black'
axes[1].text(j, i, f'{int(dp_result[i, j])}',
ha='center', va='center', color=text_color, fontsize=10)
# 标记最小路径
for i, j in enumerate(dp_path):
rect = patches.Rectangle((j - 0.5, i - 0.5), 1, 1,
linewidth=3, edgecolor='green', facecolor='none')
axes[1].add_patch(rect)
plt.colorbar(im, ax=axes[1], label='最小和')
plt.tight_layout()
plt.show()
性能比较¶
import time
def compare_performance(sizes=[5, 8, 10, 12, 15]):
"""
比较暴力解法和动态规划的性能
"""
results = []
for size in sizes:
np.random.seed(42)
test_M = np.random.randint(0, 10, size=(size, size))
# 暴力解法
start = time.time()
test_paths = generate_all_paths(size)
brute_min = min(path_sum(test_M, p) for p in test_paths)
brute_time = time.time() - start
num_paths = len(test_paths)
# 动态规划
start = time.time()
dp_result = dynamic_programming_solve(test_M)
dp_min = dp_result[-1, :].min()
dp_time = time.time() - start
results.append({
'size': size,
'num_paths': num_paths,
'brute_time': brute_time,
'dp_time': dp_time,
'brute_min': brute_min,
'dp_min': dp_min,
'verified': brute_min == dp_min
})
print(f"矩阵大小 {size}x{size}:")
print(f" 路径数量: {num_paths:,}")
print(f" 暴力解法时间: {brute_time:.4f}s")
print(f" 动态规划时间: {dp_time:.6f}s")
print(f" 加速比: {brute_time/dp_time:.1f}x")
print(f" 结果验证: {'通过' if brute_min == dp_min else '失败'}")
print()
return results
results = compare_performance()
矩阵大小 5x5: 路径数量: 259 暴力解法时间: 0.0003s 动态规划时间: 0.000024s 加速比: 11.4x 结果验证: 通过 矩阵大小 8x8: 路径数量: 11,814 暴力解法时间: 0.0152s 动态规划时间: 0.000052s 加速比: 290.5x 结果验证: 通过 矩阵大小 10x10: 路径数量: 136,946 暴力解法时间: 0.2129s 动态规划时间: 0.000083s 加速比: 2566.4x 结果验证: 通过 矩阵大小 12x12: 路径数量: 1,515,926 暴力解法时间: 2.6294s 动态规划时间: 0.000111s 加速比: 23666.4x 结果验证: 通过 矩阵大小 15x15: 路径数量: 52,694,573 暴力解法时间: 109.9546s 动态规划时间: 0.000157s 加速比: 699822.3x 结果验证: 通过
# 可视化性能比较
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
sizes = [r['size'] for r in results]
num_paths = [r['num_paths'] for r in results]
brute_times = [r['brute_time'] for r in results]
dp_times = [r['dp_time'] * 1000 for r in results] # 转换为毫秒
# 左图:路径数量(指数增长)
axes[0].semilogy(sizes, num_paths, 'bo-', linewidth=2, markersize=8)
axes[0].set_xlabel('矩阵大小 (n)', fontsize=12)
axes[0].set_ylabel('路径数量', fontsize=12)
axes[0].set_title('路径数量随矩阵大小增长\n(指数级增长)', fontsize=12)
axes[0].grid(True, alpha=0.3)
# 右图:时间比较
x = np.arange(len(sizes))
width = 0.35
bars1 = axes[1].bar(x - width/2, brute_times, width, label='暴力解法', color='coral')
bars2 = axes[1].bar(x + width/2, dp_times, width, label='动态规划 (ms)', color='seagreen')
axes[1].set_xlabel('矩阵大小', fontsize=12)
axes[1].set_ylabel('运行时间 (秒/毫秒)', fontsize=12)
axes[1].set_title('运行时间比较', fontsize=12)
axes[1].set_xticks(x)
axes[1].set_xticklabels([f'{s}×{s}' for s in sizes])
axes[1].legend()
axes[1].set_yscale('log')
axes[1].grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.show()