文章目录
-
- 备忘录的魔法:拒绝重复计算
- [一、 前言:什么是记忆化搜索?](#一、 前言:什么是记忆化搜索?)
- [二、 斐波那契数:三阶进化的起点](#二、 斐波那契数:三阶进化的起点)
-
- [2.1 题目描述](#2.1 题目描述)
- [2.2 深度拆解:演变过程](#2.2 深度拆解:演变过程)
-
- [1. 第一阶:暴力递归 (超时警告)](#1. 第一阶:暴力递归 (超时警告))
- [2. 第二阶:记忆化搜索 (优雅降维)](#2. 第二阶:记忆化搜索 (优雅降维))
- [3. 第三阶:动态规划 (自底向上)](#3. 第三阶:动态规划 (自底向上))
- [2.3 C++ 代码实战(对比展示)](#2.3 C++ 代码实战(对比展示))
- [三、 不同路径:网格中的记忆化](#三、 不同路径:网格中的记忆化)
-
- [3.1 题目描述](#3.1 题目描述)
- [3.2 深度拆解:为什么会重复?](#3.2 深度拆解:为什么会重复?)
- [3.3 C++ 代码实战(记忆化搜索版)](#3.3 C++ 代码实战(记忆化搜索版))
- [四、 最长递增子序列 (LIS)](#四、 最长递增子序列 (LIS))
-
- [4.1 题目描述](#4.1 题目描述)
- [4.2 深度拆解](#4.2 深度拆解)
- [4.3 C++ 代码实战(对比)](#4.3 C++ 代码实战(对比))
- [五、 猜数字大小 II:无法用常规 DP 的奇葩](#五、 猜数字大小 II:无法用常规 DP 的奇葩)
-
- [5.1 题目描述](#5.1 题目描述)
- [5.2 深度拆解:Min-Max 极小化极大算法](#5.2 深度拆解:Min-Max 极小化极大算法)
- [5.3 C++ 代码实战](#5.3 C++ 代码实战)
- [六、 矩阵中的最长递增路径](#六、 矩阵中的最长递增路径)
-
- [6.1 题目描述](#6.1 题目描述)
- [6.2 深度拆解:无需 vis 的 FloodFill](#6.2 深度拆解:无需 vis 的 FloodFill)
- [6.3 C++ 代码实战](#6.3 C++ 代码实战)
- [七、 终极总结:动态规划与记忆化的抉择](#七、 终极总结:动态规划与记忆化的抉择)
备忘录的魔法:拒绝重复计算
一、 前言:什么是记忆化搜索?
💬 开篇 :如果你写了一个 DFS,逻辑全对,但在力扣上提交时报了 TLE (Time Limit Exceeded 超时),那你大概率遇到了**"重叠子问题"**。
🚀 核心痛点与解药 :
想象你在算斐波那契数列 F ( 5 ) F(5) F(5):
- 算 F ( 5 ) F(5) F(5) 需要算 F ( 4 ) F(4) F(4) 和 F ( 3 ) F(3) F(3)。
- 算 F ( 4 ) F(4) F(4) 需要算 F ( 3 ) F(3) F(3) 和 F ( 2 ) F(2) F(2)。
- 发现了吗?F ( 3 ) F(3) F(3) 被算了两遍! 当数字变大时,这种重复计算会呈指数级爆炸。
解药(记忆化搜索):准备一个"备忘录"(数组或哈希表)。
- 进门前查字典:如果备忘录里有答案,直接拿走,不进递归!
- 出门前写日记:算出答案后,先存进备忘录,再返回。
💡 本质 :记忆化搜索 = 动态规划的自顶向下写法。加上备忘录,暴搜的时间复杂度就瞬间降维到和动态规划一样!
二、 斐波那契数:三阶进化的起点
2.1 题目描述
题目链接 :509. 斐波那契数
描述 :
计算第 n n n 个斐波那契数。 F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n) = F(n - 1) + F(n - 2) F(n)=F(n−1)+F(n−2)。
2.2 深度拆解:演变过程
1. 第一阶:暴力递归 (超时警告)
傻乎乎地直接翻译公式。
cpp
int fib(int n) {
if (n == 0 || n == 1) return n;
return fib(n - 1) + fib(n - 2);
}
2. 第二阶:记忆化搜索 (优雅降维)
创建一个 memo 数组,初始值为 -1。
两步改造法:
- 递归开头:
if (memo[n] != -1) return memo[n]; - 递归结尾:
memo[n] = 算出的结果; return memo[n];
3. 第三阶:动态规划 (自底向上)
既然知道 F ( 2 ) F(2) F(2) 依赖 F ( 0 ) F(0) F(0) 和 F ( 1 ) F(1) F(1),那我直接从前往后推(填表)。
2.3 C++ 代码实战(对比展示)
cpp
class Solution {
private:
int memo[31]; // 备忘录,题目限制 n <= 30
int dp[31]; // DP 数组
public:
Solution() {
memset(memo, -1, sizeof(memo));
}
// --- 方法一:记忆化搜索 (自顶向下) ---
int fib_memo(int n) {
// 1. 查字典:算过直接给
if (memo[n] != -1) return memo[n];
// 2. 递归出口
if (n == 0 || n == 1) {
memo[n] = n; // 别忘了存
return n;
}
// 3. 递归计算,并记日记
memo[n] = fib_memo(n - 1) + fib_memo(n - 2);
return memo[n];
}
// --- 方法二:动态规划 (自底向上) ---
int fib_dp(int n) {
if (n == 0) return 0;
if (n == 1) return 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];
}
};
三、 不同路径:网格中的记忆化
3.1 题目描述
题目链接 :62. 不同路径
描述 :
机器人从左上角走到右下角,每次只能向下或向右。求总共有多少条路径。
3.2 深度拆解:为什么会重复?
走到 (2, 2) 这个点,既可以从上方 (1, 2) 走过来,也可以从左方 (2, 1) 走过来。
如果不加备忘录,(2, 2) 走到起点的路径数会被上方和左方的递归各算一遍!
3.3 C++ 代码实战(记忆化搜索版)
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// memo[i][j] 表示从 (0,0) 走到 (i,j) 的方法数
vector<vector<int>> memo(m, vector<int>(n, 0));
return dfs(m - 1, n - 1, memo); // 从终点往前找
}
int dfs(int i, int j, vector<vector<int>>& memo) {
// 1. 越界检查 (找不到路)
if (i < 0 || j < 0) return 0;
// 2. 递归出口:回到起点了,算 1 条有效路径
if (i == 0 && j == 0) {
memo[i][j] = 1;
return 1;
}
// 3. 查字典
if (memo[i][j] != 0) return memo[i][j];
// 4. 递归计算:方法数 = 从上面来的 + 从左边来的
memo[i][j] = dfs(i - 1, j, memo) + dfs(i, j - 1, memo);
return memo[i][j];
}
};
四、 最长递增子序列 (LIS)
4.1 题目描述
题目链接 :300. 最长递增子序列
描述 :
求数组中最长严格递增子序列的长度。
4.2 深度拆解
- 暴搜定义 :
dfs(pos)表示以pos结尾的最长递增子序列长度。 - 记忆化 :用
memo[pos]存下来。
4.3 C++ 代码实战(对比)
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
// --- 动态规划版 ---
int n = nums.size();
vector<int> dp(n, 1);
int ret = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ret = max(ret, dp[i]);
}
return ret;
/*
// --- 记忆化搜索版 ---
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;
}
};
五、 猜数字大小 II:无法用常规 DP 的奇葩
5.1 题目描述
题目链接 :375. 猜数字大小 II
描述 :
猜数字游戏,范围
1到n。如果你猜错了,要支付对应的现金。求能够确保 获胜的最小现金数。
5.2 深度拆解:Min-Max 极小化极大算法
如果硬写 DP,循环的嵌套极其难写。这时候记忆化搜索直接"降维打击"。
- 递归定义 :
dfs(left, right)表示在区间[left, right]内猜中数字的最小钱数。 - 策略(Min) :我要在所有的猜测点
head中,选一个花费最小的方案。 - 最坏情况(Max) :对方非常狡猾,不管我猜什么,他总会让我付出最大的代价 (即左右两边花费更高的那一边)。
花费 =head + max(dfs(left, head - 1), dfs(head + 1, right))。
5.3 C++ 代码实战
cpp
class Solution {
private:
int memo[201][201] = {0};
public:
int getMoneyAmount(int n) {
return dfs(1, n);
}
int dfs(int left, int right) {
// 区间只有一个数或没有数,不用猜,花 0 元
if (left >= right) return 0;
// 查字典
if (memo[left][right] != 0) return memo[left][right];
int ret = INT_MAX;
// 枚举猜哪一个数作为切入点
for (int head = left; head <= right; head++) {
// 最坏情况:对方引我们去花费大的那一半
int worst_cost = max(dfs(left, head - 1), dfs(head + 1, right));
// 我们要在所有策略中选最小的代价
ret = min(ret, head + worst_cost);
}
// 记日记
memo[left][right] = ret;
return ret;
}
};
六、 矩阵中的最长递增路径
6.1 题目描述
题目链接 :329. 矩阵中的最长递增路径
描述 :
给定一个
m x n矩阵,找出其中 最长递增路径 的长度。只能上下左右移动。
6.2 深度拆解:无需 vis 的 FloodFill
这是二维网格和 LIS 的结合。
亮点 :题目要求严格递增 。你从 3 走到 5,绝对不可能从 5 走回 3。递增条件天然砍断了回头路,所以不需要 vis 数组 !
为什么用记忆化 :从不同起点出发的路径会频繁交汇,交汇后的后半段路径一定是一样的。memo[i][j] 记录从 (i, j) 出发能走的最长长度即可。
6.3 C++ 代码实战
cpp
class Solution {
private:
int m, n;
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
int memo[201][201] = {0};
public:
int longestIncreasingPath(vector<vector<int>>& matrix) {
m = matrix.size();
n = matrix[0].size();
int ret = 0;
// 任何一个格子都可能是最长路径的起点
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
ret = max(ret, dfs(matrix, i, j));
}
}
return ret;
}
int dfs(vector<vector<int>>& matrix, int i, int j) {
// 查字典
if (memo[i][j] != 0) return memo[i][j];
int max_len = 1; // 哪怕无路可走,自己也算长度 1
for (int k = 0; k < 4; k++) {
int x = i + dx[k];
int y = j + dy[k];
// 不越界 && 严格递增 (天然防回头)
if (x >= 0 && x < m && y >= 0 && y < n && matrix[x][y] > matrix[i][j]) {
max_len = max(max_len, dfs(matrix, x, y) + 1);
}
}
// 记日记
memo[i][j] = max_len;
return max_len;
}
};
七、 终极总结:动态规划与记忆化的抉择
💬 结业致辞:这是本系列的最后一个知识点,也是内功大成的一刻。
什么时候用 DP 填表?什么时候用记忆化搜索?
- 状态依赖简单、顺序明确时(如斐波那契、背包问题) :优先用 DP。因为循环填表没有递归调用的压栈开销,常数时间更优,且方便进行空间优化(滚动数组)。
- 状态依赖复杂、难以确定填表顺序时(如猜数字大小、矩阵递增路径) :优先用 记忆化搜索。在矩阵里,你很难通过简单的双重 for 循环保证"先填小值,再填大值"。让递归帮你自动处理拓扑排序般的依赖关系,代码写起来如丝般顺滑!
🎉 恭喜你!《递归、搜索、回溯算法》完美通关!希望这 40 道经典的算法题能成为你斩获大厂 Offer 的最强底气!咱们江湖再见! 🚀✨