目录
图文详解两种算法:深度优先遍历(DFS)和广度优先遍历(BFS)
什么是搜索?搜索用来干什么?
深度优先遍历
深搜过程图解
1、递归实现
2、非递归实现
广度优先遍历
DFS vs BFS
搜索的解题步骤
搜索中的常用术语
搜索的一些优化
剪枝
其他的剪枝策略:
习题演练
阅读本文前,请确保你已经掌握了递归、栈和队列的基本知识,如想掌握搜索的代码实现,请确保你能够用代码实现栈和队列的基本操作。
深度优先遍历(Depth First Search, 简称 DFS) 与广度优先遍历(Breath First Search,简称 BFS)是图论中两种非常重要的算法,也是进行更高的算法阶段学习的最后一道门槛。搜索算法频繁出现在算法竞赛题中, 尤其是深度优先搜索,在竞赛中,它是用来进行保底拿分的神器!
本文将会从以下几个方面来讲述深度优先遍历,广度优先遍历,相信大家看了肯定会有收获。
什么是搜索?
搜索用来干什么?
深度优先遍历,广度优先遍历介绍
DFS vs BFS搜索的解题流程
搜索中的常用术语
搜索的一些优化
习题演练
注:搜索本质上就是枚举,只不过是一种有策略的枚举
搜索算法是利用计算机的高性能来有目的的穷举一个问题解空间的部分或所有的可能情况,从而求出问题的解的一种方法。现阶段一般有枚举算法、深度优先搜索、广度优先搜索、A*算法、回溯算法、蒙特卡洛树搜索、散列函数等算法。搜索本质上就是枚举,只不过是一种有策略的枚举, 通常在搜索前,根据条件降低搜索规模;根据问题的约束条件进行剪枝;利用搜索过程中的中间解,避免重复计算这几种方法进行优化 。
搜索算法实际上是根据 初始条件 和 扩展规则 构造一棵“解答树”并寻找符合目标状态的节点的过程。所有的搜索算法从最终的算法实现上来看,都可以划分成两个部分——控制结构(扩展节点的方式)和产生系统(扩展节点),而所有的算法优化和改进主要都是通过修改其控制结构来完成的。我们所熟悉的最常用的搜索算法:深度优先搜索和广度优先搜索就是有两种不同的控制结构(策略)的搜索算法。
其实,在这样的思考过程中,我们已经不知不觉地将一个具体的问题抽象成了一个模型——树,即搜索算法的使用 第一步在于搜索树的建立 。
主要思路是从图中一个未访问的顶点 V 开始,沿着一条路一直走到底,然后从这条路尽头的节点回退到上一个节点,再从另一条路开始走到底......,不断递归重复此过程,直到所有的顶点都遍历完成,它的特点是不撞南墙不回头,先走完一条路到底,再换一条路继续走。
树是图的一种特例(连通无环的图就是树),接下来我们来看看树用深度优先遍历该怎么遍历。
1、我们从根节点 1 开始遍历,它相邻的节点有 2,3,4,先遍历节点
‘2,再遍历 2 的子节点 5,然后再遍历 5 的子节点 9。2、上图中一条路已经走到底了(9是叶子节点,再无可遍历的节点),此时就从 9 回退到上一个节点 5,看下节点 5 是否还有除 9 以外的节点,没有继续回退到 2,2 也没有除 5 以外的节点,回退到 1,1 有除 2 以外的节点 3,所以从节点
3 开始进行深度优先遍历,如下:3、同理从 10 开始往上回溯到 6, 6 没有除 10 以外的子节点,再往上回溯,发现 3 有除 6 以外的子点 7,所以此时会遍历 7。
4、从 7 往上回溯到 3, 1,发现 1 还有节点 4 未遍历,所以此时沿着 4, 8 进行遍历,这样就遍历完成了。完整的节点的遍历顺序如下(节点上的的蓝色数字代表):
相信大家看到以上的遍历不难发现这就是树的前序遍历,实际上不管是前序遍历,还是中序遍历,亦或是后序遍历,都属于深度优先遍历。
那么深度优先遍历该怎么实现呢,有递归和非递归两种表现形式。
递归实现比较简单,由于是前序遍历,所以我们先遍历当前节点,然后从左到右遍历当前节点的所有的子节点。在遍历每一个子节点的过程中,对于每一个子节点,依次遍历它们的所有子节点即可,依此不断递归下去,直到叶节点(递归终止条件)时返回,代码框架如下:
全局状态变量
void dfs(当前状态)
{
if(当前状态是目标状态) // 判断
进行相应处理(输出当前解、更新最优解、退出返回等)
// 扩展
for(所有可行的新状态)
{
if(新状态没有访问过 && 需要访问) // 可行性剪枝、最优性剪枝、重复性剪枝
{
标记
dfs(新状态);
取消标记
}
}
}
int main()
{
...
dfs(初始状态);
...
}
递归的表达性很好,也很容易理解,不过如果层级过深,很容易导致 栈溢出或超时 。原因有二:(1)程序的运行时,函数的调用需要用到系统为函数申请一个函数栈(函数栈的概念),通俗点讲,这个栈的栈底就是我们熟悉的“main”函数,栈顶就是程序运行时当前所在的函数。如果递归调用的层级过深,就会导致这个函数栈需要保存的层数过多,而函数栈本身的容量是有限制的。因而可能会导致栈的溢出;
(2)考虑到上面的递归调用过程中,需要反复地将函数及相关信息压栈和入栈,而这个过程在反复执行多次后,其所耗费的时间已经不可以忽略了,因而容易导致程序超时。
下面讲解能有效避免栈溢出或超时问题的非递归实现。
仔细观察深度优先遍历的特点,对二叉树来说,由于是先序遍历(先遍历当前节点,再遍历左节点,再遍历右节点),所以我们有如下思路:
对于每个节点来说,先遍历当前节点,然后把右节点压栈,再压左节点(这样弹栈的时候会先拿到左节点遍历,符合深度优先遍历要求)。
弹栈,拿到栈顶的节点,如果节点不为空,重复步骤 1, 如果为空,结束遍历。
我们以以下二叉树为例来看下如何用栈来实现 DFS。
整体动图如下:
整体思路还是比较清晰的,使用栈来将要遍历的节点压栈,然后出栈后检查此节点是否还有未遍历的节点,有的话压栈,没有的话不断回溯(出栈),有了思路,不难写出如下用栈实现的深度优先遍历的代码框架:
全局状态变量
void dfs(初始状态)
{
定义一个状态栈 // 用来保存搜索过程中的节点
初始状态入栈 // 根节点入栈
while(栈不为空)
{
当前的状态 = 栈顶的状态(栈顶元素出栈)
if(当前状态是目标状态) // 判断
进行相应处理(输出当前解、更新最优解、退出循环等)
// 扩展
for(所有可行的新状态)
{
if(新状态没有访问过 && 需要访问) // 可行性剪枝、最优性剪枝、重复性剪枝
{
标记
新状态入栈
取消标记
}
}
}
}
int main()
{
...
dfs(初始状态);
...
}
可以看到用栈实现深度优先遍历其实代码也不复杂(甚至和下面即将讲解的广搜的代码框架几乎一样),而且也不用担心递归那样层级过深导致的栈溢出问题。
广度优先遍历,指的是从图的一个未遍历的节点出发,先遍历这个节点的相邻节点,再依次遍历每个相邻节点的相邻节点。
上文所述树的广度优先遍历动图如下,每个节点的值即为它们的遍历顺序。所以广度优先遍历也叫层序遍历,先遍历第一层(节点 1),再遍历第二层(节点 2,3,4),第三层(5,6,7,8),第四层(9,10)。
深度优先遍历用的是栈,而广度优先遍历要用队列来实现,我们以下图二叉树为例来看看如何用队列来实现广度优先遍历。
全局状态变量
void BFS()
{
定义状态队列
初始状态入队
while(队列不为空)
{
取出队首状态作为当前状态
if(当前状态是目标状态)
进行相应处理(输出当前解、更新最优解、退出返回等)
else
for(所有可行的新状态)
{
if(新状态没有访问过 && 需要访问) // 可行性剪枝、最优性剪枝、重复性剪枝
{
新状态入队
}
}
}
}
对比我们可以发现:广度优先搜索和深度优先搜索(尤其是非递归的栈实现)的基本过程很相似,都包含如下两个过程:
1.判断边界:判断当前状态是否是目标状态,并进行相应处理
2.扩展新状态:由当前状态(节点)出发,扩展出新的状态(节点)
它们的区别就是一个用队列实现和一个用栈实现,一个按层横向遍历,一个按列纵深遍历。数据结构的不同也导致了它们对待 扩展出的新状态 的处理策略不同。深度优先搜索会将所有的新状态压入栈中,采取 先扩展出来的最后处理 的策略。而广度优先搜索会将所有的新状态压入队列中,采取 先扩展出来的先处理 的策略。这也正是栈 先入后出 和队列 先入先出 的体现。
搜索策略 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
深度优先 | 1.自动保留历史状态 2.函数参数即状态变量,状态定义简单 3.使用函数递归即可实现 |
1.递归非常耗时,容易TLE或爆栈 2.一般需要搜索完所有状态才能确定问题的解 |
1.需要求出所有解 2.需要输出状态路径 |
广度优先 | 1.求最优解时比DFS快 | 1.状态变量比较多时,实现麻烦 | 1.求最优解 2.状态定义简单时也可以用 |
需要注意的是:DFS(深度优先搜索)是一种相对更常见的算法,大部分的题目都可以用 DFS 解决(而BFS广搜就不行了),但是大部分情况下,这都是骗分算法,很少会有爆搜为正解的题目。因为 DFS 的时间复杂度特别高。
但是,如果想掌握更高级的算法,DFS必须要先熟练掌握!
状态空间:所有状态的集合
搜索树:从初始状态出发能访问的所有状态节点及对应路径构成的一棵树
状态:各种属性(位置、步数、和、路径记录[有时可以用全局变量记录])
初始状态:状态的各种属性为初始值
目标状态:一般是状态的某个属性满足一定条件下的状态,比如达到指定位置、和达到一定值
状态的转移:通过一些操作,导致了问题的状态发生了变化,就叫做状态的转移
可行解:所有目标状态都是可行解
最优解:目标状态中满足最优性(某属性最小、最大)的解,可能不唯一
解集:有时要求输出一个最优状态、有时要求输出所有的目标状态,有时要求输出任意一个目标状态
回溯:是一种经常被用在深度深度优先搜索(DFS)的技巧。其基本思想是——从一条路往前走,能进则进,不能进则退回来,换一条路再试。典型的例题是:八皇后问题。
方向数组:在二维数组上的搜索会经常用到的,用来更方便地实现状态转移和扩展的数组。常见的有四方向数组、八方向数组。
// 四方向数组的一种写法
int dx[4] = {0,0,-1,1}; // 左、右、上、下
int dy[4] = {-1,1,0,0};
剪枝,顾名思义,就是通过一些判断,砍掉搜索树上不必要的子树。有时候,我们会发现某个结点对应的子树的状态都不是我们要的结果,那么我们其实没必要对这个分支进行搜索,砍掉这个子树,就是剪枝。
最常用的剪枝有三种,可行性剪枝、重复性剪枝、最优性剪枝。
可行性剪枝:在搜索过程中,一旦发现如果某些状态无论如何都不能找到最终的解,就可以将其“剪枝”了,比如越界操作、非法操作。一般通过条件判断来实现,如果新的状态节点是非法的,则不扩展该节点
重复性剪枝:对于某一些特定的搜索方式,一个方案可能会被搜索很多次,这样是没必要的。在实现上,一般通过一个记忆数组来记录搜索到目前为止哪些状态已经被搜过了,然后在搜索过程中,如果新的状态已经被搜过了,则不再扩展该状态节点。
最优性剪枝:对于求最优解的一类问题,通常可以用最优性剪枝,比如在求解迷宫最短路的时候,如果发现当前的步数已经超过了当前最优解,那从当前状态开始的搜索都是多余的,因为这样搜索下去永远都搜不到更优的解。通过这样的剪枝,可以省去大量冗余的计算,避免超时。在实现上,一般通过一个记忆数组来记录搜索到目前为止的最优解,然后在搜索过程中,如果新的状态已经不可能是最优解了,那再往下搜索肯定搜不到最优解,于是不再扩展该状态节点。
奇偶性剪枝
我们先来看一道题目:有一个n×m大小的迷宫。其中字符S表示起点,字符D表示出口,字符X表示墙壁,字符.表示平地。你需要从S出发走到D,每次只能向上下左右相邻的位置移动,并且不能走出地图,也不能走进墙壁。每次移动消耗1时间,走过路都会塌陷,因此不能走回头路或者原地不动。现在已知出口的大门会在T时间打开,判断在0时间从起点出发能否逃离迷宫。数据范围n,m≤10,T≤50。
我们只需要用DFS来搜索每条路线,并且只需搜到T时间就可以了(这是一个可行性剪枝)。但是仅仅这样也无法通过本题,还需考虑更多的剪枝。
参见训练中的章节内题目,值得一提的是:
第一章除最后一题是搜索的入门题外,其他都是递推和递归类的题目,建议用递推和递归都各实现一遍,以复习相应的知识点;
第三章的细胞既可以用深搜做,也可以用广搜做,建议两种写法各实现一遍,以加深理解。其实所有的题目都可以尝试用两种方法去解决看一看,以体会深搜和广搜在实现上的不同,以及它们不同的适用场合;
第五章的N皇后问题是回溯法的经典问题,而且需要进行剪枝,建议认真做;
第五章的数谜这道题考察了深搜的一些技巧,而且需要进行深度优化才能通过,值得认真去做,做完可以看题解区的题解总结;