LeetCode 记忆化搜索 刷题总结

题目1:斐波那契数(LeetCode 509)

  1. 题目描述
  1. 三种解法:暴搜 → 记忆化搜索 → 动态规划

1) 暴力递归(暴搜)

算法思路

递归含义:定义 dfs(n) 函数,使命是返回第 n 个斐波那契数的值。

函数体:直接套用递推公式 dfs(n) = dfs(n-1) + dfs(n-2)。

递归出口:当 n == 0 或 n == 1 时,直接返回 n(对应 F(0)=0、F(1)=1)。

cpp 复制代码
int dfs(int n) 
{
    if (n == 0 || n == 1)
        return n;
    return dfs(n - 1) + dfs(n - 2);
}

int fib(int n) 
{
    return dfs(n); 
}

核心缺陷

存在大量重复计算,例如计算 F(5) 时,会多次重复计算 F(3)、F(2) 等,时间复杂度为 O(2ⁿ),效率极低。

以 n=5 为例,递归树如下:

cpp 复制代码
d(5)
├─ d(4)
│  ├─ d(3)
│  │  ├─ d(2)
│  │  │  ├─ d(1)
│  │  │  └─ d(0)
│  │  └─ d(1)
│  └─ d(2)
│     ├─ d(1)
│     └─ d(0)
└─ d(3)  ← 重复计算!
   ├─ d(2) ← 重复计算!
   │  ├─ d(1)
   │  └─ d(0)
   └─ d(1)

大量重复节点(如 d(3)、d(2)、d(1))被多次计算,导致时间复杂度指数级增长。

2) 记忆化搜索(递归优化版)

核心思想

在暴力递归的基础上,增加备忘录(数组/哈希表),存储已经计算过的斐波那契数,避免重复计算,将时间复杂度优化为 O(n)。

算法思路

步骤1:添加备忘录:创建数组 memo,用于存储已经计算过的结果,格式为 <可变参数, 返回值>,初始值设为 -1(表示未计算)。

步骤2:递归前查询备忘录:每次进入递归函数时,先检查 memon 是否已计算(不为 -1),如果已计算则直接返回 memon

步骤3:递归出口与计算:若 n == 0 或 n == 1,直接返回 n,并将结果存入 memon

步骤4:递归后更新备忘录:计算 dfs(n-1) + dfs(n-2),将结果存入 memon 后返回。

原理说明

添加备忘录后,计算过的节点会被缓存:

第一次计算 d(3) 时,将结果存入备忘录;

后续再遇到 d(3) 时,直接从备忘录中读取,无需重复递归。

时间复杂度从 O(2ⁿ) 优化为 O(n),每个节点仅计算一次。

cpp 复制代码
// 备忘录数组,全局初始化(初始值为0,实际使用时需手动设为-1)
int memo[31];

// 记忆化搜索递归函数
int dfs(int n) 
{
    // 1. 先查备忘录,已计算则直接返回
    if (memo[n] != -1) 
    {
        return memo[n];
    }

    // 2. 递归出口:n=0或n=1时,直接返回并记录
    if (n == 0 || n == 1) 
    {
        memo[n] = n;
        return n;
    }

    // 3. 递归计算,并将结果存入备忘录
    memo[n] = dfs(n - 1) + dfs(n - 2);
    return memo[n];
}



int fib(int n) 
{
    memset(memo,-1,sizeof(memo));
    return dfs(n); 
}

关键问题解答

  1. 所有的递归(暴搜、深搜),都能改成记忆化搜索吗?

不是的,只有在递归的过程中,出现了大量完全相同的子问题时,才能用记忆化搜索的方式优化。例如斐波那契数的递归存在大量重复子问题,而普通的全排列递归没有重复子问题,无法优化。

  1. 带备忘录的递归 vs 带备忘录的动态规划 vs 记忆化搜索:本质是同一类思想,只是实现方式不同。

  2. 自顶向下 vs 自底向上:记忆化搜索是"自顶向下"(从问题拆解到子问题),动态规划是"自底向上"(从子问题递推到最终问题)。

3) 动态规划(递推版,迭代实现)

核心思想

将递归的"自顶向下"拆解,转化为"自底向上"的递推过程,用数组存储每个状态的结果,避免递归栈开销,是更高效的实现方式。

