(五)算法与数据结构 | BFS和DFS


0. 简介

广度优先搜索 B r e a d t h   F i r s t   S e a r c h , B F S {\rm Breadth\ First\ Search,BFS} )和深度优先搜索 D e p t h   F i r s t   S e a r c h , B F S {\rm Depth\ First\ Search,BFS} )是图论中在图上最基础、最重要的搜索算法之一。图的大部分操作都可以基于以上两种搜索算法而稍作改动。由于图的数据结构定义较复杂,同时平面图可以等价于一个二维数组。为了方便,这里使用数组介绍 B F S {\rm BFS} D F S {\rm DFS} 。以下图的 5 × 5 {\rm 5×5} 的数组做后续说明:
在这里插入图片描述

图1:位置矩阵


1. 广度优先搜索

在进行本部分内容前,首先介绍广度优先搜索中常用到的数据结构,队列( q u e u e {\rm queue} )。队列是一种先进先出的数据结构,普通队列只能在队尾添加元素、在队头删除元素。在 C {\rm C++} 中,通过语句queue<T> q;定义一个类型为 T {\rm T} 的队列。队列的常用操作如下:

  • q.push(T val);在队尾添加元素 v a l {\rm val}
  • T val = q.front();取队头的元素,并赋值给 v a l {\rm val}
  • T val = q.back();取队尾的元素,并赋值给 v a l {\rm val}
  • q.pop();删除队头元素;
  • q.empty();判断队列是否为空,如果为空则返回 T r u e {\rm True} ,否则返回 F a l s e {\rm False}
  • int val = q.size();获取队列中元素的个数。

广度优先搜索类似于树的层次遍历。它的基本思想是:首先访问起始点 v {\rm v} ,然后选取 v {\rm v} 相邻的所有点 v 1 , v 2 , . . . , v n {\rm v_1,v_2,...,v_n} 进行访问,再依次访问与 v 1 , v 2 , . . . , v n {\rm v_1,v_2,...,v_n} 相邻的所有点。如下图:在这里插入图片描述

图2:广度优先搜索

( 0 , 0 ) {\rm (0,0)} 为起始点开始进行搜索,根据广度优先搜索的定义,图中不同颜色标识了访问顺序: ( 0 , 0 ) {\rm (0,0)→} ( 1 , 0 ) , ( 0 , 1 ) ( 2 , 0 ) , ( 1 , 1 ) ( 0 , 2 ) . . . ( 4 , 4 ) {(1,0),(0,1)→(2,0),(1,1)→(0,2)→...→(4,4)} 。要实现上述访问顺序,可以利用队列先进先出的性质,首先将 ( 0 , 0 ) {\rm (0,0)} 入队;当队不为空时,出队并访问;同时判断出队元素的相邻位置是否合法,如果合法则入队;重复上述过程直到队列为空,则完成广度优先搜索遍历。广度优先搜索的广度体现在每次尽可能多地将元素入队访问。以下是 C {\rm C++} 伪代码:

定义队列q;(0,0)压入队列中;
while(队列不为空){
	元素出队并访问;
	找到元素的相邻位置并入队;
}

在这里,假如规定每次仅在右方下方寻找相邻元素(如 ( 1 , 1 ) {\rm (1,1)} 的相邻元素只能是 ( 2 , 1 ) {\rm (2,1)} ( 1 , 2 ) {\rm (1,2)} )。为了避免重复访问(如 ( 2 , 0 ) {\rm (2,0)} 的右方和 ( 1 , 1 ) {\rm (1,1)} 的下方均为 ( 2 , 1 ) {\rm (2,1)} ),定义一个和原数组等大的访问数组用于标识每个位置的访问情况。 根据以上内容,得到最终的 C {\rm C++} 代码(以访问上述数组中的坐标为例):

void BFS() {
	// 定义方向数组,规定每次查找相邻位置的方向
	vector<int> dirX = { 1,0 };
	vector<int> dirY = { 0,1 };
	// 定义访问数组
	vector<vector<int>> vec(5, vector<int>(5, 0));
	// 定义队列
	queue<pair<int, int>> q;
	q.push(make_pair(0, 0));
	vec[0][0] = 1;
	// 以BFS方式访问
	while (!q.empty())
	{
		// 取队头元素并出队
		pair<int, int> temp = q.front();
		q.pop();
		// 访问元素
		visit(temp);
		// 按照规定方向查找相邻位置
		for (int i = 0; i < 2; ++i) {
			int coorX = temp.first + dirX[i];
			int coorY = temp.second + dirY[i];
			// 边界条件
			if (coorX == 5 || coorY == 5) {
				continue;
			}
			// 访问相邻位置并入队
			else if(!vec[coorX][coorY])
			{
				q.push(make_pair(coorX, coorY));
				vec[coorX][coorY] = 1;
			}
		}
	}
}

