Alpha内测版本警告:此为早期内部构建版本,尚不完整且可能存在错误,欢迎大家提Issue反馈问题或建议。
Skip to content

第7章:记忆的力量(动态规划)

兔狲教授的亲切开场
在探索了启发式搜索的智慧之后,我们面对一个新的问题:如何用记忆加速推理? 当问题具有重叠子结构时,重复计算会造成巨大的浪费。今天,我们探索动态规划的智慧——用记忆避免重复,用空间换时间。


核心议题:记忆如何赋予推理力量?

康乐园的深夜,珠江的水面平静如镜,倒映着满天的星斗。黑石屋书房的灯光依然亮着,小小猪和小海豹正在研究斐波那契数列。

“教授,我写了一个递归函数计算斐波那契数,”小小猪指着屏幕,“fib(40) 要等好久好久!”

小海豹观察着递归树,“fib(40) 调用 fib(39) 和 fib(38),而 fib(39) 又调用 fib(38) 和 fib(37)……这里面有大量的重复计算。”

兔狲教授走过来,微笑道:“你们发现了一个关键问题:重叠子问题。当同一个子问题被重复计算多次时,计算时间会爆炸式增长。今天我们要探索的核心议题是:记忆如何赋予推理力量? 更具体地说:如何用存储中间结果的方式,避免重复计算,从而大幅提升效率?

他走到白板前,写下两个词:

重复计算 —— 记忆化搜索

“动态规划的核心思想是记住已经计算过的结果,”兔狲教授解释,“这样,当再次需要相同结果时,可以直接查表,而不是重新计算。这种‘用空间换时间’的策略,是算法设计中的重要范式。”

动态规划:从斐波那契到背包问题

“让我们从斐波那契数列开始,”兔狲教授在白板上写出递归定义:

F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2)  (n ≥ 2)

“朴素递归的时间复杂度是指数级的 O(2ⁿ),”他说,“但如果我们用记忆化搜索(Memoization),复杂度可以降到 O(n)。”

记忆化搜索 vs 自底向上递推

小小猪问:“记忆化搜索和自底向上递推有什么区别?”

“两种都是动态规划的实现方式,”兔狲教授解释:

  1. 记忆化搜索(Top-down with Memoization)

    • 从原问题出发,递归分解
    • 计算子问题时,先查表,如果已计算则直接返回
    • 否则计算并存储结果
    • 适合子问题结构不明确的情况
  2. 自底向上递推(Bottom-up Tabulation)

    • 从最小子问题开始,逐步构建到原问题
    • 通常使用数组或表格存储结果
    • 适合子问题依赖关系清晰的情况

“以斐波那契为例,”兔狲教授写出两种实现:

python
# 记忆化搜索
memo = {}
def fib_memo(n):
    if n in memo: return memo[n]
    if n <= 1: return n
    memo[n] = fib_memo(n-1) + fib_memo(n-2)
    return memo[n]

