集训 搜索剪枝,记忆化搜索
DM1-1 started this conversation in Algorithm
集训 搜索剪枝,记忆化搜索
DM1-1
搜索剪枝
概述
相信大家对搜索都不陌生,搜索是一个非常有用的算法,同时他的时间复杂度也是非常高的,一般来说都是指数级的复杂度。正是因为搜索的复杂度过高,所以我们直接使用搜索可能不能满足题目的时间要求,才需要来优化搜索,于是也引入了今天的内容:搜索剪枝。
剪枝是搜索中常用的优化手段,在特定条件下可以把指数级的复杂度优化至近似多项式的复杂度。为什么叫剪枝,因为我们搜索的时候,你画一幅图理解的话,他就是一棵树,这棵树的部分枝叶是没有不会产生答案或者不必要的,我们就这些枝叶减掉(也就是不对他们进行后续的搜索)。那有关剪枝,就是两个问题,什么时候剪枝?剪那些枝?
BFS中的剪枝
BFS中的剪枝主要是判重。我先拿一道题来举例。
给出一个
n
×
n
的棋盘和一个起点,每次你可以向上向下向左向右走一步,让你输出从起点到棋盘上每一个点的最短路径。相信大家都知道,这就是一个 BFS 的模板题,但是大家有没有思考过为什么这道题的时间复杂度是
O
(
n
2
)
的,也就是所有节点的数量?
因为我们在BFS的过程中进行了判重,判重就是一种剪枝。简单的说,BFS的原理就是逐步地扩展下一层,把扩展出来的下一层放入队列中。在处理当前层的时候,就会把下一层的点放到队列的尾部。所以在队列中,只包含着相邻两层的点。若点重复出现,我们是只需要处理一次的,所以就保证了进入过队列的点的总数是
n
×
n
。所以时间复杂度也就是
O
(
n
2
)
的了。
例题:八数码问题
我们分析这个问题,如果我们直接去搜索,每个状态最多可以扩展出4个状态,那么我们进行 x 次扩展,就会有
4
x
种跳法,这个指数级的复杂度是不可能可以承受的。可以发现这个棋盘的布局一共有
9
!
362880
种情况。画出一棵搜索树,如图所示。我们不对重复进行的状态进行搜索,也就是做一个判重,这样就能保证在最坏情况下,出现的状态数不超过362880,那这个题目就是可以在正常的时间复杂度内完成的。
那关于这道题题目的思路就讲到这里了,具体实现还有一定难度以及一些技巧,大家课后做题的时候自行学习。
关于做题就要买一送一这件事:这里有一道题,是八数码问题的变式&&蓝桥杯真题,感兴趣可以做一下:2017年蓝桥杯A组省赛 - 跳蚱蜢
DFS中的剪枝
说完了BFS,再说DFS。DFS的剪枝技术很多,在这里我只介绍一下DFS常用的几种剪枝手段。
可行性剪枝:当目前状态和题意不符,并且由于题目可以推出,往后的所有情况和题意都不符,那么就可以进行剪枝,直接把这种情况及后续的所有情况判断不可行,直接返回。
排除等效冗余:当几个子树具有完全相同的效果的时候,只选择其中一个搜索。
搜索顺序剪枝:不同的搜索顺序会导致搜索树形态差异很大,那么时间复杂度自然差异也很大。比如说我们搜索一个最小值,那肯定是从最小的节点开始搜索,而非从最大的节点开始搜索。一般来讲,有单调性存在的搜索问题可以和贪心思想结合,进行顺序剪枝。
最优性剪枝:当搜索还没结束的时候,当前记录的状态已经比当前保存的最优解更劣,就没有必要在继续搜索下去了,因为他已经不可能成为我们的最优解了。
直接看这些概念,会觉得很抽象,需要在做题的过程中逐渐理解这些概念以及形成对应的思想。
下面详细来讲解一道例题:P1120-小木棍
乔治有一些同样长的小木棍,他把这些木棍随意砍成几段,直到每段的长都不超过 50。现在,他想把小木棍拼接成原来的样子,但是却忘记了自己开始时有多少根木棍和它们的长度。给出每段小木棍的长度,编程帮他找出原始木棍的最小可能长度。
数据:
1
≤
n
≤
65
,
1
≤
a
i
≤
50
我来讲解一下这道题该怎么做。
首先提炼一下题意,题目的意思是将若干木棍拼接为若干根长度相同的原始木棍(下文用木棒指代原始木棍),让我们输出可以通过拼接木棍形成的最小的木棒长度。
可以发现木棒的取值范围是最长的木棍到所有木棍的和,这是比较小的一个范围,可以考虑枚举。
可以发现木棒的长度必须被所有的木棍的和乘除,因为木棒都是整数,所有拼凑起来的木棒也一定是整数。这是一步大剪枝。
我们可以开始思考一个朴素的 dfs 函数该怎么写了,需要以下参数:已经拼了几根木棒,当前在拼的木棒的长度,还有枚举的木棒长度。
到这一步,还是一个比较朴素的搜索。按照上面的思路写出来的话,只有30pts,TLE了70pts。
所以看得出,上面的搜索是需要剪枝优化的。
下面我们接着想怎么优化,我们回顾一下上面的几种剪枝手段,我们先试试搜索顺序剪枝。比如说我们把木棍从短到长排序,长度小的木棍组合起来需要很多段,那这样就会余下大量的长度大的木棍,他们是难以拼接的,这就会造成我们做了很多无效的搜索,导致搜索树规模过大。反之,先用长木棍拼,然后用短的木棍去补剩下的部分,就会灵活很多。所以我们应该把木棍按长度进行降序排序。排序后,变成了36pts。还需要进一步优化。
从大到小排序再结合“不降原则”,保证是降序选取木棍且选取方案不重复。优化至54pts。
接着是排除等效冗余剪枝,我们很容易想到,若干个长度相同的木棍,若是第一根就失败的话,那么其他长度相同的也不必试了。这也是一个很有效的剪枝,不过与下一个剪枝有大量重叠,所以我代码中就没有写。
最后这个是最难想,同时也是优化最大的一句。两行代码就让54pts -> 100pts,也就是AC本题。
总结一下:本题使用了各种剪枝手段,是一道加强搜索剪枝理解的好题,难度较大。推荐大家反复思考,理解代码后自行尝试编写程序。
include <bits/stdc++.h>
using namespace std;
const int N = 70;
int n, sum;
int cnt[N]; //桶
int a[N];
int max_len, min_len = 1e9;
//num:当前还剩几根木棒没有拼好,now:现在正在拼的这根木棒的长度,len:规定的木棒长度,last:上一次枚举到哪了
bool dfs(int num, int now, int len, int last)
{
if(num == 0) //全部拼好了
return true;
if(now == len) //当前这根木棒拼好了
return dfs(num - 1, 0, len, max_len);
for(int i = last; i >= min_len; i --) //从大到小枚举
{
if(cnt[i] && now + i <= len) //如果可行,那就搜一下
{
cnt[i] --;
if(dfs(num, now + i, len, i))
return true;
cnt[i] ++; //回溯
if(now == 0 || now + i == len)
return false;
//关键也十分难想的一步剪枝 54pts->100pts
//首先是now = 0,那就是长度为i的木棍没有被用上,即已经失败了(因为拼接成功是需要每一根木棒的),因为如果成功的话,上面的dfs就会返回true。
//now + i == len,这时候选长度为i的木棍一定是最优解,因为越长的越不灵活。换句话说,这个位置填长的话都不能拼接成功,那用短的填这里就显然更不能拼接成功了。所以上面没成功的话,后面的搜索已经不可行了。
//所以出现以上两种情况就不需要再继续枚举了,直接回溯
}
}
return false; //没有可行方案
}
int main()
{
ios::sync_with_stdio(false);cin.tie(0);
cin >> n;
for(int i = 1; i <= n; i ++)
{
int x; cin >> x;
max_len = max(x, max_len);
min_len = min(x, min_len);
cnt[x] ++; sum += x;
}
for(int len = max_len; len <= sum; len ++) //从小到大枚举答案,当len == sum时必定有解
{
if(sum % len == 0) //易知,木棍总长度只有是木棒的长度的倍数,才是有解的。
{
if(dfs(sum / len, 0, len, max_len))
{
cout << len << '\n';
break;
}
}
}
}
其他的一些搜索优化算法
双向搜索
当我们遇到一种问题:起点和终点已知,求起点到终点的最少步数。如果只从起点进行搜索的话,那搜索树规模会过大。假设搜索树是 x 叉的搜索树,他就会有
x
n
种可能的状态。
但如果这个时候我们从起点和终点一起搜索的话,搜索出来的状态数就可以降到
2
×
x
n
2
,直接开了一个平方,对时间复杂度的优化是非常大的。具体到算法上,有两种类型:双向 BFS 的双向迭代加深。这里不做详细解释,主要是做科普,感兴趣的同学可以自行了解。
启发式搜索
回忆BFS的实现过程,想要寻找一个目的地,在大多数情况下,基本会走遍所有的节点,比如搜索
(
0
,
0
)
−
(
n
,
n
)
的题目。这就体现了BFS的一个「盲目性」。为了避免这样盲目的搜索,我们引入了启发式搜索,启发式搜索是有「方向性」的。
我们想象一个这样的搜索,他是每次移动都基于贪心思想的移动,比如说直接前往可以移动的离终点最近的节点,这样搜索的速度是非常快的,但是这样的做法很容易被卡住。
例如这样构造一个地图,显然他会被卡在墙那里,得到一个错误的解。
..........
.#######..
.......#..
S......#E.
.......#..
.#######..
..........
怎么解决这种因为纯粹的贪心无法得到正确解的情况呢?我们再引入一个概念叫估价函数,这个估价函数,就是启发式搜索的核心。我们把贪心和朴素的搜索结合,假设当前状态为 x,那么可以得到一个对当前状态的估价函数:
f
(
x
)
g
(
x
)
+
h
(
x
)
我们来解释一下这个公式,其中
g
(
x
)
是从起点到当前状态的代价(譬如:走过的步数);
h
(
x
)
则是预计到达终点需要的代价。正是因为
h
(
x
)
,所以正确解不会被漏掉。如何理解这两个函数呢?
若
h
(
x
)
0
那么这个算法就是朴素的BFS。
若
g
(
x
)
0
那么这个算法就是朴素的贪心搜索 。
通过这个估价函数
f
(
x
)
,我们就可以避开很多不需要搜索的点,从而大大优化了搜索的过程。
记忆化搜索
引出记忆化搜索
首先,我们要知道为什么需要记忆化搜索。
大家应该都很熟悉这个计算fib问题的递归式写法。代码如下
long long fib(int x)
{
if(x == 1) return 1;
return fib(x - 1) + fib(x - 2);
}
这个代码的时间复杂度是多少,为什么是这样算的?
下面这个是fib问题的动态规划解法,时间复杂度是\(O(N)\)的
f[1] = f[2] = 1;
for(int i = 3; i <= n; i ++)
f[i] = f[i - 1] + f[i - 2];
为什么对同一个问题进行求解,递归求解是指数级的时间复杂度,而动态规划就是线性的复杂度呢?因为递归的过程中,我们进行了大量重复计算。
image-20221227145852850
以这张图为例,在计算 fib(5) 的时候,就进行了多次的重复运算。
因此,为了解决大量重复计算的递归式,记忆化搜索这个概念就被提出了。
记忆化搜索
上面的做法为什么效率低下呢?因为同一个状态会被访问多次。
如果我们每查询完一个状态后将该状态的信息存储下来,再次需要访问这个状态就可以直接使用之前计算得到的信息,从而避免重复计算。这充分利用了动态规划中很多问题具有大量重叠子问题的特点,属于用空间换时间的「记忆化」思想。
所以,记忆化的基本思想就是:我们对一些状态的答案进行记录,多次访问这个状态的时候,直接返回结果,避免重复计算。
具体到代码上,我们就需要通过增加一个数组 f ,来记录每个
d
f
s
(
x
)
的返回值。一开始的时候数组 f 默认都为0。每次访问一个状态的时候,若数组中已经存储了一个值,直接返回值;反之,递归访问这个状态,并且将得到的运算结果保存到数组里。这样处理呢,我们就可以保证了每个状态只会被访问一遍。
long long f[N];
long long fib(int x)
{
if(f[x]) return f[x]; //关键之处
if(x == 1) return 1;
long long res = fib(x - 1) + fib(x - 2);
f[x] = res;
return res;
}
下面给出记忆化搜索的核心代码(模板)
返回类型 dfs(参数)
{
if(边界条件)
return 结果;
if(当前状态已计算过)
return 记录的状态结果;
搜索
保存计算结果至数组
return 计算结果;
}
对于一个状态由多个变量表示的数组,要保证参数与数组的维度相同,每对参数都代表一个唯一的状态。
int f[N][N][N];
int dfs(int a, int b, int c)
{
...
if(f[a][b][c] != 0) return f[a][b][c];
...
}
因为我们需要判断某个状态是否已经被计算过,我们需要对数组进行初始化,一般可以是0,但是当搜索结果可能为0的时候,要把数组初始化为 -1 或者其他特殊值。如下方代码所示。
int dfs(int x)
{
...
if(f[x] != -1) return f[x];
...
}
for(int i = 1; i <= n; i ++)
f[i] = -1;
关于记忆化搜索的理解
对于记忆化搜索时间复杂度的计算,可以直接计算所有状态被访问的总次数。
求解动态规划时,记忆化搜索和递推其实是同一个问题,他们都保证了同一状态最多被计算一次,只是采用了不一样的实现方法:递推是通过指定的访问顺序避免重复访问;而记忆化搜索是通过对状态的标记避免重复访问。这两种实现方式各有千秋,递推的话,运行效率更高,可以用滚动数组优化空间;记忆化搜索往往在实现难度上更低,处理边界更为轻松。需要结合题目的实际情况来选择这两种写法。
一般来说,递推和记忆化搜索是可以相互转换的,也就是一道题两种写法都可以解决。但是也有一些特例:
当题目的状态转移存在复杂拓扑结构,或者需要依赖哈希来存储状态的时候,递推就难以实现;
当题目需要用到DP优化,如前缀和优化,斜率优化时,记忆化搜索就无法实现。
记忆化搜索与DAG的关系
记忆化搜索的搜索过程,如果画出来的话,其实就是一个有向无环图(DAG),之所以记忆化搜索可以等效于递推的原因就是因为DAG中不存在返祖边(后续的状态无法通过任何途径返回到之前转移到自己的那些状态,说人话就是图里不存在环),那么这就保证了状态转移的无后效性。还是拿 fib 来举例。
image-20221227164156048
这个图中的点,表示的是状态,这些有向边表示的是我们转移的方向。记忆化搜索的时间复杂度就是画出的搜索树中的边的数量。
我们结合图来回顾一下上面说过的,通过数组存储计算结果来避免重复搜索。因为无论第几次到达一个状态的时候,会跟之前一样做出相同的转移决策。所以我们就可以用记忆化的思想来存储状态来避免重复的搜索。
所以当一个动态规划问题可以被抽象成DAG上的动态规划的话,用记忆化搜索来解决题目是一个非常好的选择。当然,因为是单向边,也可以对DAG拓扑排序之后进行递推,但是这样做会比较复杂,一般不推荐。
除此之外,还有一些非DAG的有向图问题,可以通过强连通分量的缩点将其变为DAG,然后在DAG上跑记忆化搜索。大家在学习的过程中要注意这种奇淫巧技的积累,充实自己的武器库。
题单
基础组题目
题目 Tag 难度 题解链接
洛谷P1036 DFS + 不降原则 ★ 👍
HDU1495 BFS ★★ 👍
洛谷P1379 BFS + 二维转一维 ★★★ 👍
POJ 2531 DFS ★★★ 👍
HDU1010 DFS + 方格图奇偶判断 + 可行性剪枝 ★★★ 👍
洛谷P1120 DFS + 剪枝技巧综合 ★★★★ 👍
计蒜客T1408 DAG上记忆化搜索 ★★ 👍
洛谷P1434 DAG上记忆化搜索 ★★ 👍
洛谷P3183 DAG上记忆化搜索 ★★ 👍
POJ 2287 记忆化搜索 贪心 ★★ 👍
进阶组题目(选做,题解待更新)
题目 Tag 难度 题解链接
POJ 3900(典) 搜索剪枝 ★★★
abc_220d 记忆化搜索 ★★★
洛谷P2476 记忆化搜索 ★★★★
abc_271f 双向搜索 ★★★ 👍
POJ 2249 K短路 A* ★★★★

浙公网安备 33010602011771号