算法步骤(从递归到动态规划的转化)

|------|----------|---------------------------------|
| 递归概念 | 动态规划对应概念 | 斐波那契数的具体实现 |
| 递归含义 | 状态表示 | dpi 表示:第 i 个斐波那契数 |
| 函数体 | 状态转移方程 | dpi = dpi-1 + dpi-2 |
| 递归出口 | 初始化 | dp0 = 0,dp1 = 1 |
| 调用顺序 | 填表顺序 | 从左往右(i 从 2 到 n) |
| 返回值 | 最终结果 | dpn |

完整C语言代码

cpp 复制代码
// 动态规划数组
int dp[31];

int fib(int n)
{
    // 初始化
    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数组 |
| 执行顺序 | 从 n 向下拆解到 0/1 | 从 0/1 向上递推到 n |
| 栈开销 | 存在递归栈开销 | 无递归栈开销 |

  1. 三种方法对比

|----------|-------|----------------|------------------------|
| 方法 | 时间复杂度 | 空间复杂度 | 核心特点 |
| 暴力递归 | O(2ⁿ) | O(n)(递归栈) | 实现简单,但重复计算多,效率极低 |
| 记忆化搜索 | O(n) | O(n)(备忘录+递归栈) | 保留递归逻辑,用备忘录优化重复计算 |
| 动态规划(迭代) | O(n) | O(n)(可优化为O(1)) | 自底向上递推,无递归栈开销,可进一步优化空间 |

  1. 关键知识点总结
  1. 斐波那契数列的定义:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n>1),核心是"前两项之和"的递推关系。

  2. 重复计算问题:暴力递归的核心缺陷是子问题重复计算,解决思路是"缓存已计算结果"。

  3. 记忆化搜索本质:是"带备忘录的递归",属于动态规划的"自顶向下"实现方式,仅在存在大量重复子问题时有效。

  4. 动态规划的五步法:

  1. 确定状态表示(对应递归含义)

  2. 推导状态转移方程(对应递归函数体)

  3. 初始化边界条件(对应递归出口)

  4. 确定填表顺序(对应递归调用顺序)

  5. 确定最终返回值(对应递归调用的返回)

  1. 优化拓展:动态规划可进一步优化空间,只需保留前两项的值,将空间复杂度从O(n)降至O(1):
cpp 复制代码
int fib(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    int a = 0, b = 1, c;
    for(int i = 2; i <= n; i++){
        c = a + b;
        a = b;
        b = c;
    }
    return b;
}
  1. 从递归到动态规划的转化技巧:先写出暴力递归函数;分析递归的含义、函数体、出口;对应转化为动态规划的状态表示、转移方程、初始化。

题目2:不同路径(LeetCode 62)

  1. 题目描述
  1. 三种解法思路梳理

题目给出了三种逐步优化的解法:暴力递归 → 记忆化搜索 → 动态规划,我们逐一拆解:

1) 暴力递归(暴搜)

核心逻辑

递归含义:定义函数 dfs(i, j),返回从 0, 0 位置走到 i, j 位置的不同路径数量。

函数体逻辑:到达 i, j 只能从上方 i-1, j 或左方 i, j-1 移动而来,因此路径数为两者之和:

dfs(i, j) = dfs(i-1, j) + dfs(i, j-1)

递归出口

当下标越界(i < 1 或 j < 1,注:代码中从1开始索引),返回 0(无有效路径);

当位于起点 1, 1(对应原网格的 0,0),返回 1(只有1种方式:原地不动)。

问题:存在大量重复子问题(如计算 dfs(2,2) 时,会重复计算 dfs(1,2) 和 dfs(2,1)),时间复杂度为指数级 O(2^{m+n}),无法通过大数据用例。

cpp 复制代码
class Solution {
public:
    int uniquePaths(int m, int n) 
    {
        return dfs(m, n);
    }

    int dfs(int i, int j) 
    {
        if (i == 0 || j == 0)
            return 0;
        if (i == 1 && j == 1)
            return 1;
        return dfs(i - 1, j) + dfs(i, j - 1);
    }
};

2) 记忆化搜索(递归+备忘录)

核心优化思路:为了解决暴力递归的重复计算问题,增加一个备忘录(memo),存储已经计算过的 dfs(i, j) 结果,避免重复计算。

