作为一名在算法领域摸爬滚打多年的老手,我见过太多初学者在递归问题上栽跟头。今天我要分享的是一个能让你算法效率产生质变的技术——记忆化搜索(Memoization)。这不仅是暴力递归的优化手段,更是通向动态规划的桥梁。
记忆化搜索的核心思想简单来说就是:用空间换时间。想象你在解一个复杂的数学题,每次遇到相同的子问题都要重新计算,这显然是在做无用功。而记忆化搜索就像随身携带的笔记本,记录下已经解决过的子问题答案,下次遇到直接查笔记,省去了重复计算的时间。
让我们用最经典的斐波那契数列问题来说明。传统的递归解法是这样的:
cpp复制int fib(int n) {
if (n == 0 || n == 1) return n;
return fib(n - 1) + fib(n - 2);
}
这个解法虽然简洁,但存在严重的效率问题。计算fib(5)时,fib(3)会被计算两次,fib(2)会被计算三次。随着n增大,这种重复计算呈指数级增长,时间复杂度达到惊人的O(2^n)。
实际测试中,计算fib(40)时,普通递归需要约1秒,而记忆化搜索仅需不到1毫秒。这种差距在算法竞赛或面试中往往是能否通过的关键。
记忆化搜索通过三个简单步骤实现优化:
改造后的斐波那契函数:
cpp复制int memo[100] = {0}; // 全局备忘录
int fib(int n) {
if (n == 0 || n == 1) return n;
if (memo[n] != 0) return memo[n]; // 查备忘录
memo[n] = fib(n - 1) + fib(n - 2); // 存结果
return memo[n];
}
这种优化将时间复杂度从O(2^n)降到了O(n),空间复杂度也是O(n),实现了质的飞跃。
暴力递归是最直接的实现方式,完全按照问题描述编写代码。斐波那契的暴力递归版本我们已经看过,它的优点是:
但缺点也很明显:
记忆化搜索在暴力递归基础上增加了备忘录机制。它的优势在于:
但需要注意:
动态规划(DP)是记忆化搜索的迭代版本,采用自底向上的计算方式:
cpp复制int fib(int n) {
if (n == 0) return 0;
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];
}
DP的优点:
但缺点也很明显:
让我们用具体数据感受三种实现的差异:
| 实现方式 | 时间复杂度 | 空间复杂度 | fib(40)执行时间 |
|---|---|---|---|
| 暴力递归 | O(2^n) | O(n) | ~1秒 |
| 记忆化搜索 | O(n) | O(n) | <1毫秒 |
| 动态规划 | O(n) | O(n) | <0.5毫秒 |
| DP空间优化版 | O(n) | O(1) | <0.3毫秒 |
从表格可以看出,记忆化搜索相比暴力递归有巨大提升,而动态规划在此基础上还有小幅优化空间。
LeetCode第62题"不同路径"是一个经典的二维DP问题:
一个机器人位于m×n网格的左上角,每次只能向下或向右移动一步,问到达右下角有多少种不同的路径。
最直观的递归解法是:
cpp复制int uniquePaths(int m, int n) {
if (m == 1 || n == 1) return 1;
return uniquePaths(m-1, n) + uniquePaths(m, n-1);
}
这种解法的问题同样是重复计算。例如,计算uniquePaths(2,2)时:
可以看到uniquePaths(1,1)被重复计算了。
我们使用二维数组作为备忘录:
cpp复制class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> memo(m+1, vector<int>(n+1, 0));
return dfs(m, n, memo);
}
int dfs(int m, int n, vector<vector<int>>& memo) {
if (m == 1 || n == 1) return 1;
if (memo[m][n] != 0) return memo[m][n];
memo[m][n] = dfs(m-1, n, memo) + dfs(m, n-1, memo);
return memo[m][n];
}
};
对应的DP解法是:
cpp复制int uniquePaths(int m, int n) {
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (i == 1 || j == 1) {
dp[i][j] = 1;
} else {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[m][n];
}
观察DP解法可以发现,我们实际上只需要前一行的数据就可以计算当前行。因此可以将空间复杂度从O(mn)优化到O(n):
cpp复制int uniquePaths(int m, int n) {
vector<int> dp(n, 1);
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[j] += dp[j-1];
}
}
return dp[n-1];
}
这种优化在面试中常常被问到,体现了对问题的深入理解。
LeetCode第300题"最长递增子序列"要求找到数组中最长的严格递增子序列的长度。例如:
输入:[10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是[2,3,7,101],长度为4
定义dfs(i)为以nums[i]结尾的最长递增子序列长度。对于每个i,我们需要检查所有j < i,如果nums[j] < nums[i],则可以形成更长的子序列。
cpp复制class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> memo(n, 0);
int max_len = 0;
for (int i = 0; i < n; i++) {
max_len = max(max_len, dfs(i, nums, memo));
}
return max_len;
}
int dfs(int pos, vector<int>& nums, vector<int>& memo) {
if (memo[pos] != 0) return memo[pos];
int ret = 1; // 至少包含自己
for (int i = 0; i < pos; i++) {
if (nums[i] < nums[pos]) {
ret = max(ret, dfs(i, nums, memo) + 1);
}
}
memo[pos] = ret;
return ret;
}
};
cpp复制int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 1);
int max_len = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
max_len = max(max_len, dp[i]);
}
return max_len;
}
虽然这不是本文重点,但值得一提的是这个问题存在更优的解法:
cpp复制int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for (int num : nums) {
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) {
tails.push_back(num);
} else {
*it = num;
}
}
return tails.size();
}
这个解法利用了二分查找,将时间复杂度优化到了O(nlogn),体现了算法优化的多样性。
LeetCode第375题"猜数字大小II"是一个典型的Minimax问题:
我们正在玩一个猜数游戏,游戏规则如下:
这个问题如果尝试用传统DP解决,会发现:
记忆化搜索可以自然地处理这种复杂依赖关系,让递归自动处理计算顺序。
cpp复制class Solution {
private:
int memo[201][201] = {0}; // n最大为200
public:
int getMoneyAmount(int n) {
return dfs(1, n);
}
int dfs(int left, int right) {
if (left >= right) return 0; // 只有一个数,不用猜
if (memo[left][right] != 0) return memo[left][right];
int min_cost = INT_MAX;
for (int guess = left; guess <= right; guess++) {
// 最坏情况下需要支付的最大金额
int cost = guess + max(dfs(left, guess-1), dfs(guess+1, right));
min_cost = min(min_cost, cost); // 选择最小化最大损失的策略
}
memo[left][right] = min_cost;
return min_cost;
}
};
这个解法体现了Minimax算法的思想:
时间复杂度为O(n^3),因为:
空间复杂度为O(n^2)用于存储备忘录。
LeetCode第329题"矩阵中的最长递增路径":
给定一个m×n整数矩阵,找出最长递增路径的长度。对于每个单元格,你可以向上下左右四个方向移动,但不能对角线移动或移动到边界外。
这个问题有以下几个特点:
记忆化搜索可以自然地处理这种多起点、共享子路径的情况。
cpp复制class Solution {
private:
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
vector<vector<int>> memo;
public:
int longestIncreasingPath(vector<vector<int>>& matrix) {
if (matrix.empty()) return 0;
int m = matrix.size(), n = matrix[0].size();
memo = vector<vector<int>>(m, vector<int>(n, 0));
int max_len = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
max_len = max(max_len, dfs(matrix, i, j));
}
}
return max_len;
}
int dfs(vector<vector<int>>& matrix, int i, int j) {
if (memo[i][j] != 0) return memo[i][j];
int max_len = 1; // 至少包含当前单元格
for (int k = 0; k < 4; k++) {
int x = i + dx[k], y = j + dy[k];
if (x >= 0 && x < matrix.size() && y >= 0 && y < matrix[0].size()
&& matrix[x][y] > matrix[i][j]) {
max_len = max(max_len, dfs(matrix, x, y) + 1);
}
}
memo[i][j] = max_len;
return max_len;
}
};
这个解法的时间复杂度是O(mn),因为:
空间复杂度也是O(mn)用于存储备忘录。
在实际应用中,何时选择记忆化搜索,何时选择传统DP?根据我的经验:
| 考虑因素 | 记忆化搜索 | 动态规划 |
|---|---|---|
| 编码复杂度 | 低 | 中到高 |
| 时间复杂度 | 相同 | 相同 |
| 空间复杂度 | 相同 | 可优化 |
| 适用问题范围 | 广 | 较窄 |
| 常数时间 | 较差 | 较好 |
问题:为什么有时候备忘录需要初始化为-1而不是0?
解答:这取决于问题本身。如果0是可能的合法结果(如某些计数问题),就需要用-1或其他特殊值表示未计算状态。
问题:当n很大时,记忆化搜索可能导致栈溢出,如何解决?
解决方案:
技巧:对于多维DP问题,可以考虑:
经验:记忆化搜索中最常见的错误是边界条件处理不当。建议:
记忆化搜索的函数参数通常直接对应DP的状态定义。例如:
分析记忆化搜索的调用关系,找出依赖方向。例如斐波那契中:
将记忆化搜索中的递归终止条件转化为DP的初始状态。例如:
记忆化搜索版本:
cpp复制int fib(int n, vector<int>& memo) {
if (n == 0 || n == 1) return n;
if (memo[n] != -1) return memo[n];
memo[n] = fib(n-1, memo) + fib(n-2, memo);
return memo[n];
}
转换为DP:
DP实现:
cpp复制int fib(int n) {
if (n == 0) return 0;
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];
}
许多博弈论问题天然适合记忆化搜索,如:
这类问题通常需要:
在某些图论问题中,记忆化搜索可以简化实现:
虽然强大,记忆化搜索也有其局限:
当记忆化搜索不可行时,可以考虑:
在我多年的算法竞赛和面试官经历中,记忆化搜索有几点深刻体会:
记忆化搜索最神奇的地方在于,它能让一个看似不可能解决的问题(如O(2^n)复杂度)突然变得可行(O(n^2)或更好)。这种"化腐朽为神奇"的能力,正是算法设计的魅力所在。