程序运行结果:
在这里插入图片描述

图3:广度优先搜索结果


2. 深度优先搜索

在进行本部分内容前,首先介绍深度优先搜索中常用到的数据结构,栈( s t a c k {\rm stack} )。队列是一种后进先出的数据结构,普通栈的元素操作均在栈顶进行。在 C {\rm C++} 中,通过语句stack<T> q;定义一个类型为 T {\rm T} 的栈。栈的常用操作如下:

  • s.push(T val);在栈顶添加元素;
  • T val = s.top();取栈顶的元素,并赋值给 v a l {\rm val}
  • s.pop();删除栈顶元素;
  • s.empty();判断栈是否为空,如果为空则返回 T r u e {\rm True} ,否则返回 F a l s e {\rm False}
  • int val = s.size();获取栈中元素的个数。

深度优先搜索类似于二叉树的先序遍历。它的基本思想是:首先访问起始点 v {\rm v} ,然后访问与 v {\rm v} 任一相邻位置 x {\rm x} ;再访问与 x {\rm x} 任一相邻位置 y {\rm y} ;如此进行下去直到到达数组边界。深度优先搜索与广度优先搜索不同的是,每次只选取一个与当前位置相邻的元素,并按照此方向一直进行下去。当到达数组边界后,返回前一个位置,再选取不同方向访问。如下图:在这里插入图片描述

图4:深度优先搜索

( 0 , 0 ) {\rm (0,0)} 为起始点开始进行搜索,根据广度优先搜索的定义,图中红色箭头标识了元素的访问顺序: ( 0 , 0 ) ( 0 , 1 ) ( 0 , 2 ) ( 0 , 3 ) . . . ( 4 , 0 ) {\rm (0,0)→(0,1)→(0,2)→(0,3)→...→(4,0)} 。要实现上述访问顺序,可以利用栈后进先出的性质,首先将 ( 0 , 0 ) {\rm (0,0)} 入栈;当栈不为空时,出栈并访问;同时判断出栈元素的相邻位置是否合法,如果合法则入栈;重复上述过程直到栈为空,则完深度优先搜索遍历。深度优先搜索的深度体现在每次尽可能朝一个方向访问以期达到最长的访问路径。以下是 C {\rm C++} 伪代码:

定义队列s;(0,0)压入栈中;
while(栈不为空){
	元素出栈并访问;
	找到元素的相邻位置并入栈;
}

同样地,假如规定每次仅在右方下方寻找相邻元素。为了避免重复访问,同广度优先遍历操作。 根据以上内容,得到最终的 C {\rm C++} 代码(以访问上述数组中的坐标为例):

void DFS() {
	// 定义方向数组,规定每次查找相邻位置的方向
	vector<int> dirX = { 1,0 };
	vector<int> dirY = { 0,1 };
	// 定义访问数组
	vector<vector<int>> vec(5, vector<int>(5, 0));
	// 定义栈
	stack<pair<int, int>> s;
	s.push(make_pair(0, 0));
	vec[0][0] = 1;
	// 以DFS方式访问
	while (!s.empty())
	{
		// 取栈顶元素并出栈
		pair<int, int> temp = s.top();
		s.pop();
		// 访问元素
		visit(temp);
		// 按照规定方向查找相邻位置
		for (int i = 0; i < 2; ++i) {
			int coorX = temp.first + dirX[i];
			int coorY = temp.second + dirY[i];
			// 边界条件
			if (coorX == 5 || coorY == 5) {
				continue;
			}
			// 访问相邻位置并入栈
			else if (!vec[coorX][coorY])
			{
				s.push(make_pair(coorX, coorY));
				vec[coorX][coorY] = 1;
			}
		}
	}
}

程序运行结果:
在这里插入图片描述

图5:深度优先搜索结果

此外,根据深度优先搜索的搜索方式,还可以使用递归形式。 C {\rm C++} 代码如下:

// 定义全局数组,这里定义一个6×6的访问数组,目的是控制边界条件和避免重复访问
vector<vector<int>> vec(6, vector<int>(6, 0));
void DFS(int i, int j, int N) {	
	// 递归边界条件
	if (i == N || j == N) {
		return;
	}
	// 递归访问
	else if(!vec[i][j])
	{
		if (i != 5 || j != 5) {
			// 访问并给访问数组赋值
			visit(make_pair(i, j));
			vec[i][j] = 1;
		}
		// 递归访问右面
		DFS(i, j + 1, N);
		// 递归访问下面
		DFS(i + 1, j, N);
	}
}

程序运行结果同图 4 4
可以看到广度优先搜索和深度优先搜索非递归形式的代码几乎是一样的,只是使用的数据结构不同。下一部分将介绍这两种算法的特点以及应用场景。


3. 总结

上面例子的代码是两种搜索代码的模板,实际问题中只用稍作修改就可以解决相关问题。下面给出两种搜索方法模板的使用方法。
(一)首先是广度优先搜索,由于在搜索过程中体现了从某个位置开始,由近向远层层扩展的方式访问元素,广度优先搜索过程中的最后一个位置一定是距离给定位置最远的元素。因此,广度优先搜索常常用于求解有关路径长度的问题(最短距离或打印最短路径)。下面以 L e e t C o d e {\rm LeetCode} 中的一道题说明(题目链接),题目大意:

  • 0代表空单元格
  • 1代表新鲜橘子
  • 2代表腐烂橘子

现给定一个仅包含数值 0 1 2 {\rm 0、1、2} 的二维数组模拟橘子腐烂过程:每分钟,任何与腐烂橘子相邻( 4 {\rm 4} 个正方向)的新鲜橘子都会腐烂。问所有新鲜橘子变为腐烂橘子的最小分钟数或由于空单元格的阻挡,最后仍会剩余新鲜橘子无法受到腐烂,则返回 1 {\rm ﹣1} 。如下图,模拟橘子腐烂的过程:在这里插入图片描述

图6:腐烂的橘子

图中蓝色代表到达当前状态所需的最少分钟数,红色表示上个状态到当前状态腐烂的新鲜橘子。

由题意可知,该问题符合广度优先搜索中的由近向远层层扩展的性质。具体地,从初始位置 2 {\rm 2} 处向外扩展,遇到相邻的 1 {\rm 1} 则将其变为 2 {\rm 2} 。此外,对于这道题还需注意几点

  • 上图展示的是只有一个初始位置,实际过程中还可能会存在多个初始位置,这时需要对所有初始位置同时向外扩展访问
  • 为了获得准确的分钟数,必须将当前层所有元素处理完后才能处理下一层的元素;
  • 为了避免重复访问,可以使用上述例子中的访问数组,也可以使用 C {\rm C++} s e t {\rm set} 数据结构。 C {\rm C++} 代码如下:
int orangesRotting(vector<vector<int>>& grid) {
	int result = 0;
	// 方向数组
	vector<int> dirX = {0, 0, -1, 1};
	vector<int> dirY = {-1, 1, 0, 0};
	// 定义队列实现BFS,定义集合避免重复访问
	queue<pair<int, int>> q;
	set<pair<int, int>> s;
	// 将所有值为2的位置入栈,值为1的位置入集合
	for (int i = 0; i < grid.size(); i++) {
		for (int j = 0; j < grid[i].size(); j++) {
			if (grid[i][j] == 2) {
				q.push(make_pair(i, j));
			}
			if (grid[i][j] == 1) {
				s.insert(make_pair(i, j));
			}
		}
	}
	while (!q.empty())
	{
		int flag = 0;		// 用于表示相邻位置是否还有元素1
		int size = q.size();
		// 保证当前层访问完成后才开始访问下一层
		for (int k = 0; k < size; k++) {
			pair<int, int> temp = q.front();
			q.pop();
			// 4个正方向搜索
            for(int d = 0; d < 4; ++ d){
            	auto t = make_pair(temp.first + dirX[d], temp.second + dirY[d]);
            	if (s.find(t) != s.end()) {
            		s.erase(t);
            		q.push(t);
            		flag = 1;
            	}
        	}
		}
		// 分钟数加一	
		if (flag) {
			++result;
		}
	}
	// 集合不为空,说明还剩余1没有变为2,则表示新鲜橘子不能完全腐烂
	if (!s.empty()) {
		return -1;
	}
	return result;
}