步骤:

  1. 初始化一个二维数组 memo,大小为 (m+1) × (n+1),初始值全为0;

  2. 每次进入递归时,先检查 memoij 是否不为0:

若不为0,直接返回 memoij

若为0,计算 dfs(i, j) 的结果,并将结果存入 memoij,再返回。

时间复杂度:优化为 O(m×n),每个子问题只计算一次。

cpp 复制代码
class Solution {
public:
    int uniquePaths(int m, int n) 
    {
        vector<vector<int>> memo(m + 1, vector<int>(n + 1));
        return dfs(m, n, memo);
    }

    int dfs(int i, int j, vector<vector<int>>& memo) 
    {
        if (memo[i][j] != 0) 
        {
            return memo[i][j];
        }

        if (i == 0 || j == 0)
            return 0;
        if (i == 1 && j == 1) 
        {
            memo[i][j] = 1;
            return 1;
        }

        memo[i][j] = dfs(i - 1, j, memo) + dfs(i, j - 1, memo);
        return memo[i][j];
    }
};

3) 动态规划(递推)

动态规划是记忆化搜索的"迭代版",将递归的自顶向下改为自底向上,避免递归栈开销。

对应关系(递归 → 动态规划)

|-------|-------------------------------------------------------|
| 递归概念 | 动态规划对应实现 |
| 递归含义 | 状态表示:dpij 表示从 0,0 走到 i-1j-1 的路径数 |
| 函数体逻辑 | 状态转移方程:dpij = dpi-1j + dpij-1 |
| 递归出口 | 初始化:dp11 = 1(起点路径数为1) |

实现步骤

  1. 定义二维 dp 数组,大小为 (m+1) × (n+1)(为了和递归索引对齐,避免边界处理麻烦);

  2. 初始化起点:dp11 = 1;

  3. 按行/列遍历网格,依次计算每个位置的路径数(跳过起点);

  4. 最终 dpmn 即为从左上角到右下角的路径总数。

时间复杂度:O(m×n),空间复杂度 O(m×n) 。

cpp 复制代码
class Solution
{
public:
    int uniquePaths(int m, int n)
    {
        // 动态规划实现(迭代版)
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[1][1] = 1; // 初始化起点路径数为1
        for(int i = 1; i <= m; i++)
        {
            for(int j = 1; j <= n; j++)
            {
                if(i == 1 && j == 1) continue; // 跳过起点,避免覆盖
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m][n];
     }
};

题目3:最长递增子序列(LeetCode 300)

  1. 题目描述
  1. 三种解法思路梳理

题目给出了三种逐步优化的解法:暴力递归 → 记忆化搜索 → 动态规划,我们逐一拆解:

1) 暴力递归(暴搜)

核心逻辑

递归含义:定义函数 dfs(i),返回以 i 位置为起点的最长递增子序列的长度。

函数体逻辑:遍历 i 后面的所有位置 j,如果 numsj > numsi,说明 numsj 可以接在 numsi 后面,此时以 i 为起点的子序列长度为 1 + dfs(j)。取所有可能情况的最大值,就是 dfs(i) 的结果。

递归出口:题目描述中提到"因为我们是判断之后再进入递归的,因此没有出口",实际隐含的终止条件是:当 i 是数组最后一个元素时,dfs(i) = 1(子序列只有自身)。

问题

存在大量重复子问题(如计算 dfs(0) 时,会重复计算 dfs(2)、dfs(3) 等多次),时间复杂度为指数级 O(2^n),无法通过大数据用例。

cpp 复制代码
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        int ret = 0;
        for (int i = 0; i < nums.size(); i++) 
        {
            ret = max(ret, dfs(i, nums));
        }
        return ret;
    }

    int dfs(int pos, vector<int>& nums) 
    {
        int ret = 1;
        for (int i = pos + 1; i < nums.size(); i++) 
        {
            if (nums[i] > nums[pos]) 
            {
                ret = max(ret, dfs(i, nums) + 1);
            }
        }
        return ret;
    }
};

2) 记忆化搜索(递归+备忘录)

核心优化思路:为了解决暴力递归的重复计算问题,增加一个备忘录(memo),存储已经计算过的 dfs(i) 结果,避免重复计算。

步骤:

  1. 初始化一个一维数组 memo,大小为 n,初始值全为0;

  2. 每次进入递归时,先检查 memopos 是否不为0:

