2019.4.20 时隔两年,重新修改了一下,加入一些现在的理解。
上篇文章简单介绍了两种搜索思想的概念,在了解了最基础的概念之后,让我们先通过一些简单的题目实战了解下,再来总结获得的感悟
我们先从最基本的图形问题说起
题目一:
有一个长为N,宽为M的花园,在一次下雨后,花园里面出现了许多水洼,水洼的形状如图所示(‘。’代表土地,‘w’代表水洼,8个方向的水洼都视为联通,属于同一个):
。。。
。w 。
。。。
现给定一个N行,M列的图,求其中有多少个水洼。
输入:
(第一行两个数N,M)
(接下来有N行,每行M个字符)
输出:
(一个整数,代表有多少个水洼)
样例输入:
6 7
。。。。。。。
。w 。。w 。。
。。。。w w w
w 。。。。。。
w 。。。。w 。
。。。。。。。
样例输出:
4
总体思路:
题目要求我们计算总共有多少个水洼,并且相邻的水洼视为一个,所以我们第一步需要做的是用双重循环遍历整个图,对于遍历到的当前节点graph[i][j],如果是 ‘。’ 则无视,如果是 ‘w’,那么则代表我们发现一个水洼,答案数量加一,再以当前的这个点为中心去向四周展开搜索,将与之相邻的水洼全部变为土地(因为相邻的水洼只算一个),随后在继续遍历整个图。这样整个遍历下来,就计算出它的水洼数量了。
细节思路:
在总体思路上我们忽视了很多细节,其中最主要的细节便在如果访问到图中的 ‘w’ 时的具体操作,这里,我们着重讲解。
在我们遍历到一个 ‘w’ 时,我们知道,我们发现一个新水洼,答案数量需要加一,并将所有与它相邻的水洼变为土地‘。’,避免当后面遍历到这些点是重复计数。因此就这个水洼的四周展开搜索,对于水洼四周的搜索,我们采用DFS。
遍历整个图的函数seekSolv如下。其中dfs_time便是最后我们需要的答案。
int
该函数作用是遍历整个图,如果graph[i][j] 为 ‘w’,则用函数dfsVector函数去搜索该点的四周是否有水洼。dfsVector函数如下
void
dfsVector函数中, 第一行,将当前坐标的字符变为‘。’,以代表该水洼访问并且计算过了,接下来的两个循环主要是为了达到位置偏移的作用,因为我们要以当前点为中心,搜索周边共八个方向。new_row , new_col分别是要去探索新的点的坐标,首先我们判断该点是否是‘w’ 以及它的数值是否越界,若是‘w’,则dfs继续递归下去搜索。直到某个点四周再无w,才开始逐一回溯。
如上篇文章所说,DFS的关键点便在于递归与回溯,在本题目中,我们通过对于第一个发现的‘w’点point1递归搜索它周围为‘w’的点,我们寻找到point2,并将poing1设为 ‘。’
我们又递归下去搜索point2周围的为‘w’的点point3,poing2也设为 ‘。’
递归到point3时,它的八个方位都没有水洼了,则回溯到point2的函数体内,再探寻point2这个点的其它方。
当point2的所有方向也探寻完毕了,才回到point1的函数体内,探寻point1的其它方向,以此类推,最后point1的方向也都探寻完毕了,回到seek_solv的函数体内,此时,对于point1这个点,我们已经DFS探查了它周围的所有的点,并将与之相邻的水洼全都变为了 ‘。’,此时,我们便完成了一次水洼的彻底搜索。我们也可以断定,我们发现了并且计算清楚了一个水洼。下图为搜索point1点完毕后的图的形状
在这道题目中,主要侧重的方向是函数的递归与函数的回溯。总体上是非常简单的。
然而在DFS的回溯中,大多数时候,还要注意,除了函数的回溯,还有数据的回溯。我们在不停的递归搜索中,其本质实际上是在测试不同的数据产生的不同的结果。所以除非题目需求,多数情况下,我们改变测试数据递归下去,等到回溯回来的时候,数据也是需要修改回来的,就像在上例中,我们不停的测试不同的new_row 和new_col,而由于题目的关系,我们并未需要做太多数据的回溯,而下面这个例子,将会主要表达数据的回溯,以及如何回溯数据的两种方式。
题目二:
求1到9的全排列总共有多少种。
题目是很简单的题目,我们尝试用DFS去做,把9个数字想象成空间中九个位置,求全排列相当于我们从其中任意一个点出发经过九个点的线路共有多少种,下面直接放代码
int ans = 0;
void DFS(vector<bool> &used , int deep)
{
if (deep == 9) {
++ans;
return ;
}
for (int i = 0 ; i != 9 ; ++i) {
if (!used[i]) {
used[i] = 1;
++deep;
DFS(used , deep);
used[i] = 0;
--deep;
}
}
}
其中used数组有9个元素,代表9个数字,used[i]的值为T or F。 代表 i 这个数字有没有被使用过。
代码很简单,我们可以在if代码块内发现极度对称的结构。在if代码块内,我们改变数据,去测试不同的数据产生的结果,在测试完毕后返回当前函数时,我们必须将原先更改过的数据再修改回来,使得对下次我们测试别的数据的时候不会造成影响。
在本例中,倘若不将deep数据回溯,那么deep就会瞬间达到9,而可能我们当前只走了几个节点而已,还远未走满9个节点。倘若我们不将used数组改回来,可能我们还未用到数字4,但used[4]的值却会为1,这也将使得我们的程序产生与我们期待的行为不一致。
在数据回溯的时候有两个主要的方式。
- 人为的修改回来
- 传参方式的改变
在本例中,我们所用的便是人为的修改回来,所以产生了如此对称的结构。另一个方法,传参方式的改变。我们可以传临时变量过去,例如本例中可以将deep改成:
......
由于传递的是deep + 1这个临时变量过去,当前函数下的deep并未发生改变,所以无需修改回来。
但是临时对象会增加对象的构造与析构成本,虽然可以减少代码过度膨胀,并且简洁易懂,但当对象很大的时候,例如成千上万行的矩阵对象,这时临时对象所消耗的成本就会让人经受不住了。在这里提两种数据回溯的方式旨在提醒,最好不要混用二者,避免漏考虑一部分,除非你有足够的理由说服自己那样做。
总结:
DFS的实际应用中,我们其实关注于递归与回溯,这二者的结合使得我们可以不去关注细枝末节的改变,而专注于入口与出口的定义,内部运行规则的制定,以及这套理论有用的证明。而这也是计算机诞生的最原始的任务,简化与轻量化人所要去做的琐事。
通过第一个与第二个题目,我们不妨可以发现,DFS其实不止局限于图的搜索,或者可以说其实相当一部分问题可以转变成图的世界去思考,在我看来,DFS还是BFS都是一种最底层的思考方式,我们根据不同的问题抽象出不同的具象搜索方法去解决,这两个思想都是地基,是万丈高楼的根本。代码可以千变万化,其中的思想却是不会变的,甚至万般思想皆可相容相通,时而汇聚时而分道而行,而这也是算法世界迷人的魅力之一。
(完)