• 算法学习-记忆化搜索(持续更新中)


    记忆化搜索可以有效地在搜索的过程中减少重复计算,因此能把原先时间复杂度无法接受的递归操作,变成可以通过的操作。由于在看题解的过程中,记忆化搜索的出现频率也非常高,因此笔者决定开个专题好好学习下相关的知识。

    本文参考:

    宫水三叶的记忆化搜索题单

    相关基础

    记忆化搜索其实是用在深度优先搜索里面很常见的技巧,需要先掌握深度优先的思想,引路->算法学习-深度优先遍历。当搜索的子状态太多,并且子状态结果应该是唯一的时候,可以通过一个记忆cache,将已经计算的状态保存起来。每次计算的时候,如果发现该状态已经被算出,则直接return。

    由于这种单个状态唯一的性质和动态规划的状态相似,记忆化搜索也可以进一步转换为动态规划来做。参考我另一篇关于动态规划的总结,引路->算法学习-动态规划

    相关模板

    在Java中手写一个cache数组进行存储,这是对每个实例的搜索空间记忆化。或者更进一步的,用static cache数组让所有Solution实例只初始化一次,所有实例共用一个记忆化空间。在递归函数中进行记忆化数组的获取和更新。

    class Solution {
        static int N=xxx;
        //所有Solution实例共享一份
        static int[][] cache=new int[N][N];
        public int Main(int n) {
            return dfs(0,n);
        }
       
       //dfs递归
        public int dfs(int l,int r){
        	//base情况直接返回
            if(base情况) return 0;
            //已记忆化结果直接返回
            if(cache[l][r]!=0) return cache[l][r];
            
            //进行一系列递归操作逻辑 
            int ans=0;
    		ans=dfs()+dfs()...
    		
    		//将结果进行记忆化
            cache[l][r]=ans;
            //记忆化后仍然要返回递归结果给调用者
            return ans;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    Java中不一定非要用数组进行存储,同样可以采用HashMap进行记忆化存储,在数据范围合适的情况下,对原来需要存储两个维度的信息能够转换为一个维度的信息。如下面的2400.恰好移动k步到达某一位置的方法数目.

    Python题解中可以直接对递归函数进行cache:

    @cache
    def dfs(x:int, left:int) -> int:         
    
    • 1
    • 2

    相关题目

    375.猜数字大小II

    本质上就是用dfs搜索枚举所有情况,同时由于无论N是什么,每次区间[l,r]里面计算出来的东西其实都是一样的,可以用static int[][] cache保存,这样子所有Solution实例共享一份,可以减少计算消耗。在区间内枚举所有x,考虑往两边猜的可能的最大值,结果是区间枚举结果中所有最大值的最小值。

    class Solution {
        static int N=210;
        //所有Solution实例共享一份
        static int[][] cache=new int[N][N];
        public int getMoneyAmount(int n) {
            return dfs(1,n);
        }
        //[l,r]里面猜数字,保证能够猜出的最小现金数,其实就是将猜数字的最大值最小化
        public int dfs(int l,int r){
            if(l>=r) return 0;
            if(cache[l][r]!=0) return cache[l][r];
            int ans=0x3f3f3f3f;
            //选择猜x,需要保证cur才能确保猜中
            for(int x=l;x<=r;x++){
                //只会有往两边猜的可能
                int cur=Math.max(dfs(l,x-1),dfs(x+1,r))+x;
                //结果为最大值的最小情况
                ans=Math.min(ans,cur);
            }    
            cache[l][r]=ans;
            return ans;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    2400.恰好移动k步到达某一位置的方法数目

    递归函数中返回到某个位置x还剩余left步的情况下,能够有的方案数,将(x,left)进行记忆化。Python题解如下:

    class Solution:
        def numberOfWays(self, startPos: int, endPos: int, k: int) -> int:
            MOD=10**9+7
    
            @cache
            # 返回到某个位置x还剩余left步的情况下,能够有的方案数
            def dfs(x:int, left:int) -> int:
                # 当没法到达终点的时候,包括left=0但是还没到终点
                if abs(x-endPos)>left: return 0
                # 在排除了上面的情况后,剩下的left=0一定是到达终点的
                if(left==0): return 1
                return (dfs(x-1,left-1)+dfs(x+1,left-1))%MOD
            return dfs(startPos,k)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    用Java的HashMap实现记忆化搜索,在数据范围合适的情况下,对原来需要存储两个维度的信息能够转换为一个维度的信息,这题就需要存储当前位置信息和剩余步数信息。

    class Solution {
        int mod=(int)1e9+7;
        int endPos;
        HashMap<Integer,Integer> map;
        public int numberOfWays(int startPos, int _endPos, int k) {
            endPos=_endPos;
            map=new HashMap<>();
            return (int)dfs(startPos,k);
        }
        public long dfs(int x,int left){
            if(Math.abs(x-endPos)>left) return 0;
            if(left==0) return 1;
            //二维信息转一维
            //由于0<=left<=1000,因此可以让当前位置乘以一个大于1000的数再加上它
            int key=x*1005+left;
            if(map.containsKey(key)) return map.get(key);
            int ans= (int)(dfs(x-1,left-1)+dfs(x+1,left-1))%mod;
            map.put(key,ans);
            return ans;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    当时只想着动态规划能做,但是始终不知道如何表示恰好走几步以及走的位置,还有当走出去又走回来怎么办,看朋友的题解才想到,开辟二维数组表示,每轮都暴力枚举所有的空间状态就可以了,某个位置的方法数目只和其左右两边有关。因此用二维动态规划,dp[i][j]代表最多走i步到下标j的方法数目。因为会往左右两边都走,因此出发点设置在中间。为了计算最终状态dp[k][1005+endPos-startPos],每轮的j都需要从开始的位置枚举到最多走i步,其状态只和左右两边状态有关。

    class Solution {
        public int numberOfWays(int startPos, int endPos, int k) {
            int[][]dp=new int[k+1][2010];
            dp[0][1005]=1;
            int mod=(int)1e9+7;
            for(int i=1;i<=k;i++){
                for(int j=0;j<=i;j++){
                    dp[i][1005+j]=(dp[i-1][1005+j-1]+dp[i-1][1005+j+1])%mod;
                    dp[i][1005-j]=(dp[i-1][1005-j-1]+dp[i-1][1005-j+1])%mod;
                }
            }
            return dp[k][1005+endPos-startPos];
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    808.分汤

    深度优先搜索的解法:首先需要对题目进行抽象,我们用 p r o b ( a , b ) prob(a, b) prob(a,b) 表示汤 A 剩余 a 毫升,汤 B 剩余 b 毫升时的概率。那么由全概率公式:prob(a, b) = prob(a1, b1)*0.25 + prob(a2, b2)*0.25 + prob(a3, b3)*0.25 + prob(a4, b4)*0.25, 其中 ai, bi 表示执行操作 i 之后,汤 A 和汤 B 中剩余汤的容量,其中的 p r o b ( a , b ) prob(a, b) prob(a,b)在满足条件的情况下可以继续递归下去,递归的base返回条件就是判断a,b的分光情况,全概率公式到最后用0,1表示base事件发生的概率,前面一堆系数乘起来就是最终发生的概率。但是由于这样子很难估计递归树的层数,在n=800的时候就会出现超时TLE。

    class Solution {
        HashMap<Integer,int[]> methods=new HashMap<>();
    
        public double soupServings(int n) {
            methods.put(1,new int[]{100,0});
            methods.put(2,new int[]{75,25});
            methods.put(3,new int[]{50,50});
            methods.put(4,new int[]{25,75});
            return dfs(n,n);
    
        }
    
        private double dfs(int a,int b){
            // base情况
            // 全概率公式到最后用0,1表示该事件发生的概率
            // a被分光
            if (a<=0){
                // a,b同时分配完
                if(b<=0) return 0.5;
                // a先被分配完
                else return 1;
            }
            // b先被分配完
            if (a>0 && b<=0) return 0;
            // 都没被分配完则继续进行下面的分配
            double res=0;
            for (int i=1;i<=4;i++){
                int[]m=methods.get(i);
                res+=0.25*dfs(a-m[0],b-m[1]);
            }
            return res;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    改用下面的记忆化搜索,当n=660295675时报了内存爆炸,显然是double[][] cache=new double[n+1][n+1];开辟空间太大了,但是已经很大程度上提升了前面n的范围:

    class Solution {
        HashMap<Integer,int[]> methods=new HashMap<>();
        public double soupServings(int n) {
            double[][] cache=new double[n+1][n+1];
            methods.put(1,new int[]{100,0});
            methods.put(2,new int[]{75,25});
            methods.put(3,new int[]{50,50});
            methods.put(4,new int[]{25,75});
            return dfs(n,n,cache);
    
        }
    
        private double dfs(int a,int b,double[][]cache){
            // 全概率公式到最后用0,1表示该事件发生的概率
            // a被分光
            if (a<=0){
                // a,b同时分配完
                if(b<=0) return 0.5;
                // a先被分配完
                else return 1;
            }
            // b先被分配完
            if (a>0 && b<=0) return 0;
            // 已经被记忆化
            if (cache[a][b]!=0) return cache[a][b];
    
            // 都没被分配完则继续进行下面的分配
            double res=0;
            for (int i=1;i<=4;i++){
                int[]m=methods.get(i);
                res+=0.25*dfs(a-m[0],b-m[1],cache);
            }
            //记忆化
            cache[a][b]=res;
            return res;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    由于题目给的 0 < = n < = 10 ​​ ​ 9 0 <= n <= 10​​​^9 0<=n<=10​​9过大所以会爆空间,这里就需要用到题目中的条件“返回值在正确答案 1 0 − 5 10^{-5} 105 的范围内将被认为是正确的”,当n越大时,在所有分配条件中,汤A被先分配完的几率就越趋近于1,因为四种方案中 汤 A 分配 > 汤 B 分配 汤A分配>汤B分配 A分配>B分配的方案多,这里我们就取 n = 5000 n=5000 n=5000

    class Solution {
        HashMap<Integer,int[]> methods=new HashMap<>();
        public double soupServings(int n) {
            // 概率论知识
            if(n>=5000) return 1;
            double[][] cache=new double[n+1][n+1];
            methods.put(1,new int[]{100,0});
            methods.put(2,new int[]{75,25});
            methods.put(3,new int[]{50,50});
            methods.put(4,new int[]{25,75});
            return dfs(n,n,cache);
    
        }
    
        private double dfs(int a,int b,double[][]cache){
            // 全概率公式到最后用0,1表示该事件发生的概率
            // a被分光
            if (a<=0){
                // a,b同时分配完
                if(b<=0) return 0.5;
                // a先被分配完
                else return 1;
            }
            // b先被分配完
            if (a>0 && b<=0) return 0;
            // 已经被记忆化
            if (cache[a][b]!=0) return cache[a][b];
    
            // 都没被分配完则继续进行下面的分配
            double res=0;
            for (int i=1;i<=4;i++){
                int[]m=methods.get(i);
                res+=0.25*dfs(a-m[0],b-m[1],cache);
            }
            //记忆化
            cache[a][b]=res;
            return res;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    329.矩阵中的最长递增路径

    DFS,dfs(int px,int py)表示从matrix[px,py]出发的最长递增的长度,从每个点出发进行dfs,路径总数为 O ( m ∗ n ∗ 2 m n ) O(m*n*2^{mn}) O(mn2mn),直接TLE。

    class Solution {
        int[][]map;
        int[][]dir={{1,0},{-1,0},{0,1},{0,-1}};
        int m,n;
        public int longestIncreasingPath(int[][] matrix) {
            map=matrix;
            int res=1;
            m=matrix.length;
            n=matrix[0].length;
            // 每个点出发进行dfs
            for(int i=0;i<m;i++){
                for(int j=0;j<n;j++){
                    res=Math.max(res,dfs(i,j));
                }
            }
            return res;
        }
    
        // 从matrix[px,py]出发的最长递增的长度
        public int dfs(int px,int py){
            int ans=1;
            for(int i=0;i<4;i++){
                int nextx=px+dir[i][0];
                int nexty=py+dir[i][1]; 
                // 没有把return base放到下一层去,直接上一层全部判断完
                // 主要是因为只能走递增路径,放到下一层不太好判断了
                if(nextx<0||nextx>=m||nexty<0||nexty>=n) continue;
                if(map[nextx][nexty]<=map[px][py]) continue;
                ans=Math.max(ans,1+dfs(nextx,nexty));
            }
            // 当没有更max的ans直接return 1 
            return ans;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    考虑到在主循环中不断更新res的过程中,可以利用前面已经计算出来的状态,因此采用记忆化搜索的优化,有望将复杂度降至 O ( k ∗ m ∗ n ) O(k*m*n) O(kmn),相当于遍历几次地图而非选择各种路径。

    class Solution {
        int[][]map;
        int[][]dir={{1,0},{-1,0},{0,1},{0,-1}};
        int m,n;
        int[][]cache;
        public int longestIncreasingPath(int[][] matrix) {
            map=matrix;
            int res=1;
            m=matrix.length;
            n=matrix[0].length;
            cache=new int[m][n];
            // 每个点出发进行dfs
            for(int i=0;i<m;i++){
                for(int j=0;j<n;j++){
                    // 前面遍历节点的时候已经把后面遍历过了(尽可能搜索到底了)
                    // 因此只有后面还没有被搜索过的起点有可能变大
                    if(cache[i][j]==0) res=Math.max(res,dfs(i,j));
                }
            }
            return res;
        }
    
        // 从matrix[px,py]出发的最长递增的长度
        public int dfs(int px,int py){
            if(cache[px][py]!=0) return cache[px][py];
            int ans=1;
            for(int i=0;i<4;i++){
                int nextx=px+dir[i][0];
                int nexty=py+dir[i][1]; 
                // 没有把return base放到下一层去,直接上一层全部判断完
                // 主要是因为只能走递增路径,放到下一层不太好判断了
                if(nextx<0||nextx>=m||nexty<0||nexty>=n) continue;
                if(map[nextx][nexty]<=map[px][py]) continue;
                ans=Math.max(ans,1+dfs(nextx,nexty));
            }
            // 当没有更max的ans直接return 1
            cache[px][py]=ans;
            return ans;
        }
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
  • 相关阅读:
    【前端】使用promise解决地狱回调问题
    【Spring从入门到实战】第1讲:为什么要学习Spring框架?
    2022年双十一好物分享,数码好物选购指南
    【汇编】“转移”综述、操作符offset、jmp指令
    ESP01S通过心知天气获取天气和时间信息
    基于MYSQL的论坛管理系统数据库设计项目实战
    互帮互助,小bug记录:libcurl timeout不起作用,程序无法结束
    在 HarmonyOS 上实现 ArkTS 与 H5 的交互
    SpringBoot-数据库操作
    Ubuntu20安装docker实践纠正版
  • 原文地址:https://blog.csdn.net/qq_44036439/article/details/126335371