专题六:记忆化搜索

111 阅读11分钟

0 记忆化搜索

0.1 什么是?

带备忘录的递归

0.2 如何实现记忆化搜索

  1. 添加一个备忘录 -> <可变参数,返回值>
  2. 递归每次返回的时候,将结果放到备忘录里面
  3. 在每次进入递归的时候,往备忘录里面瞅一瞅

0.3 动态规划(斐波那契数列为例)

  1. 确定状态表示:dfs函数的含义

    • dp[i]表示:第 i 个斐波那契数
  2. 推导状态转移方程:dfs函数的函数体

    • dp[i] = dp[i - 1] + dp[i - 2]
  3. 初始化:dfs函数的递归出口

    • dp[0] = 0, dp[1] = 1;
  4. 确定填表顺序:填写备忘录的顺序

    • 从左往右
  5. 确定返回值:主函数是如何调用dfs的

    • dp[n]

0.4 总结

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

    • 不是的,只有在递归的过程中,出现了大量完全相同的问题时,才能用记忆化搜索的方式优化。
  2. 带备忘录的递归 vs 带备忘录的动态规划 vs 记忆化搜索

    • 都是一回事

0.5 记忆化搜索 and 动态规划 的本质:

  1. 暴力解法(暴搜)
  2. 对优化解法的优化:把已经计算过的值,存起来
  3. 记忆化搜索(递归) vs 常规的递归(递推 -> 循环)-> 动态规划

1 斐波那契数

1.1 题目链接

509. 斐波那契数

1.2 题目描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n) 。

 

示例 1:

输入: n = 2
输出: 1
解释: F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入: n = 3
输出: 2
解释: F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入: n = 4
输出: 3
解释: F(4) = F(3) + F(2) = 2 + 1 = 3

 

提示:

  • 0 <= n <= 30

1.3 解法(暴搜 -> 记忆化搜索 -> 动态规划):

算法思路

暴搜:

  • a. 递归含义:给 dfs ⼀个使命,给他⼀个数 n ,返回第 n 个斐波那契数的值;
  • b. 函数体:斐波那契数的递推公式;
  • c. 递归出⼝:当 n == 0 或者 n == 1 时,不⽤套公式。

记忆化搜索

  • a. 加上⼀个备忘录;
  • b. 每次进⼊递归的时候,去备忘录⾥⾯看看;
  • c. 每次返回的时候,将结果加⼊到备忘录⾥⾯。

动态规划

  • a. 递归含义 -> 状态表⽰;
  • b. 函数体 -> 状态转移⽅程;
  • c. 递归出⼝ -> 初始化。

1.4 C++算法代码:

记忆化搜索

class Solution {
    int memo[31]; //memory 备忘录
public:
    int fib(int n) {
        // 初始化
        memset(memo, -1, sizeof(memo));
        return dfs(n);
    }
    int dfs(int n)
    {
        // 往备忘录查找一下
        if(memo[n] != -1)  // 剪枝
        {
            return memo[n];
        }

        if(n == 0 || n == 1)
        {
            memo[n] = n;  // 返回之前放进备忘录里面
            return n;
        }

        memo[n] = dfs(n - 1) + dfs(n - 2);  // 返回之前放进备忘录里面
        return memo[n];
    }
};

动态规划

class Solution {
    int dp[31];
public:
    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];
    }
};

2 不同路径

2.1 题目链接

62. 不同路径

2.2 题目描述

一个机器人位于一个 m x n **网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

 

示例 1:

输入: m = 3, n = 7
输出: 28

示例 2:

输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

示例 3:

输入: m = 7, n = 3
输出: 28

示例 4:

输入: m = 3, n = 3
输出: 6

 

提示:

  • 1 <= m, n <= 100
  • 题目数据保证答案小于等于 2 * 109

2.3 解法(暴搜 -> 记忆化搜索 -> 动态规划):

算法思路

