ACM搜索入门问题模型的构建与分析

个人博客:ACM搜索入门问题模型的构建与分析 | Iwts’s blog

写在前面

最近一段时间也集中刷了一些搜索的入门题,感觉应该对搜索问题进行一次总结。所以此篇博文是我针对写的题的简单分析,错误无法避免,如果发现欢迎留言改正。另外,由于是分析类的博文,所以代码会很少,算法的代码和题解这里不会出现哒^_^。如果对BFS和DFS的代码都不会,还是推荐学习一下图论以及具体代码,下面写的都是思想了(口胡技能EX)

搜索基本算法:BFS与DFS

关于BFS与DFS,即广度优先搜索与深度优先搜索。由于重点不在这两个算法的具体实现,这里就简单写一下这两种算法的思想好了。

DFS,用迷宫类比是很恰当的。一般人在考虑走迷宫的时候是先直接走,碰见岔路的时候选择其中一条继续走,再碰见岔路就继续走,直到碰见死路或者走出迷宫。走出迷宫就不说了,碰到死路的时候,一般人都是往回退,退到岔路口选择另外一条路再继续走。就这样,直到出迷宫。

在这个过程中,我们对于路线有2种处理:

1.记录路线,方便往回走,以及哪个地方走过哪个地方没走过。

2.记录岔路口,方便确定该回退到哪里有岔路。

很明显,是递归与回溯的问题。我们可以用数组来储存走过的地点防止重复,同时,每次走的时候都建立成递归的方式,这样如果走出迷宫,没什么好说的;如果没走出迷宫,直接回溯,能够回溯到上一次有岔路的地方,并且由于数组记录了走过的路,所以一定走的是岔路的另一条没走过的路。

确定走出迷宫很简单,出去就是出去了,之前的所有数据都不管。如果没有走出,一定是在回溯的时候发现:已经没有岔路好选了。这里注意一点,回溯的时候是一层一层向上回的。上一个岔路没有路可以走就继续向上回,直到第一个岔路仍然无路可走(也就是回溯到最初的函数),就说明搜索失败。

BFS,用迷宫类比不太恰当,因为正常人不会选择在走迷宫的时候“广搜”。假设你站在岔路口,DFS是先随便找一条路走走看,出去就出去,出不去返回岔路口重来。但是BFS就是先选一条路走,走两步,再拐回来选另一条路走两步,在拐回来。所以我们用棋盘涂色来类比BFS。看下图:

在涂色的时候,我们从左上角开始图,然后涂紧挨着这个地方的方块,然后继续,再图紧挨着的方块。

那么在这个过程中,我们对整块棋盘有这2步处理:

1.将当前方块的紧挨着的方块涂色,同时记录已经涂过色的方块。

2.记录新出现的方块,这样才能涂新的方块。

为了实现这些操作,除了创建一个数组存放已经遍历过的方块,我们还需要一种数据结构,要求将最外面的方块存放,当需要涂色的时候取出这个方块,并对其四周进行处理——栈,很方便完成我们的想法。

以上,我们可以确定储存已经遍历过的位置可以单独开一个数组来存放,BFS使用栈来处理,DFS利用递归与回溯。具体的实现如果不会的话可以去百度了,博主就不贴代码啦。

BFS与DFS的选择以及两者的优缺点

bfs与dfs的选择是很重要的问题,虽然两者都是搜索,但是根据问题的不同两者在在使用上、耗费的时间都有非常大的不同。

BFS是用来搜索最短径路的解是比较合适的,比如求最少步数的解,最少交换次数的解,因为BFS搜索过程中遇到的解一定是离根最近的,所以遇到一个解,一定就是最优解,此时搜索算法可以终止。这个时候不适宜使用DFS,因为DFS搜索到的解不一定是离根最近的,只有全局搜索完毕,才能从所有解中找出离根的最近的解。

DFS适合搜索全部的解,因为要搜索全部的解,那么BFS搜索过程中,遇到离根最近的解,并没有什么用,也必须遍历完整棵搜索树,DFS搜索也会搜索全部,但是相比DFS不用记录过多信息,所以搜素全部解的问题,DFS显然更加合适。

空间优劣上,DFS是有优势的,DFS不需要保存搜索过程中的状态,而BFS在搜索过程中需要保存搜索过的状态,而且一般情况需要一个队列来记录。

一些问题会针对于这些浅显易懂的优缺点下手,例如求最短路类的问题,如果用DFS仍然不是TLE就要怀疑数据太水或者是签到题了。当然这些问题都是相对简单的。