(二)深度优先搜索,由于在搜索过程中体现了从某个位置开始,按照某个方向访问元素,到达边界后使用回溯后再以不同方向访问。深度优先搜索最早提出用于解决迷宫问题,因为迷宫问题只需要问题有解,并不关心是否是最优解,深度优先搜索达到边界即可返回。由于深度优先遍历设计到回溯过程,在程序中通常使用剪枝避免重复计算。值得一提的是,能使用深度优先搜索解决的问题,一般情况下都能使用广度优先搜索。下面以 L e e t C o d e {\rm LeetCode} 中的一道题说明(题目链接),题目大意:

  • 0代表海洋
  • 1代表陆地

现给定一个仅包含数值 0 1 {\rm 0、1} 的二维数组模表示海洋和陆地。问所有陆地中的最大面积。如图:在这里插入图片描述

图7:岛屿的最大面积

最大面积为加粗部分,为 6 6 。该问题可以使用深度优先搜索访问计算每个区域的大小。具体地,从每个值为 1 {\rm 1} 的位置开始深度访问直到结束,记录当前访问区域的大小。为避免重复访问,这里将访问过的 1 {\rm 1} 置为 0 {\rm 0} C {\rm C++} 代码如下:

int maxAreaOfIsland(vector<vector<int>>& grid) {
    int maxArea = 0, temp = 0;
    stack<pair<int, int>> s;
	// 方向数组
	vector<int> dirX = {0, 0, -1, 1};
	vector<int> dirY = {-1, 1, 0, 0};
    // 遍历数组中的每个元素,对其中值为1的元素处理
    for (int i = 0; i < grid.size(); ++i) {
        for (int j = 0; j < grid[i].size(); ++j) {
            temp = 0;
            // 入栈
            if (grid[i][j] == 1) {
                s.push(make_pair(i, j));
            }
            // 对栈内元素进行处理
            while (!s.empty())
            {
                ++temp;
                pair<int, int>p = s.top();
                // 处理后置0
                grid[p.first][p.second] = 0;
                s.pop();
                for(int k = 0; k < 4; ++k){
                	auto t = make_pair(p.first + dirX[k], p.second + dirY[k]);
                    // 越界
                    if (t.first < 0 || t.first == grid.size() || t.second < 0 || t.second == grid[i].size())
                    {
                        continue;
                    }
                    // 周围值为1的元素入栈,并将当前处理完的元素置0
                    if (grid[t.first][t.second] == 1) {
                        s.push(t);
                        grid[t.first][t.second] = 0;
                    }
                }                   
            }
		}
           // maxArea用于保存最大值
		maxArea = max(maxArea, temp);
      }
	return maxArea;
}

深度优先遍历的递归形式:

// 得到由当前点(i,j)出发所得到的岛屿的面积
int dfs(vector<vector<int>>& grid, int i, int j) {
	// 递归出口
	 (i < 0 || i == grid.size() || j < 0 || j == grid[i].size() || grid[i][j] != 1) {
		return 0;
	}
	grid[i][j] = 0;
	// 方向数组
	vector<int> dirX = {0, 0, -1, 1};
	vector<int> dirY = {-1, 1, 0, 0};
	int count = 1;
	for (int id = 0; id < 4; ++id) {
		int next_i = i + dirX [id];
		int next_j = j + dirY [id];
		// 对当前位置递归访问并累计
		count += dfs(grid, next_i, next_j);
	}
	return count;
}
int maxAreaOfIsland(vector<vector<int>>& grid) {
	int count = 0;
	for (int i = 0; i < grid.size(); ++i) {
		for (int j = 0; j < grid[i].size(); ++j) {
			count = max(count, dfs(grid, i, j));
		}
	}
	return count;
}

这道题目也可以使用广度优先搜索,将深度优先搜索的非递归形式中的栈改为队列即可。

总结:上面介绍的两种搜索算法均是在图遍历的重要算法,而图的遍历又是其他图的操作的基础。一般地,在访问深度未知时使用广度优先搜索更为安全,而使用深度优先遍历可能陷入无限深度;广度优先搜索基本适用于所有的搜索问题;在访问深度已知且数据规模较大时使用深度优先搜索更节省计算时间和空间。经典的广度优先搜索问题:图的单源最短路径、图的层序遍历等,经典的深度优先搜索问题:8皇后问题、搜索树、迷宫问题等。这里给出 L e e t C o d e {\rm LeetCode} 中的相关题目:广度优先遍历深度优先遍历



猜你喜欢

转载自blog.csdn.net/Skies_/article/details/104938422
今日推荐