暴搜:

  • a. 递归含义:给 dfs ⼀个使命,给他⼀个下标,返回从 [0, 0] 位置⾛到 [i, j] 位置⼀共有多少种⽅法;
  • b. 函数体:只要知道到达上⾯位置的⽅法数以及到达左边位置的⽅法数,然后累加起来即可;
  • c. 递归出⼝:当下标越界的时候返回 0 ;当位于起点的时候,返回 1 。

记忆化搜索

  • a. 加上⼀个备忘录;
  • b. 每次进⼊递归的时候,去备忘录⾥⾯看看;
  • c. 每次返回的时候,将结果加⼊到备忘录⾥⾯。

动态规划

  • a. 递归含义 -> 状态表⽰;
  • b. 函数体 -> 状态转移⽅程;
  • c. 递归出⼝ -> 初始化。

2.4 C++算法代码:

记忆化搜索

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];
    }
};

动态规划

class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[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 最长递增子序列

3.1 题目链接

300. 最长递增子序列

3.2 题目描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的

子序列

。 

示例 1:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

输入: nums = [0,1,0,3,2,3]
输出: 4

示例 3:

输入: nums = [7,7,7,7,7,7,7]
输出: 1

 

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

3.3 解法(暴搜 -> 记忆化搜索 -> 动态规划):

算法思路

暴搜:

  • a. 递归含义:给 dfs ⼀个使命,给他⼀个数 i ,返回以 i 位置为起点的最⻓递增⼦序列的⻓度;
  • b. 函数体:遍历 i 后⾯的所有位置,看看谁能加到 i 这个元素的后⾯。统计所有情况下的最⼤值。
  • c. 递归出⼝:因为我们是判断之后再进⼊递归的,因此没有出⼝~

记忆化搜索

  • a. 加上⼀个备忘录;
  • b. 每次进⼊递归的时候,去备忘录⾥⾯看看;
  • c. 每次返回的时候,将结果加⼊到备忘录⾥⾯。

动态规划

  • a. 递归含义 -> 状态表⽰;
  • b. 函数体 -> 状态转移⽅程;
  • c. 递归出⼝ -> 初始化。

3.4 C++算法代码:

记忆化搜索

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;
    }
};

动态规划

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n, 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

4.1 题目链接

375. 猜数字大小 II

4.2 题目描述

我们正在玩一个猜数游戏,游戏规则如下:

  1. 我从 1 ****到 n 之间选择一个数字。
  2. 你来猜我选了哪个数字。
  3. 如果你猜到正确的数字,就会 赢得游戏 。
  4. 如果你猜错了,那么我会告诉你,我选的数字比你的 更大或者更小 ,并且你需要继续猜数。
  5. 每当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。如果你花光了钱,就会 输掉游戏 。

给你一个特定的数字 n ,返回能够 确保你获胜 的最小现金数,不管我选择那个数字 。

 

示例 1:

输入: n = 10
输出: 16
解释: 制胜策略如下:
- 数字范围是 [1,10] 。你先猜测数字为 7 。
    - 如果这是我选中的数字,你的总费用为 $0 。否则,你需要支付 $7 。
    - 如果我的数字更大,则下一步需要猜测的数字范围是 [8,10] 。你可以猜测数字为 9 。
        - 如果这是我选中的数字,你的总费用为 $7 。否则,你需要支付 $9 。
        - 如果我的数字更大,那么这个数字一定是 10 。你猜测数字为 10 并赢得游戏,总费用为 $7 + $9 = $16 。
        - 如果我的数字更小,那么这个数字一定是 8 。你猜测数字为 8 并赢得游戏,总费用为 $7 + $9 = $16 。
    - 如果我的数字更小,则下一步需要猜测的数字范围是 [1,6] 。你可以猜测数字为 3 。
        - 如果这是我选中的数字,你的总费用为 $7 。否则,你需要支付 $3 。
        - 如果我的数字更大,则下一步需要猜测的数字范围是 [4,6] 。你可以猜测数字为 5 。
            - 如果这是我选中的数字,你的总费用为 $7 + $3 = $10 。否则,你需要支付 $5 。
            - 如果我的数字更大,那么这个数字一定是 6 。你猜测数字为 6 并赢得游戏,总费用为 $7 + $3 + $5 = $15 。
            - 如果我的数字更小,那么这个数字一定是 4 。你猜测数字为 4 并赢得游戏,总费用为 $7 + $3 + $5 = $15 。
        - 如果我的数字更小,则下一步需要猜测的数字范围是 [1,2] 。你可以猜测数字为 1 。
            - 如果这是我选中的数字,你的总费用为 $7 + $3 = $10 。否则,你需要支付 $1 。
            - 如果我的数字更大,那么这个数字一定是 2 。你猜测数字为 2 并赢得游戏,总费用为 $7 + $3 + $1 = $11 。