大部分题目考虑了两者的优缺点,但是实际上是两者都可以使用,具体怎么用着方便就仁者见仁智者见智了。因为两者的框架与思路不同,根据不同的要求选择某一种方法会明显顺畅许多。所以大部分题目虽然都是搜索,但是会发现discuss里大部分讨论的都是某一种方法(当然大佬一般都使用骚算法1A带走,顺便验证真理——我做的题都是水题)。下面博主会分析一下两者的选择。

最短路问题果断选择bfs,这个就没必要解释了,当然,如果数据量小同时dfs算法还简单粗暴的情况下dfs也未尝不可,只是碰见的几率着实不高。bfs在整体上有几个非常重要的点:

1.使用队列。

2.层次分明,如果构造搜索树的话,bfs是按层遍历的。

3.不使用递归和回溯。

所以说bfs不适合大范围搜索,或者说不适合需要得到路径的问题,但是非常适合复杂的数据结构以及有特殊要求的问题。一个结构体,然后扔在queue中就好了,每次的操作也是非常简单的判定,判断某个状态是否能存在,能存在就将该状态扔进queue,不能则不用管。搜索到结果直接结束搜索。特殊要求是指关于排序方面的问题。因为我们使用队列,那么当然也能使用优先队列了。常规的排序方式可以对结构体重载操作符实现,复杂一点的比如路径花费问题,能走3种方向,但是每个方向的花费不同,那么果断优先队列啊,否则就算找到最短路也不一定是花费最短的路。如果是bfs,再考虑建立cost数组就将问题复杂化了。

dfs对博主而言,感觉上是比bfs难的。bfs只用考虑一点——如何走就行。dfs有很重要的一点是考虑划分子问题。只要跟递归有关就要考虑子问题的划分。(有时候分着分着就把dp的状态转移方程写出来了- -)。最优解的问题以及路径问题一般也是使用dfs。这里注意,博主说的最优解跟最短路是不一样的,也跟花费没有太大关系。这里的最优解其实跟dp问题有点类似了,当前的选择会影响后面的问题。所以说dfs相关问题很大程度上能变成dp问题。dfs主要的点是:

1.回溯与递归

2.剪枝非常方便,因为划分成了子问题,剪枝会变得很简单。

所以说很多复杂问题还是能够构造成dfs的思路的。尤其是因为dfs的算法实现了递归,所以不仅剪枝相对容易(因为是单独的小问题考虑,剪枝要求再多也是多写几个判断而已),在路径的统计问题上也比bfs好很多。同时可以有两套相关操作:递归时一套,回溯时一套。比如要求总共走多少步的时候可以定义全局变量,递归时count++,回溯时count++。有特殊的要求比如回来时花费的精力要高于探索的时候,也是递归一套标准回溯一套标准。

BFS模型的框架与分析

关于dfs的模型框架,我们分成2部分:

1.在进入bfs函数前,建立好队列以及结构体。一般来说,我们把图的节点存在结构体中(一般不会出简单的搜索,各种限制条件,坐标数量,需要用结构体以及泛型)。建立queue,同时将起点储存在队列中。

2.进入bfs函数。

框架如下:

void bfs(队列que) {
    while (队列不为空) {
        取出队列头
        if (满足搜索条件) 结束搜索;
        for (迭代下一步移动方式) {
            建立下一步移动的位置数据
            if (检查是否满足条件) {
                满足条件,处理数据
		将该步放进队列
            }else continue;
        }
    }
    return;
}

当然,实际上我们会有很多不同的写法。例如队列,我们可以传参,但是博主个人更喜欢将队列写入全局变量,这样方便main函数中进行处理。或者写进bfs函数中,可以免去初始化的烦恼。bfs的返回值可以不怎么考虑。如果有返回值也是在答案要求单一的情况下可以这样处理。实际上将答案储存在全局变量中也未尝不可。

DFS模型的框架与分析

dfs算法的核心部分是利用递归,在搜索问题建立模型的时候主要考虑搜索的结果。这也是dfs跟bfs的主要区别:dfs重点在于全搜,bfs重点在于搜最短。dfs的模型一般是求方案。例如从A点到B点有几种走法。要求暴力搜索全部可能。或者要求的结果基本上是将图搜索了一遍甚至多遍。此时使用dfs的递归就非常简单易懂。框架如下:

void dfs(){
	if(满足条件,或者说找到了该可能){
		处理这种可能
	}
	各种剪枝
	for(遍历下一步的可能){
		假设有n种可能,这里开始第i种可能
		dfs(); 递归进入该可能的情况
		假设不从i进行,而是继续遍历走其他的可能
	}
	完全不考虑这个节点,直接进行下一步。或者是结束整个寻找。
}

