第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 自底向上递推
小小猪问:“记忆化搜索和自底向上递推有什么区别?”
“两种都是动态规划的实现方式,”兔狲教授解释:
记忆化搜索(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背包问题,”兔狲教授在白板上描述问题:
- 有 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 时的最大价值……这个方程怎么理解?”
“这是动态规划的关键:状态转移方程,”兔狲教授解释,“它描述了问题的最优子结构:当前状态的最优解可以由之前状态的最优解推导出来。”
兔狲教授的评语
动态规划的哲学是有记忆的推理:我们不仅思考当前问题,还记住过去的问题及其解决方案。这种记忆能力让我们能够从经验中学习,避免重复犯错。这不仅是算法设计的原则,也是人类智慧的重要特征——我们能从历史中汲取教训。
动态规划的三要素
小海豹总结:“教授,动态规划问题通常需要满足哪些条件?”
兔狲教授在白板上列出三要素:
最优子结构(Optimal Substructure):
- 问题的最优解包含其子问题的最优解
- 例如:背包问题中,前 i 件物品的最优解包含前 i-1 件物品的最优解
重叠子问题(Overlapping Subproblems):
- 不同的子问题会重复出现
- 例如:斐波那契数列中,F(38) 被多次计算
状态转移方程(State Transition Equation):
- 描述如何从子问题的解构造原问题的解
- 这是动态规划的核心,需要创造性设计
“当一个问题同时具备这三个特征时,”兔狲教授说,“动态规划就是合适的解决方案。”
正交计算图:看见动态规划的记忆流
兔狲教授打开投影仪,一幅规整的计算图出现在屏幕上。
“这是动态规划的正交计算图,”兔狲教授指着图说,“原问题分解为子问题,子问题的解存储到记忆表中,后续计算可以直接复用,最后组合得到最终解。”
小小猪盯着图,“这个图有循环箭头!子问题可以直接从记忆表获取结果,避免重复计算。”
“正是,”兔狲教授说,“动态规划的核心是记忆流:计算的结果流向记忆表,后续计算从记忆表读取。这种‘计算-存储-复用’的循环,使它能够高效解决复杂问题。”
小海豹仔细观察图的层级结构,“教授,这个图的设计……子问题求解与记忆存储分离,但通过虚线连接表示复用关系。这反映了动态规划的双重特性:计算与记忆。”
“这就是动态规划的优雅之处,”兔狲教授解释,“它将计算过程与记忆结构分离,但又通过状态转移方程紧密联系。这种分离与联系,不仅在算法中重要,在认知科学中也有对应——人类的记忆系统也是计算与存储的复杂互动。”
思想模型:空间与时间的永恒交换
兔狲教授回到座位,泡了一壶新茶。“让我们总结今天最重要的思想模型:空间与时间的永恒交换。”
他在白板上画出对比表:
| 维度 | 时间优先思维 | 空间优先思维 |
|---|---|---|
| 核心策略 | 用时间换空间(重复计算) | 用空间换时间(存储结果) |
| 计算复杂度 | 可能指数级 | 通常多项式级 |
| 空间复杂度 | 低 | 高 |
| 适用场景 | 问题规模小,空间受限 | 问题规模大,时间受限 |
| 哲学根源 | 节俭主义 | 投资主义 |
“这两种思维不是对立的,”兔狲教授解释,“而是资源分配的两极。聪明的算法设计者知道何时节省空间,何时节省时间。”
动态规划的适用条件与变体
小小猪问:“教授,动态规划适用于所有问题吗?”
兔狲教授在白板上列出动态规划的适用场景:
适合动态规划的问题通常具有:
1. **最优子结构**:问题可分解为相似子问题,且子问题最优解可组合为原问题最优解
2. **重叠子问题**:子问题会重复出现,而非各自独立
3. **状态空间可管理**:状态数量在可接受范围内(通常多项式级别)
动态规划主要变体:
├── **经典动态规划**:0-1背包、最长公共子序列、编辑距离
├── **区间动态规划**:矩阵链乘法、最优二叉搜索树
├── **树形动态规划**:树上的最大独立集、最小点覆盖
├── **状态压缩动态规划**:旅行商问题(TSP)的状态压缩
└── **数位动态规划**:统计满足条件的数字个数“理解这些变体,”兔狲教授说,“帮助我们识别何时可以用动态规划,以及选择哪种动态规划变体。”
兔狲教授的评语
动态规划的哲学是前瞻性记忆:我们不仅记住过去,还预先计算并存储可能需要的未来结果。这种‘为未来做准备’的思维方式,不仅是高效算法的基础,也是人类规划能力的体现。我们通过想象未来可能的需求,提前准备,从而在真正需要时能够快速响应。
关键要点
兔狲教授的总结:动态规划的智慧
- 动态规划核心框架:通过最优子结构、重叠子问题、状态转移方程三要素,用空间换时间,将指数复杂度降为多项式复杂度,体现"记忆赋能推理"的深刻智慧
- 两种实现范式:记忆化搜索(自顶向下,懒惰计算)与自底向上递推(积极计算)各有适用场景,理解其差异有助于选择合适实现方式
- 经典问题建模:0-1背包问题的状态转移方程展示了动态规划如何形式化复杂决策过程,斐波那契数列的优化展示了记忆化如何消除重复计算
- 空间与时间的交换:动态规划体现了计算机科学的基本权衡——用存储空间换取计算时间,这种资源交换思维是算法设计的重要范式
- 应用智慧:在序列比对、资源分配、路径规划等现实问题中,动态规划往往是最优解决方案,掌握状态设计与转移方程构建是算法思维的关键能力
代码实践:动态规划的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背包问题:动态规划经典案例
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毫秒!空间换时间的效果太惊人了。动态规划就像给算法装上了“记忆外挂”!
小海豹的笔记:查了动态规划的历史,发现贝尔曼最初研究动态规划是为了解决多阶段决策问题。有趣的是,他选择“动态”一词是为了强调决策的时间顺序,“规划”一词则是为了吸引数学规划领域的注意。有时,命名本身就是一种策略。
兔狲教授的结语:记住,动态规划教给我们推理的重要一课:有时候,最快的进步不是向前冲,而是停下来记住已经走过的路。我们慢慢来,理解了最重要。
