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

文章目录

  • 相关基础
    • 相关模板
  • 相关题目
        • 375.猜数字大小II
        • 2400.恰好移动k步到达某一位置的方法数目
        • 808.分汤
        • 329.矩阵中的最长递增路径

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

本文参考:

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

相关基础

记忆化搜索其实是用在深度优先搜索里面很常见的技巧,需要先掌握深度优先的思想,引路->算法学习-深度优先遍历。当搜索的子状态太多,并且子状态结果应该是唯一的时候,可以通过一个记忆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;
    }
}

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

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

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

相关题目

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

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

当时只想着动态规划能做,但是始终不知道如何表示恰好走几步以及走的位置,还有当走出去又走回来怎么办,看朋友的题解才想到,开辟二维数组表示,每轮都暴力枚举所有的空间状态就可以了,某个位置的方法数目只和其左右两边有关。因此用二维动态规划,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];
    }
}
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;
    }
}

改用下面的记忆化搜索,当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;
    }
}

由于题目给的 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;
    }
}
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;
    }
}

考虑到在主循环中不断更新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;
    }
    
}

你可能感兴趣的:(算法人生,算法,学习,深度优先)