在最糟糕的情况下,你需要支付 $16 。因此,你只需要 $16 就可以确保自己赢得游戏。

示例 2:

输入: n = 1
输出: 0
解释: 只有一个可能的数字,所以你可以直接猜 1 并赢得游戏,无需支付任何费用。

示例 3:

输入: n = 2
输出: 1
解释: 有两个可能的数字 1 和 2 。
- 你可以先猜 1 。
    - 如果这是我选中的数字,你的总费用为 $0 。否则,你需要支付 $1 。
    - 如果我的数字更大,那么这个数字一定是 2 。你猜测数字为 2 并赢得游戏,总费用为 $1 。
最糟糕的情况下,你需要支付 $1

 

提示:

  • 1 <= n <= 200

4.3 解法(暴搜 -> 记忆化搜索):

算法思路

暴搜:

  • a. 递归含义:给 dfs ⼀个使命,给他⼀个区间 [left, right] ,返回在这个区间上能完胜的最⼩费⽤;
  • b. 函数体:选择 [left, right] 区间上的任意⼀个数作为头结点,然后递归分析左右⼦树。求出所有情况下的最⼩值;
  • c. 递归出⼝:当 left >= right 的时候,直接返回 0 。

记忆化搜索

  • a. 加上⼀个备忘录;
  • b. 每次进⼊递归的时候,去备忘录⾥⾯看看;
  • c. 每次返回的时候,将结果加⼊到备忘录⾥⾯。

4.4 C++算法代码:

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;
    }
};

5 矩阵中的最长递增路径

5.1 题目链接

329. 矩阵中的最长递增路径

5.2 题目描述

给定一个 m x n 整数矩阵 matrix ,找出其中 最长递增路径 的长度。

对于每个单元格,你可以往上,下,左,右四个方向移动。 你 不能 在 对角线 方向上移动或移动到 边界外(即不允许环绕)。

 

示例 1:

输入: matrix = [[9,9,4],[6,6,8],[2,1,1]]
输出: 4 
解释: 最长递增路径为 [1, 2, 6, 9]。

示例 2:

输入: matrix = [[3,4,5],[3,2,6],[2,2,1]]
输出: 4 
解释: 最长递增路径是 [3, 4, 5, 6]。注意不允许在对角线方向上移动。

示例 3:

输入: matrix = [[1]]
输出: 1

 

提示:

  • m == matrix.length
  • n == matrix[i].length
  • 1 <= m, n <= 200
  • 0 <= matrix[i][j] <= 231 - 1

5.3 解法(暴搜 -> 记忆化搜索 ):

算法思路

暴搜:

  • a. 递归含义:给 dfs ⼀个使命,给他⼀个下标 [i, j] ,返回从这个位置开始的最⻓递增路径的⻓度;
  • b. 函数体:上下左右四个⽅向瞅⼀瞅,哪⾥能过去就过去,统计四个⽅向上的最⼤⻓度;
  • c. 递归出⼝:因为我们是先判断再进⼊递归,因此没有出⼝

记忆化搜索

  • a. 加上⼀个备忘录;
  • b. 每次进⼊递归的时候,去备忘录⾥⾯看看;
  • c. 每次返回的时候,将结果加⼊到备忘录⾥⾯

5.4 C++算法代码:

class Solution {
    int m, n;
    int dx[4] = {1, -1, 0, 0};
    int dy[4] = {0, 0, 1, -1};
    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;
    }
};