程序设计与计算思维¶

Computer Programming and Computational Thinking

第 7 讲:动态规划¶

2025—2026学年度春季学期

清华大学 韩文弢

本讲内容¶

本讲将通过以下两个示例问题介绍一种高效的计算方法:动态规划(Dynamic Programming)。

  • 计算斐波那契数列
  • 路径求和问题

动态规划算法将在下一讲的图像裁剪中发挥作用。

准备工作¶

In [1]:
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$ 项?

方法一:朴素递归¶

最简单的实现方法是直接按数学定义公式进行计算。

In [2]:
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。

In [3]:
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$ 的增长会急剧变多,可以通过代码来统计。

In [4]:
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
In [5]:
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()
No description has been provided for this image

重叠子问题¶

从上述统计可以看出,导致计算性能不好的原因是有大量的重复计算。

这些重复计算构成了重叠子问题。

时间复杂度分析:

  • 朴素递归的时间复杂度是指数级的,记作 $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!)$ 阶乘时间 穷举所有排列
渐近复杂度关注的是增长趋势,忽略常数因子和低阶项。例如,$O(2n^2 + 3n + 1)$ 简记为 $O(n^2)$。

方法二:记忆化递归(自顶向下)¶

为了避免重复计算子问题的结果,可以把子问题的结果保存下来,形成记忆化递归方法。

In [6]:
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$ 从小到大依次计算,这样能够将递归改成循环。

In [7]:
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)$
  • 动态规划避免了递归调用,因此不需要栈空间

路径求和问题¶

考虑以下问题:

  • 给定一个矩阵,从上到下沿着一条路径行走。
  • 路径从顶部的某个方格开始,只能向下移动,即东南、正南或西南方向。
  • 将沿途访问的数字相加,目标是找到具有最小和的路径。

image.png

这是一个优化问题:在所有可能的路径集合中,想要找到一条最小化访问方格之和的路径。

方法一:枚举法¶

首先创建一个矩阵,指定它的大小:

In [8]:
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)。

生成所有路径¶

为此,要产生所有路径。

  • 对于层数固定的情况,可以使用多重循环来产生。
  • 对于层数不固定的情况,需要使用递归函数来产生,每次递归确定一层的选择。
In [9]:
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 条路径需要检查。
In [10]:
# 显示几条示例路径
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]

路径求和演示¶

在生成所有路径之后,可以依次计算每条路径上的数字之和,并得出最小的结果。

In [11]:
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

可视化矩阵和路径¶

In [12]:
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)}"
In [13]:
# 可视化某条路径
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()
No description has been provided for this image

枚举法所需要的时间复杂度是 $O(n \cdot 3^n)$,当 $n$ 较大时会非常慢。

思考如何优化?

方法二:动态规划解法¶

固定一个给定的点 $(i, j)$,只关注所有经过 $(i, j)$ 的路径。

In [14]:
# 设置固定点
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
In [15]:
# 可视化固定路径
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()
No description has been provided for this image

假设我们将点固定在倒数第二行(最后一行的前一行)。当我们查看固定值下方的路径时,我们在重复计算相同的内容。反复重新计算这些内容似乎不太合理。当我们把固定点向上移动时,同样的道理也适用。

所以我们不再"向前"计算,而是对每个方格查看其下方的最小值。

找到重叠子问题¶

这个问题的关键点是存在重叠子问题:有些计算不需要重复。

动态规划的思想是记住这些子问题的解,从而在计算速度上获得指数级的提升。

In [16]:
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
In [17]:
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

与暴力解法结果比较: 一致
In [18]:
# 可视化动态规划结果
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()
No description has been provided for this image

性能比较¶

In [19]:
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
  结果验证: 通过

In [20]:
# 可视化性能比较
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()
No description has been provided for this image

本讲小结¶

动态规划的核心思想:

  1. 重叠子问题:问题可以分解为多个子问题,且这些子问题会被重复计算
  2. 最优子结构:问题的最优解包含子问题的最优解
  3. 记忆化:存储子问题的解,避免重复计算

路径求和问题的动态规划解法:

  • 暴力解法:枚举所有路径,时间复杂度 $O(n \cdot 3^n)$
  • 动态规划:自底向上计算,时间复杂度 $O(n^2)$

为什么动态规划更快?¶

  • 暴力解法重复计算了大量子问题
  • 动态规划通过存储中间结果,每个子问题只计算一次
  • 这体现了用空间换时间的策略