若不为0,直接返回 memopos

若为0,计算 dfs(pos) 的结果,并将结果存入 memopos,再返回。

时间复杂度:优化为 O(n^2),每个子问题只计算一次。

cpp 复制代码
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> memo(n);
        int ret = 0;
        for (int i = 0; i < n; i++) 
        {
            ret = max(ret, dfs(i, nums, memo));
        }
        return ret;
    }

    int dfs(int pos, vector<int>& nums, vector<int>& memo) 
    {
        if(memo[pos] != 0) return memo[pos];

        int ret = 1;
        for (int i = pos + 1; i < nums.size(); i++) 
        {
            if (nums[i] > nums[pos]) 
            {
                ret = max(ret, dfs(i, nums, memo) + 1);
            }
        }
        memo[pos] = ret;
        return ret;
    }
};

3) 动态规划(递推)

动态规划是记忆化搜索的"迭代版",将递归的自顶向下改为自底向上,避免递归栈开销。

对应关系(递归 → 动态规划)

|-------|--------------------------------------------------------------------------------|
| 递归概念 | 动态规划对应实现 |
| 递归含义 | 状态表示:dpi 表示以 i 位置为终点的最长递增子序列的长度 |
| 函数体逻辑 | 状态转移方程:dpi = max(dpi, dpj + 1)(其中 j > i 且 numsj > numsi) |
| 递归出口 | 初始化:dpi = 1(每个元素自身是长度为1的子序列) |

实现步骤

  1. 定义一维 dp 数组,大小为 n,初始值全为1(每个元素自身是长度为1的子序列);

  2. 从后往前遍历数组(i 从 n-1 到 0);

  3. 对于每个 i,遍历 j 从 i+1 到 n-1:若 numsj > numsi,则更新 dpi = max(dpi, dpj + 1);

  4. 遍历过程中记录 dp 数组的最大值,即为答案。

时间复杂度:O(n^2),空间复杂度 O(n)。

cpp 复制代码
class Solution
{
public:
    // 动态规划实现(迭代版)
    int lengthOfLIS(vector<int>& nums)
    {
        int n = nums.size();
        vector<int> dp(n, 1); // 初始化:每个元素自身长度为1
        int ret = 0;
        // 填表顺序:从后往前
        for(int i = n - 1; i >= 0; i--)
        {
            for(int j = i + 1; j < n; j++)
            {
                if(nums[j] > nums[i])
                {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            ret = max(ret, dp[i]); // 更新全局最大值
        }
        return ret;
    }
};

题目4:猜数字大小 II(LeetCode 375)

  1. 题目描述
  1. 解法:暴搜 → 记忆化搜索

1) 暴搜(递归)

递归含义:给 dfs 一个使命,给他一个区间 left, right,返回在这个区间上能完胜的最小费用。

函数体:选择 left, right 区间上的任意一个数作为头结点,然后递归分析左右子树。求出所有情况下的最小值。

递归出口:当 left >= right 的时候,直接返回 0。

2) 记忆化搜索

加上一个备忘录 memo201201

每次进入递归的时候,去备忘录里面看看;每次返回的时候,将结果加入到备忘录里面。

cpp 复制代码
class Solution {
    int memo[201][201];

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 ret = INT_MAX;
        for (int head = left; head <= right; head++) // 选择头结点
        {
            int x = dfs(left, head - 1);
            int y = dfs(head + 1, right);
            ret = min(ret, head + max(x, y));
        }

        memo[left][right] = ret;
        return ret;
    }
};

区间DP + 记忆化深度优先搜索,属于区间类最优决策问题,核心:保证最坏情况下花费最少金钱

递归状态定义 dfs(left, right)

含义:在数字区间 left, right 中,确保猜对数字,需要准备的最小保底花费

递归终止条件 if (left >= right) return 0;当区间只有一个数或者区间无效时,不需要猜测,花费为0。

记忆化剪枝原理 if (memoleftright != 0) return memoleftright;二维数组 memoleftright 缓存已经计算过的区间答案,避免大量重复递归计算,把暴力指数级复杂度优化为多项式复杂度。

核心循环逻辑 for (int head = left; head <= right; head++) 遍历当前区间内每一个数字 head,假设本次优先猜测这个数。

