记忆化搜索可以有效地在搜索的过程中减少重复计算,因此能把原先时间复杂度无法接受的递归操作,变成可以通过的操作。由于在看题解的过程中,记忆化搜索的出现频率也非常高,因此笔者决定开个专题好好学习下相关的知识。
本文参考:
宫水三叶的记忆化搜索题单
记忆化搜索其实是用在深度优先搜索里面很常见的技巧,需要先掌握深度优先的思想,引路->算法学习-深度优先遍历。当搜索的子状态太多,并且子状态结果应该是唯一的时候,可以通过一个记忆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:
本质上就是用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;
}
}
递归函数中返回到某个位置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];
}
}
深度优先搜索的解法:首先需要对题目进行抽象,我们用 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<=109过大所以会爆空间,这里就需要用到题目中的条件“返回值在正确答案 1 0 − 5 10^{-5} 10−5 的范围内将被认为是正确的”,当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;
}
}
DFS,dfs(int px,int py)
表示从matrix[px,py]出发的最长递增的长度,从每个点出发进行dfs,路径总数为 O ( m ∗ n ∗ 2 m n ) O(m*n*2^{mn}) O(m∗n∗2mn),直接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(k∗m∗n),相当于遍历几次地图而非选择各种路径。
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;
}
}