【学习报告】简单搜索

简单搜索

根据《ACM主要算法》这一篇博客,简单搜索一节又分3小节,分别是深度优先搜索广度优先搜索(又称宽度优先搜索)以及简单搜索技巧和剪枝

完成了该内容的一些具有代表性的题目后,在师兄的建议下,自己算写下学习心得,以便日后复习时候可以用上。


1.深度优先搜索

深度优先搜索(DFS,Depth-First Search)是搜索的手段之一。

在学习深搜之前,要先了解递归函数的思想。


1)递归函数

递归函数的思想很好理解——我们引用一个排队事件来解释一下。

现在假设你在一条很长很长队伍的中间,你不清楚你现在是排在队伍的第几位,你也不能离队走到队伍的最前面去点人头数,那么,聪明的你问前面的同学,问他排在第几位;假如前面那位同学也不知道,那他为了回答你的问题,就会继续问他前面的同学,如果他前面的同学仍不知道,就继续问他前面的同学,以此类推(假设问的时候队伍一动不动),直到问到最前面的那位同学,这时候,那位同学肯定知道自己是第一位的(其实第二位第三位的同学也能很直观地回答自己的位置,但我们还是假设到第一位吧)。所以,第一位同学会把自己的位置告诉他后面的那位同学,然后后面那位同学就知道了自己的位置,再把自己的位置告诉后面的同学,以此类推,经过大家的共同协助,最终你肯定会知道你前面那位同学的位置,进而知道你所在的位置。


对,没错,这个有点像军训报数的过程。然而报数是一次性完成的,而上面的排队事件是经过了两次操作才完成的。

①你为了知道自己的位置而问前面那位同学的位置,前面那位同学重复你的做法,这就是递归思想中的

②前面的同学告诉身后的同学自己的位置,直到你前面的同学告诉你他所在的位置的这种做法,就是递归思想中的

当然,聪明的你肯定会注意到排在第一位的那位同学,他是整个递归事件的重要人物,是连接递与归的条件。

以上是关于递归通俗的说法,用术语说的话是:在一个函数中再次调用该函数自身的行为叫做递归,这样的函数被称作递归函数


以下是《c primer plus》的一个递归演示,请自己在自己机子上跑一下看看结果:

/* recur.c -- 递归演示 */
/* %p 是输出地址 */
#include <stdio.h>
void up_and_down(int);

int main(void){
    up_and_down(1);
    return 0;
}

void up_and_down(int n){
    printf("Level %d: n location %p\n", n, &n);  // 递
    if(n < 4) // 终止条件
        up_and_down(n + 1);
    printf("Level %d: n location %p\n", n, &n); // 归
}

2)栈

栈的思想也很好理解——我们用取东西这一生活例子来说明以下。

你有一个箱子与一叠书,每本书的大小都是一样的,而书的长宽也正刚好放进平放进箱子。于是你用把书平放进去的方式来保存这些书。很快,箱子很快放满了。某一天,你想要从箱里把想要的书拿出来。可目标并不是第一本,所以你先把书一本一本地取出来后,才把自己想要的那本书取出来。

你以上所进行的操作,就是一整个的思想的模拟,而这个装书的箱子,就是栈这个容器。生活中还有其他例子,比如叠在一起的盘子(除非你可以把一整盘拿起来,否则还是要从上往下一个一个取),叠在一起的椅子等等......


在算法的世界里,(stack)是一种数据结构,可以通过数组或者列表就可以简单实现的,支持push(把东西放到栈顶)和pop(把东西从栈顶取出)两种操作的数据结构。因为这两种操作的原因,被栈保存的数据就有后进先出的特性(LIFO:Last In First Out)。


在C++的STL里,就有stack的头文件啦,只需要在头文件声明一下就可以使用了。

以下是《挑战程序设计竞赛(第2版)》对使用stack的演示:

#include<stack>	// 头文件声明
#include<cstdio>
using namespace std;