# 自底向上递推
def fib_tab(n):
    if n <= 1: return n
    dp = [0] * (n+1)
    dp[1] = 1
    for i in range(2, n+1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

小海豹思考:“记忆化搜索更像‘懒惰计算’,自底向上递推更像‘积极计算’。”

“精辟的比喻,”兔狲教授赞许道,“两种方法各有适用场景,但核心思想相同:存储子问题的解,避免重复计算。”

背包问题:动态规划的经典案例

“现在看一个更复杂的问题:0-1背包问题,”兔狲教授在白板上描述问题:

  • 有 n 件物品,重量 wᵢ,价值 vᵢ
  • 背包容量为 W
  • 选择物品使得总价值最大,总重量不超过 W

“贪心算法可能失败,但动态规划可以解决,”他写出状态转移方程:

dp[i][c] = max(dp[i-1][c], dp[i-1][c-wᵢ] + vᵢ)  (如果 c ≥ wᵢ)
dp[i][c] = dp[i-1][c]                          (如果 c < wᵢ)

小小猪盯着方程,“dp[i][c] 表示考虑前 i 件物品,容量为 c 时的最大价值……这个方程怎么理解?”

“这是动态规划的关键:状态转移方程,”兔狲教授解释,“它描述了问题的最优子结构:当前状态的最优解可以由之前状态的最优解推导出来。”

兔狲教授的评语
动态规划的哲学是有记忆的推理:我们不仅思考当前问题,还记住过去的问题及其解决方案。这种记忆能力让我们能够从经验中学习,避免重复犯错。这不仅是算法设计的原则,也是人类智慧的重要特征——我们能从历史中汲取教训。

动态规划的三要素

小海豹总结:“教授,动态规划问题通常需要满足哪些条件?”

兔狲教授在白板上列出三要素:

  1. 最优子结构(Optimal Substructure):

    • 问题的最优解包含其子问题的最优解
    • 例如:背包问题中,前 i 件物品的最优解包含前 i-1 件物品的最优解
  2. 重叠子问题(Overlapping Subproblems):

    • 不同的子问题会重复出现
    • 例如:斐波那契数列中,F(38) 被多次计算
  3. 状态转移方程(State Transition Equation):

    • 描述如何从子问题的解构造原问题的解
    • 这是动态规划的核心,需要创造性设计

“当一个问题同时具备这三个特征时,”兔狲教授说,“动态规划就是合适的解决方案。”

正交计算图:看见动态规划的记忆流

兔狲教授打开投影仪,一幅规整的计算图出现在屏幕上。

动态规划正交计算图

“这是动态规划的正交计算图,”兔狲教授指着图说,“原问题分解为子问题,子问题的解存储到记忆表中,后续计算可以直接复用,最后组合得到最终解。”

小小猪盯着图,“这个图有循环箭头!子问题可以直接从记忆表获取结果,避免重复计算。”

“正是,”兔狲教授说,“动态规划的核心是记忆流:计算的结果流向记忆表,后续计算从记忆表读取。这种‘计算-存储-复用’的循环,使它能够高效解决复杂问题。”

小海豹仔细观察图的层级结构,“教授,这个图的设计……子问题求解与记忆存储分离,但通过虚线连接表示复用关系。这反映了动态规划的双重特性:计算与记忆。”

“这就是动态规划的优雅之处,”兔狲教授解释,“它将计算过程记忆结构分离,但又通过状态转移方程紧密联系。这种分离与联系,不仅在算法中重要,在认知科学中也有对应——人类的记忆系统也是计算与存储的复杂互动。”


思想模型:空间与时间的永恒交换

兔狲教授回到座位,泡了一壶新茶。“让我们总结今天最重要的思想模型:空间与时间的永恒交换。”

他在白板上画出对比表:

维度时间优先思维空间优先思维
核心策略用时间换空间(重复计算)用空间换时间(存储结果)
计算复杂度可能指数级通常多项式级
空间复杂度
适用场景问题规模小,空间受限问题规模大,时间受限
哲学根源节俭主义投资主义

“这两种思维不是对立的,”兔狲教授解释,“而是资源分配的两极。聪明的算法设计者知道何时节省空间,何时节省时间。”

动态规划的适用条件与变体

小小猪问:“教授,动态规划适用于所有问题吗?”

兔狲教授在白板上列出动态规划的适用场景:

适合动态规划的问题通常具有:
1. **最优子结构**:问题可分解为相似子问题,且子问题最优解可组合为原问题最优解
2. **重叠子问题**:子问题会重复出现,而非各自独立
3. **状态空间可管理**:状态数量在可接受范围内(通常多项式级别)

动态规划主要变体:
├── **经典动态规划**:0-1背包、最长公共子序列、编辑距离
├── **区间动态规划**:矩阵链乘法、最优二叉搜索树
├── **树形动态规划**:树上的最大独立集、最小点覆盖
├── **状态压缩动态规划**:旅行商问题(TSP)的状态压缩
└── **数位动态规划**:统计满足条件的数字个数

“理解这些变体,”兔狲教授说,“帮助我们识别何时可以用动态规划,以及选择哪种动态规划变体。”

兔狲教授的评语
动态规划的哲学是前瞻性记忆:我们不仅记住过去,还预先计算并存储可能需要的未来结果。这种‘为未来做准备’的思维方式,不仅是高效算法的基础,也是人类规划能力的体现。我们通过想象未来可能的需求,提前准备,从而在真正需要时能够快速响应。


关键要点

兔狲教授的总结:动态规划的智慧

  1. 动态规划核心框架:通过最优子结构、重叠子问题、状态转移方程三要素,用空间换时间,将指数复杂度降为多项式复杂度,体现"记忆赋能推理"的深刻智慧
  2. 两种实现范式:记忆化搜索(自顶向下,懒惰计算)与自底向上递推(积极计算)各有适用场景,理解其差异有助于选择合适实现方式
  3. 经典问题建模:0-1背包问题的状态转移方程展示了动态规划如何形式化复杂决策过程,斐波那契数列的优化展示了记忆化如何消除重复计算
  4. 空间与时间的交换:动态规划体现了计算机科学的基本权衡——用存储空间换取计算时间,这种资源交换思维是算法设计的重要范式
  5. 应用智慧:在序列比对、资源分配、路径规划等现实问题中,动态规划往往是最优解决方案,掌握状态设计与转移方程构建是算法思维的关键能力

代码实践:动态规划的Python实现

"让我们用Python代码来实践动态规划,"兔狲教授说,"代码不仅帮助我们理解记忆化的力量,还能让我们'运行'空间换时间的优化过程。"

斐波那契数列:从指数到线性的飞跃

python
import time

# 朴素递归实现 - 指数时间复杂度 O(2^n)
def fib_recursive(n):
    """朴素递归实现斐波那契数列"""
    if n <= 1:
        return n
    return fib_recursive(n-1) + fib_recursive(n-2)

# 记忆化搜索实现 - 线性时间复杂度 O(n)
def fib_memoization(n, memo=None):
    """记忆化搜索实现斐波那契数列"""
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fib_memoization(n-1, memo) + fib_memoization(n-2, memo)
    return memo[n]

# 自底向上递推实现 - 线性时间复杂度 O(n)
def fib_tabulation(n):
    """自底向上递推实现斐波那契数列"""
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

# 空间优化版本 - 只用O(1)空间
def fib_optimized(n):
    """空间优化的斐波那契数列实现"""
    if n <= 1:
        return n
    prev, curr = 0, 1
    for _ in range(2, n + 1):
        prev, curr = curr, prev + curr
    return curr

# 性能对比测试
print("斐波那契数列不同实现性能对比:")
n = 35  # 测试n=35

start = time.time()
result_recursive = fib_recursive(n)
time_recursive = time.time() - start
print(f"朴素递归: fib({n}) = {result_recursive}, 时间: {time_recursive:.4f}秒")

start = time.time()
result_memo = fib_memoization(n)
time_memo = time.time() - start
print(f"记忆化搜索: fib({n}) = {result_memo}, 时间: {time_memo:.6f}秒")

start = time.time()
result_tab = fib_tabulation(n)
time_tab = time.time() - start
print(f"自底向上递推: fib({n}) = {result_tab}, 时间: {time_tab:.6f}秒")

start = time.time()
result_opt = fib_optimized(n)
time_opt = time.time() - start
print(f"空间优化版本: fib({n}) = {result_opt}, 时间: {time_opt:.6f}秒")

print(f"\n记忆化搜索比朴素递归快 {time_recursive/time_memo:.0f}倍")

0-1背包问题:动态规划经典案例

python
def knapsack_01(values, weights, capacity):
    """0-1背包问题的动态规划解法
    
    问题描述:
        给定n个物品,每个物品有重量weight和价值value。
        有一个容量为C的背包。
        每个物品要么完整放入背包(1),要么不放入(0)。
        求在不超过背包容量的前提下,能装入物品的最大总价值。
    
    算法思路(动态规划):
        定义状态:dp[i][c] 表示考虑前i个物品,背包容量为c时的最大价值。
        
        状态转移方程:
        1. 如果不拿第i个物品:dp[i][c] = dp[i-1][c]
        2. 如果拿第i个物品(且重量不超过容量):
           dp[i][c] = dp[i-1][c-weights[i-1]] + values[i-1]
        3. 取两者中的最大值
        
        边界条件:
        - dp[0][c] = 0(没有物品时价值为0)
        - dp[i][0] = 0(容量为0时价值为0)
        
        步骤:
        1. 创建(n+1)×(C+1)的DP表
        2. 按物品顺序填充DP表
        3. 可选:通过回溯找出具体选择了哪些物品
    
    关键特点:
        - 最优子结构:问题的最优解包含子问题的最优解
        - 重叠子问题:不同决策路径会重复计算相同子问题
        - 记忆化:DP表避免重复计算
        - 自底向上:从小问题逐步构建大问题的解
    
    动态规划要素:
        1. 状态定义:dp[i][c]
        2. 状态转移方程:max(不拿,拿)
        3. 初始状态:dp[0][c] = 0
        4. 计算顺序:从左到右,从上到下
        5. 最终答案:dp[n][C]
    
    参数:
        values: 物品价值列表,values[i]表示第i个物品的价值
        weights: 物品重量列表,weights[i]表示第i个物品的重量
        capacity: 背包容量(正整数)
        
    返回:
        (最大总价值, 选择的物品索引列表)
    
    时间复杂度: O(n×C),其中n是物品数量,C是背包容量
    空间复杂度: O(n×C) - DP表大小
    
    示例:
        >>> values = [60, 100, 120]
        >>> weights = [10, 20, 30]
        >>> capacity = 50
        >>> knapsack_01(values, weights, capacity)
        (220, [1, 2])  # 最大价值220,选择物品1和2(价值100+120)
        
        解释:
        物品0:价值60,重量10
        物品1:价值100,重量20  
        物品2:价值120,重量30
        最优解:选择物品1和2,总重量20+30=50≤50,总价值100+120=220
    
    注意:
        - 物品重量和价值都应为非负整数
        - 如果容量很大,时间复杂度可能很高
        - 可以使用空间优化将空间复杂度降为O(C)
    
    变体问题:
        - 完全背包:每种物品可以拿无限个
        - 多重背包:每种物品有数量限制
        - 分组背包:物品分组,每组最多选一个
        - 依赖背包:物品间有依赖关系
    
    应用场景:
        - 资源分配问题
        - 投资组合优化
        - 切割问题
        - 调度问题
    """
    n = len(values)
    # 创建DP表,dp[i][c]表示前i个物品,容量为c时的最大价值
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    # 填充DP表
    for i in range(1, n + 1):
        for c in range(1, capacity + 1):
            if weights[i-1] <= c:
                # 可以选择拿或不拿当前物品
                dp[i][c] = max(
                    dp[i-1][c],  # 不拿当前物品
                    dp[i-1][c-weights[i-1]] + values[i-1]  # 拿当前物品
                )
            else:
                dp[i][c] = dp[i-1][c]  # 当前物品太重,无法拿
    
    # 可选:回溯找出选择的物品
    selected_items = []
    c = capacity
    for i in range(n, 0, -1):
        if dp[i][c] != dp[i-1][c]:
            selected_items.append(i-1)
            c -= weights[i-1]
    
    return dp[n][capacity], selected_items[::-1]

# 空间优化版本:只用O(W)空间
def knapsack_01_optimized(values, weights, capacity):
    """0-1背包问题的空间优化版本"""
    n = len(values)
    dp = [0] * (capacity + 1)
    
    for i in range(n):
        # 从后向前更新,避免覆盖上一轮的结果
        for c in range(capacity, weights[i] - 1, -1):
            dp[c] = max(dp[c], dp[c - weights[i]] + values[i])
    
    return dp[capacity]

# 背包问题测试
print("\n0-1背包问题测试:")
values = [60, 100, 120]  # 物品价值
weights = [10, 20, 30]   # 物品重量
capacity = 50            # 背包容量

max_value, selected = knapsack_01(values, weights, capacity)
max_value_opt = knapsack_01_optimized(values, weights, capacity)

print(f"物品价值: {values}")
print(f"物品重量: {weights}")
print(f"背包容量: {capacity}")
print(f"最大价值: {max_value}")
print(f"选择的物品索引: {selected}")
print(f"空间优化版本的最大价值: {max_value_opt}")

# 与贪心算法对比
def knapsack_greedy(values, weights, capacity):
    """贪心算法:按价值密度排序"""
    n = len(values)
    items = sorted(range(n), key=lambda i: values[i]/weights[i], reverse=True)
    
    total_value = 0
    remaining_capacity = capacity
    selected_items = []
    
    for i in items:
        if weights[i] <= remaining_capacity:
            total_value += values[i]
            remaining_capacity -= weights[i]
            selected_items.append(i)
    
    return total_value, selected_items

greedy_value, greedy_selected = knapsack_greedy(values, weights, capacity)
print(f"\n贪心算法结果: 价值={greedy_value}, 选择物品={greedy_selected}")
print(f"动态规划比贪心算法好 {(max_value - greedy_value)/greedy_value*100:.1f}%")

最长公共子序列:序列比对的核心

python
def longest_common_subsequence(text1, text2):
    """最长公共子序列(LCS)的动态规划解法
    
    问题描述(LeetCode 1143. 最长公共子序列):
        给定两个字符串text1和text2,返回这两个字符串的最长公共子序列的长度。
        子序列:在不改变字符相对顺序的情况下删除某些字符(也可以不删除)
              形成的新字符串。
        公共子序列:两个字符串都有的子序列。
    
    算法思路(动态规划):
        定义状态:dp[i][j] 表示text1前i个字符和text2前j个字符的LCS长度。
        
        状态转移方程:
        1. 如果text1[i-1] == text2[j-1](当前字符相同):
           dp[i][j] = dp[i-1][j-1] + 1
        2. 如果text1[i-1] != text2[j-1](当前字符不同):
           dp[i][j] = max(dp[i-1][j], dp[i][j-1])
           
        解释:
        - 字符相同:LCS长度加1,两个字符串都向前移动一位
        - 字符不同:取text1少一个字符或text2少一个字符时的LCS最大值
        
        边界条件:
        - dp[0][j] = 0(text1为空字符串)
        - dp[i][0] = 0(text2为空字符串)
    
    关键特点:
        - 经典的双序列动态规划问题
        - 状态定义:二维DP表
        - 状态转移:根据字符是否相同选择不同策略
        - 可以回溯得到具体的LCS字符串
    
    参数:
        text1: 第一个字符串
        text2: 第二个字符串
        
    返回:
        (LCS的长度, LCS字符串)
    
    时间复杂度: O(m×n),其中m=len(text1),n=len(text2)
    空间复杂度: O(m×n) - DP表大小,可优化为O(min(m,n))
    
    示例:
        >>> longest_common_subsequence("abcde", "ace")
        (3, "ace")
        >>> longest_common_subsequence("abc", "abc")
        (3, "abc")
        >>> longest_common_subsequence("abc", "def")
        (0, "")
        
        解释"abcde"和"ace":
        text1: a b c d e
        text2: a   c   e
        LCS:   a   c   e  长度3
    
    注意:
        - LCS不要求连续,这与最长公共子串不同
        - 可能有多个LCS,此实现返回找到的一个
        - 空间可以优化,但优化后无法回溯得到LCS字符串
    
    变体问题:
        - 最长公共子串(要求连续)
        - 最短公共超序列(包含两个序列的最短序列)
        - 编辑距离(LeetCode 72. 编辑距离)
        - 最长递增子序列(LeetCode 300. 最长递增子序列)
    
    应用场景:
        - DNA序列比对(生物信息学)
        - 文件差异比较(git diff)
        - 语音识别
        - 自然语言处理(机器翻译评估)
        - 版本控制系统
    
    扩展:
        - 带权重的LCS:不同字符匹配有不同的权重
        - 多序列LCS:多个字符串的公共子序列
        - 近似匹配:允许一定程度的错配
    """
    m, n = len(text1), len(text2)
    # dp[i][j]表示text1前i个字符和text2前j个字符的LCS长度
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # 填充DP表
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    # 回溯构造LCS字符串
    lcs = []
    i, j = m, n
    while i > 0 and j > 0:
        if text1[i-1] == text2[j-1]:
            lcs.append(text1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    
    return dp[m][n], ''.join(reversed(lcs))

# LCS测试
print("\n最长公共子序列(LCS)测试:")
text1 = "ABCDGH"
text2 = "AEDFHR"
lcs_len, lcs_str = longest_common_subsequence(text1, text2)
print(f"字符串1: {text1}")
print(f"字符串2: {text2}")
print(f"LCS长度: {lcs_len}")
print(f"LCS字符串: {lcs_str}")

# DNA序列比对示例
dna1 = "AGCTAGC"
dna2 = "AGCATC"
dna_lcs_len, dna_lcs_str = longest_common_subsequence(dna1, dna2)
print(f"\nDNA序列比对:")
print(f"DNA1: {dna1}")
print(f"DNA2: {dna2}")
print(f"共同序列长度: {dna_lcs_len}")
print(f"共同序列: {dna_lcs_str}")

爬楼梯问题:状态转移的简单示例

python
def climb_stairs_recursive(n):
    """爬楼梯问题的朴素递归解法"""
    if n <= 2:
        return n
    return climb_stairs_recursive(n-1) + climb_stairs_recursive(n-2)

def climb_stairs_dp(n):
    """爬楼梯问题的动态规划解法
    
    问题描述(LeetCode 70. 爬楼梯):
        假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
        每次你可以爬 1 或 2 个台阶。
        问有多少种不同的方法可以爬到楼顶?
    
    算法思路(动态规划):
        定义状态:dp[i] 表示爬到第i阶楼梯的方法数。
        
        状态转移方程:
        要到达第i阶,可以从第i-1阶爬1阶上来,或者从第i-2阶爬2阶上来。
        所以:dp[i] = dp[i-1] + dp[i-2]
        
        这实际上是斐波那契数列的变体。
        
        边界条件:
        - dp[1] = 1(爬1阶:1种方法)
        - dp[2] = 2(爬2阶:2种方法:1+1或2)
        - 注意:有些定义中dp[0]=1,表示起点有1种方法
    
    关键特点:
        - 经典的最优子结构问题
        - 状态转移简单清晰
        - 可以空间优化到O(1)
        - 与斐波那契数列密切相关
    
    参数:
        n: 楼梯的阶数(正整数)
        
    返回:
        爬到第n阶楼梯的不同方法数
    
    时间复杂度: O(n) - 需要计算n个状态
    空间复杂度: O(n) - DP数组大小,可优化为O(1)
    
    示例:
        >>> climb_stairs_dp(1)
        1
        >>> climb_stairs_dp(2)  
        2  # 方法:1+1 或 2
        >>> climb_stairs_dp(3)
        3  # 方法:1+1+1, 1+2, 2+1
        >>> climb_stairs_dp(4)
        5  # 方法:1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2
    
    注意:
        - 当n较大时,结果可能超过普通整数范围
        - 递归解法时间复杂度为O(2^n),不可行
        - 可以使用矩阵快速幂将时间复杂度降为O(log n)
    
    变体问题:
        - 每次可以爬1、2或3阶
        - 每次可以爬的阶数在一个集合中(如{1, 3, 5})
        - 有障碍的爬楼梯(LeetCode 63. 不同路径 II)
        - 最小花费爬楼梯(LeetCode 746. 使用最小花费爬楼梯)
    
    应用场景:
        - 组合计数问题
        - 路径规划
        - 投资决策(多步决策问题)
    """
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

def climb_stairs_optimized(n):
    """爬楼梯问题的空间优化解法"""
    if n <= 2:
        return n
    prev, curr = 1, 2
    for _ in range(3, n + 1):
        prev, curr = curr, prev + curr
    return curr

# 爬楼梯问题性能对比
print("\n爬楼梯问题性能对比:")
n = 35

start = time.time()
result_recursive = climb_stairs_recursive(n)
time_recursive = time.time() - start

start = time.time()
result_dp = climb_stairs_dp(n)
time_dp = time.time() - start

start = time.time()
result_opt = climb_stairs_optimized(n)
time_opt = time.time() - start

print(f"到第{n}级台阶的不同方法数: {result_dp}")
print(f"朴素递归时间: {time_recursive:.4f}秒")
print(f"动态规划时间: {time_dp:.6f}秒")
print(f"空间优化版本时间: {time_opt:.6f}秒")
print(f"动态规划比朴素递归快 {time_recursive/time_dp:.0f}倍")

"记住,"兔狲教授总结道,"动态规划是有记忆的推理的艺术。我们用代码将这些抽象概念具体化,就能真正理解如何用空间换时间,如何用记忆加速计算。当面对复杂问题时,先问三个问题:1) 有最优子结构吗?2) 有重叠子问题吗?3) 状态空间可管理吗?如果答案都是肯定的,动态规划很可能就是最佳解决方案。"


兔狲教授的思考题

实践探索(适合小小猪)

  1. 算法实现:实现0-1背包问题的动态规划解法。测试不同容量和物品组合,比较动态规划与贪心算法的结果差异。尝试优化空间复杂度到 O(W)。

  2. 记忆化实践:用记忆化搜索解决“爬楼梯问题”(每次可以爬1级或2级,到第n级有多少种方法)。比较记忆化搜索与朴素递归的性能差异。

  3. 现实观察:观察生活中的“动态规划”思维。超市购物时先列清单再分区购买是动态规划吗?什么时候这种“预先规划”会失败?(提示:考虑清单外特价商品)

历史探究(适合小海豹)

  1. 思想溯源:“动态规划”(Dynamic Programming)这个词由理查德·贝尔曼在1950年代提出。查阅贝尔曼的原著,了解他为什么选择这个名称(提示:与“数学规划”相关,与“动态”相对“静态”)。

  2. 跨学科联系:动态规划与心理学中的“组块化”(Chunking)有什么联系?人类如何通过将复杂任务分解为熟悉组块来提高效率?比较算法中的记忆化与人类的习惯形成。

  3. 哲学反思:动态规划体现了哲学中的“实用主义”还是“理性主义”?它更强调经验积累(记忆)还是逻辑推导(状态转移)?

综合思考

  1. 伦理考量:在资源分配问题(如医疗资源分配)中使用动态规划算法可能有什么伦理问题?当算法追求“最优解”时,可能忽略哪些人道考量?

  2. 算法比较:动态规划与分治算法、贪心算法有什么根本区别?为什么有些问题适合分治但不适合动态规划?有些适合贪心但不适合动态规划?

  3. 创造练习:设计一个“动态规划寓言”——用故事解释动态规划的思想。(提示:可以比喻为建筑师先计算每块砖的承重,再设计整栋大楼)

  4. 极限挑战:证明最长公共子序列(LCS)问题具有最优子结构。设计状态转移方程并实现算法。


下一步预告

晨曦初现,珠江的水面泛起淡淡的金色。康乐园的鸟儿开始鸣叫,新的一天即将开始。黑石屋书房的灯光依然温暖,仿佛一个永不熄灭的思想灯塔。

小小猪正在电脑上测试不同动态规划算法的性能,屏幕上显示着各种问题实例和运行时间。小海豹则在白板上推导状态转移方程的一般形式,试图理解那看似复杂的公式背后的统一模式。

“今天我们从‘记忆如何赋予推理力量’开始,”兔狲教授收拾着茶具,“探索了动态规划的智慧,理解了空间与时间的永恒交换。”

小海豹放下粉笔,“教授,动态规划解决了重叠子问题,但如果问题没有重叠子结构,或者状态空间太大呢?”

兔狲教授微笑:“好问题。这时我们需要新的思维工具——近似算法启发式搜索,或者接受问题的固有难度。”

小小猪抬头,“这就是我们之前学过的内容!原来算法思想是循环上升的。”

“正是,”兔狲教授说,“算法思维不是线性的,而是螺旋上升的。我们不断回到相似的问题,但每次都有新的视角和工具。”

“下一卷,”兔狲教授走向窗边,看着晨光中的康乐园,“我们将离开确定性的宇宙,进入跨越逻辑的断裂带。探索当世界变得模糊、连续、高维时,确定性规则还够用吗?”

窗外,第一缕阳光洒在珠江上,波光粼粼。黑石屋书房里的三个人,就像三个探险家,刚刚完成了一段旅程,又即将踏上新的征程。


小小猪的笔记:我测试了斐波那契数列的不同实现。朴素递归计算 fib(40) 要好几秒,记忆化搜索只要不到1毫秒!空间换时间的效果太惊人了。动态规划就像给算法装上了“记忆外挂”!

小海豹的笔记:查了动态规划的历史,发现贝尔曼最初研究动态规划是为了解决多阶段决策问题。有趣的是,他选择“动态”一词是为了强调决策的时间顺序,“规划”一词则是为了吸引数学规划领域的注意。有时,命名本身就是一种策略。

兔狲教授的结语:记住,动态规划教给我们推理的重要一课:有时候,最快的进步不是向前冲,而是停下来记住已经走过的路。我们慢慢来,理解了最重要。