状态转移拆解

  1. x = dfs(left, head - 1) 猜测head错误,目标数字在左侧区间的最小花费

  2. y = dfs(head + 1, right) 猜测head错误,目标数字在右侧区间的最小花费

  3. max(x , y) 题目要求必须保证绝对获胜,所以要考虑最坏情况,取左右两边花费更大的那一个

  4. head + max(x, y) 猜错当前数字需要支付 head 金额,加上后续区间最坏花费

最优决策选取 ret = min(ret, head + max(x, y)); 枚举所有猜测点,在所有最坏方案里,挑选花费最小的策略

缓存保存结果 memoleftright = ret; 将当前区间的最优解存入备忘录,后续直接复用

为什么要用 max 再用 min?

  1. max:规避最坏运气,保证无论答案在哪都能赢

  2. min:主动选择最优猜测方案,压缩成本,这是本题最核心的解题思想。


题目5:矩阵中的最长递增路径(LeetCode 329)

  1. 题目描述
  1. 解法:暴搜 → 记忆化搜索

1) 暴搜(DFS)

递归含义:给 dfs 一个使命,给他一个下标 i, j,返回从这个位置开始的最长递增路径的长度。

函数体:上下左右四个方向瞅一瞅,哪里能过去就过去,统计四个方向上的最大长度。

递归出口:因为我们是先判断再进入递归,因此没有出口。

2) 记忆化搜索

加上一个备忘录 memo201201

每次进入递归的时候,去备忘录里面看看;每次返回的时候,将结果加入到备忘录里面。

cpp 复制代码
class Solution 
{
    int m, n;
    int dx[4] = {0, 0, 1, -1};
    int dy[4] = {1, -1, 0, 0};
    int memo[201][201];

public:
    int longestIncreasingPath(vector<vector<int>>& matrix) 
    {
        int ret = 0;
        m = matrix.size(), n = matrix[0].size();

        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 ret = 1;
        for (int k = 0; k < 4; k++) 
        {
            int x = i + dx[k], y = j + dy[k];
            if (x >= 0 && x < m && y >= 0 && y < n && matrix[x][y] > matrix[i][j]) 
            {
                ret = max(ret, dfs(matrix, x, y) + 1);
            }
        }

        memo[i][j] = ret;
        return ret;
    }
};
  1. 核心知识点总结

这两道题都是典型的 记忆化搜索(DFS + 备忘录) 问题,核心思路一致:

1) 递归定义子问题

猜数字:dfs(left, right) 表示区间 left, right 内的最小成本。

矩阵路径:dfs(i, j) 表示从 (i, j) 出发的最长递增路径长度。

2) 状态转移

猜数字:枚举当前猜的数字 head,取左右子问题的最坏情况(max(x,y))加上当前成本 head,再取所有枚举中的最小值。

矩阵路径:遍历四个方向,若下一个位置值更大,则递归求解并更新当前位置的最大路径长度。

3) 备忘录优化

用二维数组 memo 存储已经计算过的子问题结果,避免重复计算,将时间复杂度从指数级降到多项式级。

猜数字:memoleftright 存储区间结果。

矩阵路径:memoij 存储每个位置的结果。

相关推荐
菜菜的顾清寒2 小时前
力扣HOT100(44)对称二叉树
数据结构·算法·leetcode
六bring个六2 小时前
c/c++面试踩坑笔记
c语言·数据结构·c++
bbaydnog2 小时前
嵌入式面试高频题第4弹:函数指针进阶、堆栈分析、Makefile入门,这3个答不上来就悬了
单片机·面试·职场和发展
jiayong232 小时前
海量数据常见面试问题及详细解答
大数据·面试·职场和发展
吃好睡好便好2 小时前
矩阵的左乘和右乘
人工智能·学习·线性代数·算法·matlab·矩阵
我命由我123452 小时前
SEO 与 GEO 极简理解
java·linux·运维·开发语言·学习·算法·运维开发
月光刺眼2 小时前
🎶二分 · 双指针 · 滑动窗口 · 螺旋矩阵:数组算法四题拆解
javascript·算法
南境十里·墨染春水2 小时前
数据结构 —— 双向循环链表
数据结构·链表
海清河晏1112 小时前
字符串匹配:BF算法与KMP算法
数据结构·算法·visual studio