其实博主这个框架并不好。因为dfs的情况真的多,感觉并不好描述。或者说dfs相比bfs要灵活很多,不能用一种统一的方式来描述这个过程。下面是博主对这个框架的额外解释与分析。

关于dfs的参数问题。一般来说,dfs是要有参数的。参数非常非常重要,就好比在写递归的时候难道要将各种变量定义成全局么?不是说变量是全局就无法使用递归,而是说没有用到递归最妙的部分——利用上一步的值来处理,最终回溯。dfs亦然。dfs在构造成搜索树的时候也是非常简单明了的,我们在路径搜索以及输出的时候,完全可以将树的深度作为参数,这样就可以得到每个树枝(一条树枝就是一个解,有的树枝需要剪枝)的前后关系。这个关系可以利用回溯进行额外处理,或者就是再递归的时候存入栈或者数组。总之,参数列表很重要。下面的文章也会给出一些问题,凸显了参数列表的作用。

dfs的状态转移也是相当重要的。没错,这里的状态转移跟dp的如出一辙。很多dp的问题说白了就是暴力搜索+剪枝,枚举可能嘛。dp的前一个操作对后面的操作无影响,但是影响了后面问题的规模。那么不也是搜索树的模型么,当前节点的处理跟后面节点的处理没有任何关系,但是当前节点的规模却决定了后面的树的深度。所以划分子问题是dfs的精髓。不能说看到搜索路径,好啊先走一波dfs试试,然后连状态转移都没规划好。一般需要考虑状态转移的dfs是线性问题。例如有n个加油站,加油需要耗费时间,如果路上没有就gg。求一种加油方式可以耗费最少时间到达终点。这种问题可以理解成dp,当然也可以写成搜索。本质上可以划分成在第k个加油站加油,后n-k个加油站是一个划分。

dfs并不是简单的递归就行了,还要考虑是否走这一种情况。进入下一层函数的语句作为分界线,上面是递归下面是迭代。有的时候,例如迷宫找路,我们在回溯以后再运行,实际上这个时候我们运行的前提是根本没有进入递归。以上面提到了加油站为例子,我们可以写出这样的代码:

for(迭代,在第i个加油站加油){
    加满油
    dfs(如果在第i个加油站加油,进入该子树)
    此时需要将油箱的油还原到加油前
}

因为我们已经回溯了,回溯到了第i个加油站的位置,如果加油——进入子树,或者说分支。那么回溯回来以后我们的代码还是要运行的,继续运行的是不加油的操作。加油跟不加油是两个操作,还原的操作一定不能忘记。一般来说,我们写搜索都需要辅助数组,记录已经走过的位置,当然这里辅助数组最好还是还原的。

要理解dfs的递归,当然如果对dfs原理很理解就可以忽略下面一段了。

为什么要递归?就是一个暴力搜索的过程,对于A点,有4种走法,只能走一种,那么可以写一个迭代,假设进入1号走法,那么就进入新的dfs函数,此时再从1号走法的下一个位置开始继续dfs;那么最终一定是有的方法有解有的方法无解,无论怎样,最后一定会回溯到A点,此时就不用再考虑1号走法了,因为已经搜索(处理)了走1号以后所有的可能。那么,将走1号时产生的变化全部还原到没有走1号的情况,然后继续迭代开始尝试2号。同理,一次完整的迭代就将从A点开始走的所有可能都搜索了一遍,并且进行处理。

写在中间(???)

总之,上面算是大概讲了这2个算法,下面的内容都是根据实际问题了。会有模型的建立以及题目的分析。附带部分核心代码,但是整体上仍然是瞎bb了。大概就是会列出一个模型,给出一个关于模型的通用解,有点类似高中物理的情况。当然博主也是菜的不行,肯定不能给出权威的分析,只是博主分析做过的题做出的简单总结归纳。

搜索路径的移动代码实现

一般来说,搜索问题是建立在图的基础上的。但是很多问题并没有使用图数据结构,而是利用数组在存放数据。那么,我们对于在图上移动,就变成了通过移动下标从而在数组上移动。此时有2个问题:

1.简单的加n减n可能导致数组越界。

2.情况少可以写多个if枚举所有的走的情况,但是情况多会写非常多的重复代码。

举个栗子:A在迷宫中,可以走上下左右四个方向,那么如何模拟A走路的过程?其实这个是根据本身的代码来分析的。一般来说,一般来说,利用数组模拟图,那么数组的2个下标就是图的坐标,在图上移动就是坐标的变化。那么我们可以将坐标的变化用数组来表示,例:

int map[5][5];

那么一个图map就建立完成了。总共有4种走法:上下左右。由于无法斜着走,所以对于i、j而言,上下左右移动时数值变化如下:

上:i-1,j
下:i+1,j
左:i,j-1

右:i,j+1

如何模拟上面的数值变化?此时需要的就是go数组了,4*2的二维数组,4表示4种可能,2表示坐标的总数。迭代第一个下标,赋值第二个下标,就能完美还原n种走法。

struct person{
    int x,y;
};

int go[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};

上面是标准的表示,用结构体储存位置,用go数组表示移动。

for(int i = 0;i < 4;i++){
    int new_x = per.x + go[i][0];
    int new_y = per.y + go[i][1];
}

go数组一般是二维数组,第一个4,表示有4个方向,第二个2表示是二维的地图。poj有个比较骚的题,三维迷宫,我们就要定义go[6][3],因为有6个方向,三维的坐标。

关于是否是有效移动的写法

上一段是写了关于移动的相关代码,有一个非常重要的点没有写出来,那就是移动是否合法的问题。最重要的一点是关于越界:假设有坐标(0,0),在地图中,如果我们向上或者向左走,可以得到坐标(-1,0)和(0,-1)。可以看到直接越界了。那么我们必须来判断我们进行的一次移动是否是合法的移动。

为了整体代码的优美与简洁,单独将判定的算法写进一个函数是非常恰当的。我们无论怎样,移动后的数据是很少的,例如二维的地图,每次移动后的新坐标其实就是2个变量:x、y。那么我们完全可以将x、y作为参数传递进入函数中再判断是否合法。合法返回值1,不合法返回0。此时我们就先只考虑是否越界的情况,假设有地图map[5][5],那么:

int check(int x,int y){
    if(x < 0 || x >= 5) return 0;
    if(y < 0 || y >= 5) return 0;
    return 1;
}

当然,我们也可以将或运算改成与运算,但是从逻辑上看,或运算显得更为简洁优美。可以方便理解:每一个if语句都是一个限制条件,满足则不论,不满足直接返回0。那么有几个限制条件,我们就写几个if语句,如果所有的if语句都不运行,那么返回1,也就是说x、y是合法的。

所以,博主还是推荐大家这样来写check函数。(当然也有一些时间上的原因,博主之前写过几次与运算的函数,发现总会TLE,而改成或运算则没有这样的问题,具体原因确实不清楚,如果有懂的大佬欢迎评论告知)

而现在,我们仅仅是判定了是否越界,搜索中还有一个非常重要的点,就是判定是否到了重复路径,或者说这个位置之前有没有走过。这个是非常重要的,因为到了曾经走过的路(不是分叉点,分叉点在出现后就已经在考虑进入几个分叉的情况了,bfs已经将所有的分叉全部加入队列,dfs则是利用回溯回到选择之前)有可能出现死循环现象。所以这一点我们也要考虑到。具体如何实现,下面会详细解释。

另外,针对于题目的不同我们也有更多的要求,都可以写成if语句写入check函数,而判定的时候只用写一点代码即可,例:

// 通过路径移动获得新坐标(x,y)
if(check(x,y) == 0) continue;
// 下面的语句是对正确数据的处理

这也是为了代码的简洁。嵌套的层数越少,代码读起来就越容易,逻辑上也更清晰。如果不满足,直接继续循环,不考虑下面的内容,而对正确的数据进行处理直接写在下面即可。

关于已经走过的点的存储问题

上面也简单提到了这一点。无论是bfs还是dfs,这一点都至关重要。因为如果在路径中我们走到了一个曾经走过的点,那么最终至少有一条路径是会死循环的。那么最终结果只能是内存泄露了。无论是否有剪枝,至少这一条路径不断地在进行操作而无法得到“解脱”。

这里可能有同学无法理解,分叉点到底标记不标记走过?这里解释一下:如果是是bfs,在走到分叉点的时候已经将所有接下来要走的点全部放在队列里了。并且每次都是从队列中拿出新的点。也就是说分叉点已经无关紧要了,使命已经完成。所以加在“已经走过的点”是完全没问题的。而dfs,因为有回溯,如果回溯的时候无法走到分叉点那就不能走另外的分支。这点完全不用担心,bfs的算法框架在上面博主已经写过了,着重提示了在回溯的时候要取消上一步的操作。假设我们用ver[][]数组来储存点的结构体,那么有这样的框架:

