【学习笔记】记忆化搜索
记忆化搜索
建议搭配食用。
前置知识:
- 深度优先搜索 DFS
概念:
搜索通常通过递归来实现,但是递归过程中往往有很多结果被重复计算,因此降低了搜索的效率。
因此记忆化搜索就是再递归的过程中记录已经遍历过的状态与结果,用到的时候再直接取出,避免了重复运算。
实现:
oiwiki 讲的很好,但是我要再赘述一遍:
[NOIP2005 普及组] 采药
题目描述
辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”
如果你是辰辰,你能完成这个任务吗?
输入格式
第一行有 \(2\) 个整数 \(T\)(\(1 \le T \le 1000\))和 \(M\)(\(1 \le M \le 100\)),用一个空格隔开,\(T\) 代表总共能够用来采药的时间,\(M\) 代表山洞里的草药的数目。
接下来的 \(M\) 行每行包括两个在 \(1\) 到 \(100\) 之间(包括 \(1\) 和 \(100\))的整数,分别表示采摘某株草药的时间和这株草药的价值。
输出格式
输出在规定的时间内可以采到的草药的最大总价值。
样例 #1
样例输入 #1
70 3
71 100
69 1
1 2
样例输出 #1
3
提示
【数据范围】
- 对于 \(30\%\) 的数据,\(M \le 10\);
- 对于全部的数据,\(M \le 100\)。
【题目来源】
NOIP 2005 普及组第三题。
这道题其实我记得我是用背包 dp 做的,但是今天我们先来进行一个朴素的 DFS 做法。
代码写了注释了,学过 dfs 都能懂
朴素搜索
#include<bits/stdc++.h>
using namespace std;
typedef long double llf;
typedef long long intx;
const int maxn=105;
int n,t;
//总共n株草药,总时间为t
int cost[maxn],val[maxn];
//cost是草药的花费时间,val是权值
int ans;
void input(){
scanf("%d %d",&t,&n);
for(int i=1;i<=n;++i){
scanf("%d %d",&cost[i],&val[i]);
}
}
void dfs(int pos,int lost,int sval){
//pos记录当前准备选第几个物品,lost表示剩下的时间,sval表示已获得的价值
if(lost<0) return;
//不剩时间了没有继续的必要
if(pos==n+1){
//草药已经摘到最后一株,更新答案后返回
ans=max(ans,sval);
return;
}
dfs(pos+1,lost,sval);
dfs(pos+1,lost-cost[pos],sval+val[pos]);
//选或者不选
}
int main(){
input();
dfs(1,t,0);
printf("%d\n",ans);
return 0;
}
然后 T 啦!!
同一状态会被访问多次,比如我们把采草药叫做状态 \(1\),不采草药叫做 \(0\)。
(利用了状态压缩的思想解释被访问多次的状态,但是这道题由于 \(n\) 范围过大不适合状压 dp 来做)
对于 \(n==12\),状态 \(1\) \(0\) \(0\) \(1\) \(1\) \(1\) 来说,之后还有六个状态是未知的,但是这个状态会被重复走很多次。
具体想知道运行时间有多大差距可以看木偶Roy的运行结果对比。
那么我们专门来为这个状态开一个数组 \(memo_{pos,lost}\) 表示记录的搜索返回值 \(sval\),再次访问的时候直接返回数值即可,这样也使得 dfs 省了一个参数 \(sval\)。
而对于初始化来讲,我们可以使其值为一个绝对不可能的值表示没有搜索到过(本题为 \(-1\))。
有代码实现:
记忆化搜索
#include<bits/stdc++.h>
using namespace std;
#define MYMIN -191981000
typedef long double llf;
typedef long long intx;
const int maxn=105,maxm=1005;
int n,t;
//总共n株草药,总时间为t
int cost[maxn],val[maxn];
//cost是草药的花费时间,val是权值
int memo[maxn][maxm];
//记忆状态
int ans;
void input(){
scanf("%d %d",&t,&n);
for(int i=1;i<=n;++i){
scanf("%d %d",&cost[i],&val[i]);
}
}
int dfs(int pos,int lost){
//pos记录当前准备选第几个物品,lost表示剩下的时间
if(memo[pos][lost]!=-1) return memo[pos][lost];
//如果已经访问过就直接返回
if(pos==n+1){
//草药已经摘到最后一株
return memo[pos][lost]=0;
}
int dfs1,dfs2=MYMIN;
dfs1=dfs(pos+1,lost);
//不采药
if(lost>=cost[pos]){
//如果还能继续采才有dfs2
dfs2=dfs(pos+1,lost-cost[pos])+val[pos];
}
return memo[pos][lost]=max(dfs1,dfs2);
//将当前状态存下来,如果没有dfs2对结果无影响,因为dfs2的初始值是最小值
}
int main(){
memset(memo,-1,sizeof(memo));
input();
ans=dfs(1,t);
printf("%d\n",ans);
return 0;
}
对于这道题我们总结一下记忆化搜索的特点:
-
不依靠外部变量 \(ans\) 记录,往往意味着函数具有返回值。
-
因为答案是返回值,所以不成为函数的参数。
-
对于一组参数,其返回值相同。
记忆化搜索与递推
求动态规划的问题上,记忆化搜索与递推还蛮像的,毕竟记忆化搜索就是用递推递归实现的,oiwiki 给了一个代码比对,我就不写了:
递推
const int maxn = 1010;
int n, t, w[105], v[105], f[105][1005];
int main() {
cin >> n >> t;
for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];
for (int i = 1; i <= n; i++)
for (int j = 0; j <= t; j++) {
f[i][j] = f[i - 1][j];
if (j >= w[i])
f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]); // 状态转移方程
}
cout << f[n][t];
return 0;
}
递推和记忆化搜索都保证了同一状态只被求解一次,因此复杂度相近。
但是他们的实现方式确实不同的:
-
递推通过明确的访问顺序来避免重复访问(如代码中的 \(i\) 与 \(j\) 都是有顺序的访问)。
-
记忆化搜索通过打标记的方式避免重复访问。(如 \(-1\) 表示没有访问过)
两者各有优劣,很多递推可以用滚动数组优化,而且没有递推过程。
(滚动数组:动态规划中当前状态只与上一状态有关时,用 \(cur\) 表示当前状态,\(cur\)^\(1\) 表示上一状态,可以将数组某一维的空间降为 \(2\)).
而记忆化搜索更好实现,有时在树上问题极为常见,而且还作为 数位 dp 的前置知识存在,与众多动态规划相连。