记忆化搜索:从斐波那契数列到动态规划
一、前言:为什么要学习记忆化搜索?
在编程的世界里,我们经常会遇到需要重复计算相同问题的情况。想象一下,如果你每次做数学题时都要重新计算1+1等于多少,那会多么浪费时间啊!记忆化搜索就是一种"聪明"的编程技巧,它让计算机能够记住已经计算过的结果,避免重复劳动。
这种方法特别适合解决:
- 需要递归求解的问题
- 具有重叠子问题特性的问题
- 问题可以被分解为更小的相同子问题
通过本文,即使你是编程新手,也能轻松掌握这个强大的技术!
二、斐波那契数列:从简单递归到记忆化优化
1. 什么是斐波那契数列?
斐波那契数列是一个非常有趣的数学序列,它的定义很简单:
- 第0项是0
- 第1项是1
- 从第2项开始,每一项都等于前两项之和
用数学表达式表示就是:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) (当n≥2时)
这个数列看起来是这样的:0, 1, 1, 2, 3, 5, 8, 13, 21, 34…
2. 递归求解的实现(C++版)
#include <iostream>
using namespace std;
int fibonacci(int n) {
if (n <= 1) {
return n;
}
return fibonacci(n-1) + fibonacci(n-2);
}
int main() {
int n = 10;
cout << "斐波那契数列第" << n << "项是:" << fibonacci(n) << endl;
return 0;
}
这个实现看起来简洁明了,但它有一个严重的问题——效率极低!
3. 为什么简单递归效率低?
让我们画一个调用图来看看计算fib(5)时发生了什么:
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \
fib(2) fib(1)
可以看到,fib(3)被计算了2次,fib(2)被计算了3次。随着n的增大,重复计算的次数会呈指数级增长!
时间复杂度分析:
- 每次调用会产生2个新的调用
- 对于n,调用次数大约是2^n
- 所以时间复杂度是O(2^n) —— 这太可怕了!
4. 优化思路:记住已经计算过的结果
既然问题在于重复计算,那么很自然的想法就是:如果我们能把计算过的结果存起来,下次需要时直接使用,不就能大大提高效率了吗?
这就是记忆化搜索的核心思想!
三、记忆化搜索详解
1. 记忆化斐波那契数列实现(C++版)
#include <iostream>
#include <unordered_map>
using namespace std;
unordered_map<int, int> memo;
int fibonacci(int n) {
if (n <= 1) {
return n;
}
// 如果已经计算过,直接返回存储的结果
if (memo.find(n) != memo.end()) {
return memo[n];
}
// 否则计算并存储结果
memo[n] = fibonacci(n-1) + fibonacci(n-2);
return memo[n];
}
int main() {
int n = 50; // 试试更大的数字!
cout << "斐波那契数列第" << n << "项是:" << fibonacci(n) << endl;
return 0;
}
这个版本有什么改进?
- 我们使用了一个哈希表
memo
来存储已经计算过的结果 - 每次计算前先检查是否已经计算过
- 如果没有计算过才进行递归计算,并把结果存储起来
时间复杂度分析:
- 现在每个fib(i)只会被计算一次
- 共有n个不同的值需要计算
- 所以时间复杂度降为O(n) —— 巨大的进步!
2. 记忆化搜索的工作原理图解
让我们看看计算fib(5)时记忆化搜索是如何工作的:
- 计算fib(5),发现没存储过
- 需要计算fib(4)和fib(3)
- 计算fib(4),发现没存储过
- 需要计算fib(3)和fib(2)
- 计算fib(3),发现没存储过
- 需要计算fib(2)和fib(1)
- 计算fib(2),发现没存储过
- 需要计算fib(1)和fib(0)
- fib(1)和fib(0)是基本情况,直接返回1和0
- 存储fib(2)=1
- 返回fib(3)=fib(2)+fib(1)=1+1=2,存储fib(3)=2
- 计算fib(4)=fib(3)+fib(2)=2+1=3,存储fib(4)=3
- 计算fib(5)=fib(4)+fib(3)=3+2=5,存储fib(5)=5
可以看到,每个子问题只计算了一次,之后都是直接从memo中获取结果。
3. 记忆化搜索的通用框架
记忆化搜索可以应用于很多递归问题,它的通用框架如下:
// 1. 定义记忆化数据结构(通常是哈希表或数组)
unordered_map<参数类型, 结果类型> memo;
返回值类型 函数名(参数) {
// 2. 基本情况处理
if (是基本情况) {
return 基本情况结果;
}
// 3. 检查是否已经计算过
if (memo.find(参数) != memo.end()) {
return memo[参数];
}
// 4. 递归计算并存储结果
返回值类型 结果 = 根据子问题组合得到的结果;
memo[参数] = 结果;
return 结果;
}
4. 记忆化搜索的三大优势
- 时间复杂度大幅降低:从指数级降到多项式级
- 代码改动小:只需添加记忆化逻辑,不改变原有递归结构
- 更符合人类思维:自上而下的思考方式,比自下而上的动态规划更直观
四、记忆化搜索的实际应用
1. 爬楼梯问题
问题描述:假设你正在爬楼梯,每次你可以爬1阶或2阶。问到达第n阶有多少种不同的方法?
分析:这其实就是斐波那契数列的变种!到达第n阶的方法数等于:
- 从n-1阶爬1阶上来
- 从n-2阶爬2阶上来
所以f(n) = f(n-1) + f(n-2)
C++实现:
#include <iostream>
#include <unordered_map>
using namespace std;
unordered_map<int, int> memo;
int climbStairs(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
if (memo.find(n) != memo.end()) {
return memo[n];
}
memo[n] = climbStairs(n-1) + climbStairs(n-2);
return memo[n];
}
int main() {
int n = 10;
cout << "爬" << n << "阶楼梯有" << climbStairs(n) << "种方法" << endl;
return 0;
}
2. 网格路径问题
问题描述:在m×n的网格中,从左上角走到右下角,每次只能向右或向下移动,问有多少种不同的路径?
分析:到达(i,j)的路径数等于:
- 从上方(i-1,j)下来的路径数
- 从左方(i,j-1)过来的路径数
所以f(i,j) = f(i-1,j) + f(i,j-1)
C++实现:
#include <iostream>
#include <unordered_map>
#include <utility> // for pair
using namespace std;
struct pair_hash {
template <class T1, class T2>
size_t operator() (const pair<T1, T2>& p) const {
auto h1 = hash<T1>{}(p.first);
auto h2 = hash<T2>{}(p.second);
return h1 ^ h2;
}
};
unordered_map<pair<int, int>, int, pair_hash> memo;
int uniquePaths(int m, int n) {
if (m == 1 || n == 1) return 1;
pair<int, int> key = {m, n};
if (memo.find(key) != memo.end()) {
return memo[key];
}
memo[key] = uniquePaths(m-1, n) + uniquePaths(m, n-1);
return memo[key];
}
int main() {
int m = 3, n = 7;
cout << m << "x" << n << "网格的独特路径数:" << uniquePaths(m, n) << endl;
return 0;
}
3. 0-1背包问题
问题描述:给定一组物品,每种物品都有自己的重量和价值。在限定的总重量内,如何选择物品使得总价值最大?
分析:对于每个物品,我们有两种选择:拿或者不拿。我们可以用记忆化搜索来记录已经计算过的子问题。
C++实现:
#include <iostream>
#include <vector>
#include <unordered_map>
#include <utility>
using namespace std;
struct pair_hash {
template <class T1, class T2>
size_t operator() (const pair<T1, T2>& p) const {
auto h1 = hash<T1>{}(p.first);
auto h2 = hash<T2>{}(p.second);
return h1 ^ h2;
}
};
unordered_map<pair<int, int>, int, pair_hash> memo;
int knapsack(const vector<int>& weights, const vector<int>& values, int W, int i) {
if (i == 0 || W == 0) return 0;
pair<int, int> key = {W, i};
if (memo.find(key) != memo.end()) {
return memo[key];
}
if (weights[i-1] > W) {
memo[key] = knapsack(weights, values, W, i-1);
} else {
int notTake = knapsack(weights, values, W, i-1);
int take = values[i-1] + knapsack(weights, values, W - weights[i-1], i-1);
memo[key] = max(notTake, take);
}
return memo[key];
}
int main() {
vector<int> values = {60, 100, 120};
vector<int> weights = {10, 20, 30};
int W = 50;
cout << "背包最大价值:" << knapsack(weights, values, W, values.size()) << endl;
return 0;
}
五、记忆化搜索的局限性及优化
虽然记忆化搜索非常强大,但它也有一些局限性:
- 递归深度限制:递归太深可能导致栈溢出
- 空间开销:需要存储所有子问题的结果
- 函数调用开销:递归调用比迭代开销大
对于这些问题,我们可以考虑:
- 改为迭代实现(动态规划):
int fibonacci(int n) {
if (n <= 1) return n;
vector<int> dp(n+1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
- 空间优化:有时候我们不需要存储所有结果
int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
return b;
}
六、总结
记忆化搜索是一种极其强大的算法优化技术,它的核心思想是 “以空间换时间”。通过存储已经计算过的子问题结果,可以避免重复计算,将许多指数级复杂度的问题降为多项式级。
关键要点:
- 记忆化搜索 = 递归 + 记忆存储
- 适用于有重叠子问题的情况
- 实现简单,通常只需添加几行代码
- 可以很容易地转化为动态规划解法