ver[now_x][now_y] = 1; // 记录分叉点已经走过
dfs(new_x,new_y); // 进入新的循环
ver[now_x][now_y] = 0; // 还原

确实,回溯的时候碰到了曾经的分叉点,但是我们在具体的一个分支中,这个分叉点一定要是走过的状态,只是在回溯后,我们需要将这个点还原,从而进入另外的分支。

所以,无论是bfs还是dfs,只要走过,直接加入“已经走过的点”集合就完全没问题,只是dfs考虑是否重置即可。

如何储存这个集合?其实非常简单,只用构造一个大小跟地图一样的数组即可,例如上面提到的ver[][],博主更习惯写成step[][],这个二维数组的两个维分别代表了x与y,那么走过的点令其赋值为1即可。

所以注意2点:

1.关于ver数组的初始化,一定要记得刚开始就初始化(否则每一位都是乱码),以及每一轮计算的开始初始化(上一次的运算结果清除)。使用memset函数即可:

memset(ver,0,sizeof(ver));

2.在check函数中也要判定这个数组是否为1,如果是1,说明该点已经走过了,这样的数据也是不合法的。

关于记忆路径的几种写法

记忆路径,一般来说经常出现在题目的要求中,例如:走迷宫时找到一条最短路,同时输出这条路的走路顺序。这样,等于说是额外将搜索时的路径给完整输出。这个操作看起来并不是很难,但是代码的实现却是相当麻烦的。BFS与DFS有着不同的写法来实现记忆路径,分别来实现一下。

DFS,因为利用了递归,所以看起来似乎对于记忆路径加一个回溯即可。真的是这样么?大家可以按照这个思路试一试,直接写在代码里并不简单,因为回溯的时候不仅仅是找到正确路径后回溯,找到错误路径(例如迷宫撞墙)后仍然有回溯这个操作(所以在上面写DFS时进入递归后要在回溯的时候进行还原操作),如果简单地利用回溯最终输出的并不是正确路径,而是走过的路径。但是我们仍然可以利用这个思路来完成记忆路径。

DFS有递归和回溯,那么我们可以利用队列。递归的时候将该路径添加进入队列。回溯的时候出队列。如果碰到目标路径(DFS达成满足条件时),就直接在if子句里处理即可。这个队列存放的就已经是目标路径了。

BFS,相比DFS还是难一点的,一般用BFS的题也比较少同时要求输出路径。但是

曾经走过的路径,特殊的条件有特殊的写法

可以看一下poj的1321题:传送门

这个题其实跟八皇后有点类似了,因为限制问题,横排跟竖排都只能放1个棋子。这就是一个特殊的限制条件——横排竖排只能有一个棋子。

先不想这个题,我们之前无论是DFS还是BFS都有一个重要操作——存储曾经遍历的点。对于一个二维图而言,我们需要一个二维数组来存放这个走过的路径,但是对于这个题我们这样写就非常的麻烦。空间上不是问题,问题是算法方面——假设第一行总共有3个位置可以放棋子,那么第一个棋子放下去后,该竖列跟横列就完全不能放棋子了。我们只能将这个点加入二维数组,也就是说并没有利用这个特殊条件,明明知道这一横行跟竖行都不能放棋子,却还要在走一下。同时又要写复杂的算法:

int check(int x,int y){
    int flag = 0;
    for(int i = 1;i <= MAX;i++){
        if(ver[x][i] == 1){
            flag = 1;
            break;
        }
    }
    if(flag == 1) return 0;
    flag = 0;
    for(int i = 1;i <= MAX;i++){
        if(ver[i][y] == 1){
            flag = 1;
            break;
        }
    }
    if(flag == 1) return 0;
    return 1;
}

回过头来,想一下八皇后我们是怎么写的:利用一个一维数组,ver[i],表示第i列放了一个皇后,那么第i列就再也不能放皇后了。跟该题是不是有点像?将二维数组化简为一维数组,ver[i]表示第i列已经不能再放了,至于具体是哪一行放的棋子我们不用管。这样我们就能写出算法:

for (int i = 1; i <= n; i++) {
    if (maps[index][i] == '#' && ver[i] == 0) {
        ver[i] = 1;
        // dfs(index + 1, num - 1); 这句代码不是重点
        ver[i] = 0;
    }
}

for循环迭代的是该行具体已经在那一个位置放棋子,而ver[i]控制了哪一列不能放棋子。

所以说如果题目有特殊条件,就不能死板套模板,适当修改反而能写出优秀的代码。

DFS、BFS的形参列表的灵活运用

仍然是上面的题,这里

  • 25
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值