第7章:记忆的力量(动态规划) 兔狲教授的亲切开场 在探索了启发式搜索的智慧之后,我们面对一个新的问题:如何用记忆加速推理? 当问题具有重叠子结构时,重复计算会造成巨大的浪费。今天,我们探索动态规划的智慧——用记忆避免重复,用空间换时间。 核心议题:记忆如何赋予推理力量? 康乐园的深夜,珠江的水面平静如镜,倒映着满天的星斗。黑石屋书房的灯光依然亮着,小小猪和小海豹正在研究斐波那契数列。 “教授,我写了一个递归函数计算斐波那契数,”小小猪指着屏幕,“fib(40) 要等好久好久!” 小海豹观察着递归树,“fib(40) 调用 fib(39) 和 fib(38),而 fib(39) 又调用 fib(38) 和 fib(37)……这里面有大量的重复计算。
:::info
兔狲教授的亲切开场
在探索了启发式搜索的智慧之后,我们面对一个新的问题:如何用记忆加速推理? 当问题具有重叠子结构时,重复计算会造成巨大的浪费。今天,我们探索动态规划的智慧——用记忆避免重复,用空间换时间。
:::
康乐园的深夜,珠江的水面平静如镜,倒映着满天的星斗。黑石屋书房的灯光依然亮着,小小猪和小海豹正在研究斐波那契数列。
“教授,我写了一个递归函数计算斐波那契数,”小小猪指着屏幕,“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)。”
小小猪问:“记忆化搜索和自底向上递推有什么区别?”
“两种都是动态规划的实现方式,”兔狲教授解释:
记忆化搜索(Top-down with Memoization):
自底向上递推(Bottom-up Tabulation):
“以斐波那契为例,”兔狲教授写出两种实现:
# 记忆化搜索 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背包问题,”兔狲教授在白板上描述问题:
“贪心算法可能失败,但动态规划可以解决,”他写出状态转移方程:
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 时的最大价值……这个方程怎么理解?”
“这是动态规划的关键:状态转移方程,”兔狲教授解释,“它描述了问题的最优子结构:当前状态的最优解可以由之前状态的最优解推导出来。”
:::info
兔狲教授的评语
动态规划的哲学是有记忆的推理:我们不仅思考当前问题,还记住过去的问题及其解决方案。这种记忆能力让我们能够从经验中学习,避免重复犯错。这不仅是算法设计的原则,也是人类智慧的重要特征——我们能从历史中汲取教训。
:::
小海豹总结:“教授,动态规划问题通常需要满足哪些条件?”
兔狲教授在白板上列出三要素:
最优子结构(Optimal Substructure):
重叠子问题(Overlapping Subproblems):
状态转移方程(State Transition Equation):
“当一个问题同时具备这三个特征时,”兔狲教授说,“动态规划就是合适的解决方案。”
兔狲教授打开投影仪,一幅规整的计算图出现在屏幕上。
“这是动态规划的正交计算图,”兔狲教授指着图说,“原问题分解为子问题,子问题的解存储到记忆表中,后续计算可以直接复用,最后组合得到最终解。”
小小猪盯着图,“这个图有循环箭头!子问题可以直接从记忆表获取结果,避免重复计算。”
“正是,”兔狲教授说,“动态规划的核心是记忆流:计算的结果流向记忆表,后续计算从记忆表读取。这种‘计算-存储-复用’的循环,使它能够高效解决复杂问题。”
小海豹仔细观察图的层级结构,“教授,这个图的设计……子问题求解与记忆存储分离,但通过虚线连接表示复用关系。这反映了动态规划的双重特性:计算与记忆。”
“这就是动态规划的优雅之处,”兔狲教授解释,“它将计算过程与记忆结构分离,但又通过状态转移方程紧密联系。这种分离与联系,不仅在算法中重要,在认知科学中也有对应——人类的记忆系统也是计算与存储的复杂互动。”
兔狲教授回到座位,泡了一壶新茶。“让我们总结今天最重要的思想模型:空间与时间的永恒交换。”
他在白板上画出对比表:
| 维度 | 时间优先思维 | 空间优先思维 |
|---|---|---|
| 核心策略 | 用时间换空间(重复计算) | 用空间换时间(存储结果) |
| 计算复杂度 | 可能指数级 | 通常多项式级 |
| 空间复杂度 | 低 | 高 |
| 适用场景 | 问题规模小,空间受限 | 问题规模大,时间受限 |
| 哲学根源 | 节俭主义 | 投资主义 |
“这两种思维不是对立的,”兔狲教授解释,“而是资源分配的两极。聪明的算法设计者知道何时节省空间,何时节省时间。”
小小猪问:“教授,动态规划适用于所有问题吗?”
兔狲教授在白板上列出动态规划的适用场景:
适合动态规划的问题通常具有: 1. **最优子结构**:问题可分解为相似子问题,且子问题最优解可组合为原问题最优解 2. **重叠子问题**:子问题会重复出现,而非各自独立 3. **状态空间可管理**:状态数量在可接受范围内(通常多项式级别) 动态规划主要变体: ├── **经典动态规划**:0-1背包、最长公共子序列、编辑距离 ├── **区间动态规划**:矩阵链乘法、最优二叉搜索树 ├── **树形动态规划**:树上的最大独立集、最小点覆盖 ├── **状态压缩动态规划**:旅行商问题(TSP)的状态压缩 └── **数位动态规划**:统计满足条件的数字个数
“理解这些变体,”兔狲教授说,“帮助我们识别何时可以用动态规划,以及选择哪种动态规划变体。”
:::info
兔狲教授的评语
动态规划的哲学是前瞻性记忆:我们不仅记住过去,还预先计算并存储可能需要的未来结果。这种‘为未来做准备’的思维方式,不仅是高效算法的基础,也是人类规划能力的体现。我们通过想象未来可能的需求,提前准备,从而在真正需要时能够快速响应。
:::
:::info
兔狲教授的总结:动态规划的智慧
"让我们用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}倍")
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}%")
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}")
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) 状态空间可管理吗?如果答案都是肯定的,动态规划很可能就是最佳解决方案。"
算法实现:实现0-1背包问题的动态规划解法。测试不同容量和物品组合,比较动态规划与贪心算法的结果差异。尝试优化空间复杂度到 O(W)。
记忆化实践:用记忆化搜索解决“爬楼梯问题”(每次可以爬1级或2级,到第n级有多少种方法)。比较记忆化搜索与朴素递归的性能差异。
现实观察:观察生活中的“动态规划”思维。超市购物时先列清单再分区购买是动态规划吗?什么时候这种“预先规划”会失败?(提示:考虑清单外特价商品)
思想溯源:“动态规划”(Dynamic Programming)这个词由理查德·贝尔曼在1950年代提出。查阅贝尔曼的原著,了解他为什么选择这个名称(提示:与“数学规划”相关,与“动态”相对“静态”)。
跨学科联系:动态规划与心理学中的“组块化”(Chunking)有什么联系?人类如何通过将复杂任务分解为熟悉组块来提高效率?比较算法中的记忆化与人类的习惯形成。
哲学反思:动态规划体现了哲学中的“实用主义”还是“理性主义”?它更强调经验积累(记忆)还是逻辑推导(状态转移)?
伦理考量:在资源分配问题(如医疗资源分配)中使用动态规划算法可能有什么伦理问题?当算法追求“最优解”时,可能忽略哪些人道考量?
算法比较:动态规划与分治算法、贪心算法有什么根本区别?为什么有些问题适合分治但不适合动态规划?有些适合贪心但不适合动态规划?
创造练习:设计一个“动态规划寓言”——用故事解释动态规划的思想。(提示:可以比喻为建筑师先计算每块砖的承重,再设计整栋大楼)
极限挑战:证明最长公共子序列(LCS)问题具有最优子结构。设计状态转移方程并实现算法。
晨曦初现,珠江的水面泛起淡淡的金色。康乐园的鸟儿开始鸣叫,新的一天即将开始。黑石屋书房的灯光依然温暖,仿佛一个永不熄灭的思想灯塔。
小小猪正在电脑上测试不同动态规划算法的性能,屏幕上显示着各种问题实例和运行时间。小海豹则在白板上推导状态转移方程的一般形式,试图理解那看似复杂的公式背后的统一模式。
“今天我们从‘记忆如何赋予推理力量’开始,”兔狲教授收拾着茶具,“探索了动态规划的智慧,理解了空间与时间的永恒交换。”
小海豹放下粉笔,“教授,动态规划解决了重叠子问题,但如果问题没有重叠子结构,或者状态空间太大呢?”
兔狲教授微笑:“好问题。这时我们需要新的思维工具——近似算法或启发式搜索,或者接受问题的固有难度。”
小小猪抬头,“这就是我们之前学过的内容!原来算法思想是循环上升的。”
“正是,”兔狲教授说,“算法思维不是线性的,而是螺旋上升的。我们不断回到相似的问题,但每次都有新的视角和工具。”
“下一卷,”兔狲教授走向窗边,看着晨光中的康乐园,“我们将离开确定性的宇宙,进入跨越逻辑的断裂带。探索当世界变得模糊、连续、高维时,确定性规则还够用吗?”
窗外,第一缕阳光洒在珠江上,波光粼粼。黑石屋书房里的三个人,就像三个探险家,刚刚完成了一段旅程,又即将踏上新的征程。
小小猪的笔记:我测试了斐波那契数列的不同实现。朴素递归计算 fib(40) 要好几秒,记忆化搜索只要不到1毫秒!空间换时间的效果太惊人了。动态规划就像给算法装上了“记忆外挂”!
小海豹的笔记:查了动态规划的历史,发现贝尔曼最初研究动态规划是为了解决多阶段决策问题。有趣的是,他选择“动态”一词是为了强调决策的时间顺序,“规划”一词则是为了吸引数学规划领域的注意。有时,命名本身就是一种策略。
兔狲教授的结语:记住,动态规划教给我们推理的重要一课:有时候,最快的进步不是向前冲,而是停下来记住已经走过的路。我们慢慢来,理解了最重要。