int main(){
    stack<int> s;				// 声明存储int类型数据的栈
    s.push(1);					// {} → {1}
    s.push(2);					// {1} → {1,2}
    s.push(3);					// {1,2} → {1,2,3}
    printf("%d\n", s.top());	// 3
    s.pop();					// 从栈顶移除 {1,2,3} → {1,2}
    printf("%d\n", s.top());	// 2
    s.pop();					// {1,2} → {1}
    printf("%d\n", s.top());	// 1
    s.pop();					// {1} → {}
    return 0;
}

在做题中,stack的操作我们最常用的是以下几个:

#include<stack>
using namespace std;
stack<int> s;
/*
	s.pop(); 	// 出栈操作
	s.push(x);  // 入栈操作
	s.top();    // 查询栈顶元素
	s.size();   // 查询栈里元素个数
	s.empty();  // 判断栈里元素是否为空,空返回真值
*/

关于更详细的stack,推荐浏览www.cplusplus.com来了解更多内容(全英警告)

再唠叨几句:

​ 像stack这类工具,我们可以直接理解为是一个“黑匣子”,这个“黑匣子”里有很多种功能,我们把数据放进去,它们就会自动工作,使用者完全不需要理解其工作原理(当然,想成为一名优秀的人,也少不了理解)。因此,我们也要注意,当我们使用得这个工具足够熟练的时候,就是时候去理解它的工作原理了。

3)深度优先搜索

深度优先搜索是从某个状态开始,不断地转移状态直到无法转移,然后回退到前一步的状态,继续转移到其他状态,如此不断重复,直至找到最终的解。例如求解数独,首先在某个格子内填入适当的数字,然后再继续在下一个格子内填入数字,如此继续下去。如果发现某个格子无解了,就放弃前一个格子上选择的数字,改用其他可行的数字。

总得来说,深度优先搜索的重点毕竟是在搜索,所以并不需要考虑太多的最优状态(比如贪心算法等这类),我们唯一要做的就是搜索,换言之就是枚举每一种情况——穷竭搜索(简单的理解就是如此)。就像上述的数独求解,就是要枚举每一格的每一个数的情况,当格子都填完的时候,就终止这次的搜索(这里涉及到剪枝操作,下面会提到)。


比如Lake Counting(POJ No.2386)这题

有一个大小为 N \(\times\) M 的园子,雨后积起了水。八连通的积水被认为是连接在一起的。请求出园子里总共有多少水洼?(八连通指的是下图中相对W的\(\circ\)的部分)

\(\circ\circ\circ\)

\(\circ\)W\(\circ\)

\(\circ\circ\circ\)

对于样例输入

N = 10, M = 12

园子如下图(‘W'表示积水,‘.’表示没有积水)

W........WW.
.WWW.....WWW
....WW...WW.
.........WW.
.........W..
..W......W..
.W.W.....WW.
W.W.W.....W.
.W.W......W.
..W.......W.

对于样例输出

3

左上角6个W形成一个水洼

左下角9个W形成一个水洼

右边一列下来的W形成一个水洼

实现这个代码非常简单,下面是《挑战程序设计竞赛(第2版)》的解法

// 输入
int N, M;
char field[MAX_N][MAX_M + 1]; // 园子

// 现在位置(x, y)
void dfs(int x, int y){
    // 将当前所在位置替换为
    field[x][y] = '.';
    
    // 循环遍历移动的8个方向
    for(int dx = -1; dx <= 1; dx++){
        for(int dy = -1; dy <= 1; dy++){
            // 向x方向移动dx,向y方向移动dy,移动的结果为(nx, ny)
            int nx = x + dx, ny = y + dy;
            // 判断(nx, ny)是不是在园子内,以及有积水
            if(0 <= nx && nx < N && 0 <= ny && ny < M && field[nx][ny] == 'W') dfs(nx, ny);
        }
    }
    return ;
}

void solve(){
    int res = 0;
    for(int i = 0; i < N; i++){
        for(int j = 0; j < M; j++){
            if(field[i][j] == 'W'){
                // 从有W的地方开始dfs
                dfs(i, j);
                res++;
            }
        }
    }
    printf("%d\n", res);
}

像Lake Counting这题,深搜的操作是在一个'W'上找8个方向的'W',找到的话就跳到下一个'W'上,没有就返回。了解完深搜的特性,我们就可以尝试下面的题目。

深度优先搜索的入门题:POJ2386POJ1979POJ2676......

练手题:POJ2488POJ3083POJ3009POJ1321......

小小心得:

深搜的题目难点大多在于找搜索条件,以及对路径的优化(剪枝)。练手题里有些操作不再是简单的向前一个或者向右一格的操作了,所以这个实现问题已经不是对深搜理解不深的问题,而是自己做题量够不够的问题quq。一般搜索题的数据都不会很大,如果很大的话,要考虑是否还能直接暴力搜索或者需要剪枝了。




(中途休息一下,消化一下 \ ^ w ^ \ (






2.宽度优先搜索

宽度优先搜索(BFS,Breath-First Search)也是搜索的手段之一。它与深度优先搜索类似,从某个状态出发探索所有可以到达的状态。

在学习宽度优先搜索之前,我们也还先了解一下队列

1)队列

队列的思想也很好理解——举生活的例子就是排队。

排队排队,当然是先排的人先打饭了,而且我们这个队列特别公平,没有插队这一操作,所以,大家都按顺序排队,先排的先打饭,后来的只能从队伍最后一位排起。


在算法的世界里,queue与stack一样,也是一种数据结构,也可以用数组或者链表简单实现。queue也有入队push操作与出队pop操作。当然,与stack不同的是,queue与排队是完全一模一样的,所以特性就是先进先出(FIFO: First In First Out)


与stack一样,queue在STL库里也有,因此我们定义一个头文件就可以了。

以下是《挑战程序设计竞赛(第2版)》对使用queue的演示:

#include<queue>
#include<cstdio>
using namespace std;

int main(){
    queue<int> que;				 // 声明存储int数据类型的队列
    que.push(1);				 // {} → {1}
    que.push(2);				 // {1} → {1,2}
    que.push(3);				 // {1,2} → {1,2,3}
    printf("%d\n", que.front()); // 1
    que.pop();					 // 从队尾移除 {1,2,3} → {2,3}
    printf("%d\n", que.front()); // 2
    que.pop();					 // {2,3} → {3}
    printf("%d\n", que.front()); // 3
    que.pop(); 					 // {3} → {}
    return 0;
}

queue的基本操作可以说跟stack的几乎一样,不同的是最先出队的元素是用.front(),而stack用的是.top()。

#include<queue>
using namespace std;
queue<int> s;
/*
	s.pop(); 	// 出队操作
	s.push(x);  // 入队操作
	s.front();  // 查询队头元素
	s.size();   // 查询队里元素个数
	s.empty();  // 判断队里元素是否为空,空返回真值
*/

关于更详细的queue,推荐浏览www.cplusplus.com来了解更多内容(全英警告)

2)宽度优先搜索

与深度优先搜索的不同之处在于搜索的顺序,宽度优先搜索总是先搜索距离初始状态近的状态。也就是说,它是按照开始状态→只需1次转移就可以达到的所有状态→只需2次转移就可达到的所有状态→......这样的顺序进行搜索。

因此,对于同一种状态,宽度优先搜索只经过一次,因此复杂度为O(状态数 \(\times\) 转移的方式)。

对此,宽度优先搜索来解决迷宫里的最短路径问题比深度优先搜索要好。

例如

给定一个大小为 N \(\times\) M的迷宫。迷宫由通道和墙壁组成,每一步可以向邻接的上下左右四格的通道移动。请求出从起点所需的最小步数。请注意,本题假定从起点一定可以移动到终点。

N, M \(\leq\) 100

对于样例输入

N = 10, M = 10 (迷宫如下图所示。'#', '.', 'S', 'G' 分别表示墙壁、通道、起点和终点)

#S######.#
......#..#
.#.##.##.#
.#........
##.##.####
....#....#
.#######.#
....#.....
.####.###.
....#...G#

对于样例输出

22

实现这个代码非常简单,下面是《挑战程序设计竞赛(第2版)》的解法

const int INF = 100000000;

// 使用pair表示状态时,使用typedef会更加方便一些
typedef pair<int, int> P;

// 输入
char maze[MAX_N][MAX_M + 1]; // 表示迷宫的字符串的数组
int N, M;
int sx, sy;					 // 起点坐标
int gx, gy;					 // 终点坐标

int d[MAX_N][MAX_M]; 		 // 到各个位置的最短距离的数组

// 4个方向移动的向量
int dx[4] = {1, 0, -1, 0}, dy[4] = {0, 1, 0, -1};

// 求从(sx, sy)到(gx, gy)的最短距离
// 如果无法到达,则是INF
int bfs(){
    queue<P> que;
    // 把所有的位置都初始化为INF
    for(int i = 0; i < N; i++)
        for(int j = 0; j < M; j++) d[i][j] = INF;
    // 将起点加入队列,并把这一地点的距离设置为0
    que.push(P(sx, sy));
    d[sx][sy] = 0;
    
    // 不断循环直到队列的长度为0
    while(que.size()){
        // 从队列的最前端取出元素
    	P p = que.front(); que.pop();
        // 如果取出的状态已经是终点,则结束搜索
        if(p.first == gx && p.second == gy) break;
        
        for(int i = 0; i < 4; i++){
        	// 移动之后的位置记为(nx, ny)
            int nx = p.first + dx[i], ny = p.second + dy[i];
        
        	// 判断是否可以移动以及是否已经访问过(d[nx][ny]!=INF即为已经访问过)
    	    if(0 <= nx && nx < N && 0 <= ny && ny < M && maze[nx][ny] != '#' && d[nx][ny] != INF){
   	            // 可以的话则加入到队列,并且到该位置的距离确定为到p的距离+1
            	que.push(P(nx, ny));
            	d[nx][ny] = d[p.first][d.second] + 1;
        	}
    	}
    }
 	return d[gx][gy];   
}

比如上题的宽搜的方向就是每个可行格子的从起点出发所要花费的步数。了解了宽搜的思想过后,就可以尝试下面的题目。

宽度优先搜索的入门题:POJ1979(试着用bfs解这题)

练手题:POJ1426POJ3126POJ3414POJ3278洛谷P1443


小小心得:

对于搜索这类题求最短什么的,第一反应就可以想到用宽搜了。宽搜基本要用到队列的思想。怎么入队出队,入队的条件是什么,怎么样搜索才是最佳的选择。弄明白这三点,宽搜的实现也只是时间上的问题了吧。


简单搜索技巧与剪枝

简单搜索技巧是因为这题的数据不是很大,而且通过遍历所有结果可以得到答案的做法——说白了仍是深搜或者是广搜的内容,只不过搜索的方式不会那么明显,所以这里可以说是简单搜索题的总结——说白了还是要堆题量了。

剪枝操作用通俗说法就是减少重复执行的操作。比如数独游戏中,当你搜索到最后一个格子都填完的时候,那这个答案就肯定是正确的了,所以后面的所有情况都不用再去搜索判断,直接终止输出答案就行。这个操作就是剪枝。

回溯操作的说明还是用数独游戏去举例,假设你第一个格子填1时后面的情况是不对的,把后面的情况全都清0,然后再把第一个格子换成2,这样后面的情况才不会被第一个格子填1时受影响,而这个“清0”的操作就是回溯。——八皇后这类经典题目的深搜中运用回溯是很常见的一种——回溯就是还原状态


做题感悟

做完poj2488, poj3083, poj1321, poj2251,poj3278, poj1426, poj3126, poj3087, poj3414,poj2531, poj1416, poj2676, poj1129这几道搜索题后,我感觉白书把搜索放在第二章是有原因的,因为搜索题真的比贪心要简单,简单搜索题更像是加强版的简单模拟题,只要知道怎么搜索后,实现也就是时间上的问题了。

而且搜索一般也只有两种情况——要么这样,要么不这样——对于这种题是最好写的,所以遇到搜索题的时候不妨先考虑这样的情形。


参考资料:

《挑战程序设计竞赛(第2版)》人民邮电出版社

ACM主要算法(博客)-蔡军帅

posted on 2019-10-07 11:12  Ayasan  阅读(354)  评论(0编辑  收藏  举报