广度优先搜索(BFS)-算法入门
广度优先搜索概述
广度优先搜索本质上,其实是一种迭代的思想,通过源点扩散地去搜索其他的点,搜到的点用队列来维护,再用这些点继续扩散,从而达到遍历全局;形象来说的话,可以想象成新冠疫情的扩散一样,先从一个城市爆发,如果不加以管制,它便扩散到它临近的城市,再以临近的城市为据点,继续扩散。这里就要再提一下深度优先搜索(DFS)了,深度优先搜索的本质是递归来实现的,它的实现上,不像BFS的扩散;形象地解释的话,感觉它有点像我们的打水井一样,一直打到底,打通了,从井里回来,继续打下一个;打不通,也从井里回来,继续打下一个,直到全部的打井点打完。广度优先搜索(BFS),我们需要关注的细节主要有四点,扩散类型;扩散方式;扩散条件,扩散行为下面用一些例题来了解,BFS具体是如何去实现的。
走迷宫
题目描述:
给定一个 n×m 的二维整数数组,用来表示一个迷宫,数组中只包含 0 或 1,其中 0 表示可以走的路,1 表示不可通过的墙壁。
最初,有一个人位于左上角 (1,1)(1,1) 处,已知该人每次可以向上、下、左、右任意一个方向移动一个位置。
请问,该人从左上角移动至右下角 (n,m)(n,m) 处,至少需要移动多少次。
数据保证 (1,1)(1,1) 处和 (n,m)(n,m) 处的数字为 0,且一定至少存在一条通路。
示例:
输入:5 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0
输出:8
解决方案:
#include<iostream>
#include<algorithm>
#include<cstring>
#include<queue>
using namespace std;
const int N=110;
typedef pair<int,int> pll;
int d[N][N],g[N][N];
int n,m;
int bfs(){
queue<pll> q;
q.push({0,0});
memset(d,-1,sizeof(d));//给d全局赋值
d[0][0]=0;
int dx[4]={-1,1,0,0},dy[4]={0,0,-1,1};
while(q.size()){
pll t=q.front();
q.pop();//两条语句相当于t=q[hh++]
for(int i=0;i<4;i++){
int x=t.first+dx[i],y=t.second+dy[i];
if(x>=0&&x<n&&y>=0&&y<m&&g[x][y]==0&&d[x][y]==-1){
d[x][y]=d[t.first][t.second]+1;
q.push({x,y});//相当于队列中q[++tt]=({x,y})
}
}
}
return d[n-1][m-1];
}
int main(){
cin>>n>>m;
for(int i=0;i<n;i++)
for(int j=0;j<m;j++)
cin>>g[i][j];
cout<<bfs()<<endl;
return 0;
}
分析:首先来观察它的扩散类型,它的源点即是一个二维的坐标,所以我们用pair<int, int>来定义;再来看它的扩散方式,上下左右四个方向扩散,我们用dx[4],dy[4]和一个for循环来表示它的移动;最后它的扩散条件①一个是不能超过地图边界②访问过的点不再访问③是遇到墙壁不能走;一般情况下,前两点都是包含在扩散条件之中的;扩散行为(扩散后要干什么)我们的目的是获得扩散到终点走的步数,那么我们每扩散一步,扩散得到的点的步数 = 扩散前那个点的步数 + 1;结束条件,队列为空(即已经全部扩散到了,不能再往下扩散)。
接下来看一道多源点扩散的题目
岛屿数量
题目描述:
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。
输入:grid = [
["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
输出:1
输入:grid = [
["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]
]
输出:3
解决方案:
class Solution {
public:
const int dx[4] = {0,-1,1,0};
const int dy[4] = {1,0,0,-1};
int numIslands(vector<vector<char>>& grid) {
int count = 0;
int m = grid.size(), n = grid[0].size();//求行和列的值
vector<vector<int>> grid_(m,vector<int>(n));
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
grid_[i][j]=grid[i][j] - '0';
bool visited[m][n];
memset(visited,false,m * n);
queue<pair<int,int>> q;
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++) {
if (!visited[i][j]&&grid_[i][j]==1){
q.emplace(i,j);
visited[i][j] = true;
while (!q.empty()) {
int x = q.front().first, y = q.front().second;
q.pop();
for (int k = 0; k < 4; k++) {
int mx = x + dx[k], my = y + dy[k];
if (mx>=0&&mx<m&&my>=0&&my<n&&!visited[mx][my]&&grid_[mx][my]==1) {
q.emplace(mx,my);
visited[mx][my] = true;
}
}
}
count++;
}
}
return count;
}
};
//q.emplace()和q.push()差不多,就是前者的功能更全面一些,但版本老一点的编译器似乎不支持,具体区别可以自己去网上查查看啦^ _ ^
分析:
扩散类型:若干个二维的陆地坐标(grid[][] = = 1)
扩散方式:上下左右四个方向
扩散条件:①一个是不能超过地图边界 ②访问过的点不再访问 (visited[x][y] == false)③扩散得到的区域是陆地
扩散行为:用一个计数器count初始为0,每扩散得到一片相通的岛屿即一块陆地,便让count++
再扩散到一块陆地后,我们再去寻找新的没有被扩散到的岛屿(用两层循环实现),以它为源点,继续扩散,直到结束。
再看一道多源点已知的题目
01矩阵
题目描述:
给定一个由 0 和 1 组成的矩阵 mat ,请输出一个大小相同的矩阵,其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。两个相邻元素间的距离为 1 。
输入:mat = [[0,0,0],[0,1,0],[0,0,0]]
输出:[[0,0,0],[0,1,0],[0,0,0]]
输入:mat = [[0,0,0],[0,1,0],[1,1,1]]
输出:[[0,0,0],[0,1,0],[1,2,1]]
解决方案:
class Solution {
public:
const int dx[4] = {0,0,1,-1};
const int dy[4] = {1,-1,0,0};
vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();
vector<vector<int>> dist(m, vector<int>(n));
queue<pair<int, int>> q;
bool visited[m][n];
memset(visited, false, m * n);
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (!mat[i][j]){
q.emplace(i,j);
visited[i][j] = true;
}//将所有的0弹入队列中
while (!q.empty()) {
int x =q.front().first, y =q.front().second;
q.pop();
for (int k = 0; k < 4; k++) {
int mx = x + dx[k], my = y + dy[k];
if (mx>=0&&mx<m&&my>=0&&my<n&&!visited[mx][my]){
dist[mx][my] = dist[x][y] + 1;
q.emplace(mx,my);
visited[mx][my] = true;
}
}
}
return dist;
}
};
分析:
仔细观察,这个题目其实是前两道题目的结合版,四大细节基本和第一个题目相同,但它是一个和第二题一样的多源点题目,以多个0的位置为起点,找到最近的1的距离,我们一开始便将全部的0的位置加入队列中,去找到非0的位置,具体细节和第一题相仿,然后再输出dist即可,学过最短路径的朋友,是不是会觉得很像多源最短路径hh。看到这里,如果都弄懂了的话,可以用下面这题练练手,比上面的题目应该要稍微难一点,自己做出来了就会很有成就感啦。
腐烂的橘子
之前看到的题目的扩散类型都是坐标,也是比较常见的BFS解决类型,接下来看看与树的结点相关的扩散。
填充每一个结点的下一个右侧结点
题目描述:
给定一个 完美二叉树 ,其所有叶子节点都在同一层,每个父节点都有两个子节点。二叉树定义如下:
struct Node {
int val;
Node *left;
Node *right;
Node *next;
}
填充它的每个 next 指针,让这个指针指向其下一个右侧节点。如果找不到下一个右侧节点,则将 next 指针设置为 NULL。初始状态下,所有 next 指针都被设置为 NULL。
输入:root = [1,2,3,4,5,6,7]
输出:[1,#,2,3,#,4,5,6,7,#]
解释:给定二叉树如图 A 所示,你的函数应该填充它的每个 next 指针,以指向其下一个右侧节点,如图 B 所示。序列化的输出按层序遍历排列,同一层节点由 next 指针连接,'#' 标志着每一层的结束
解决方案:
class Solution {
public:
Node* connect(Node* root) {
if (!root) return root;
queue<Node*> q;
q.emplace(root);
while (!q.empty()) {
int n = q.size();
for (int i = 0; i < n; i++) {//遍历每一层的点
Node* t = q.front();
q.pop();
if (i < n -1) t->next = q.front();//n-1时不修改
if (t->left) q.emplace(t->left);
if (t->right) q.emplace(t->right);
}
}
return root;
}
};
分析:
扩散类型:树的结点
扩散方式:一层一层地扩散(可以理解为层次遍历)
扩散条件:只要当前结点有左节点或右节点就继续扩散
扩散行为:只要不是当前这一层的最后一个结点,就将当前扩散到结点t的next指针修改为它在树中的右边的结点(即在队列中q.pop()后的队首结点)
这个题目的空间复杂度其实可以降到常数级别,直接用层次遍历的方法,感兴趣可以看看,代码放在下面:
class Solution {
public:
Node* connect(Node* root) {
if (!root) return root;
Node* leftm = root;//leftm为每一层最左边结点
while (leftm->left) {
Node* t = leftm;//因为leftm要被修改,我们需要用t暂存leftm的值,方便遍历下一层
while (t) {
t->left->next = t->right;
if (t->next) t->right->next = t->next->left;//t的右节点的next指针修改为,t的next指针的左节点(可以看图用笔比划一下)
t = t->next;
}
leftm = leftm->left;//到下一层去
}
return root;
}
};
其他:BFS实现与DFS实现对比 合并二叉树
题目描述:
给你两棵二叉树: root1 和 root2 。想象一下,当你将其中一棵覆盖到另一棵之上时,两棵树上的一些节点将会重叠(而另一些不会)。你需要将这两棵树合并成一棵新二叉树。合并的规则是:如果两个节点重叠,那么将这两个节点的值相加作为合并后节点的新值;否则,不为 null 的节点将直接作为新二叉树的节点。返回合并后的二叉树。注意: 合并过程必须从两个树的根节点开始。
输入:root1 = [1,3,2,5], root2 = [2,1,3,null,4,null,7]
输出:[3,4,5,5,4,null,7]
输入:root1 = [1], root2 = [1,2]
输出:[2,2]
解决方案:
BFS(迭代)
class Solution {
public:
TreeNode* mergeTrees(TreeNode* r1, TreeNode* r2) {
if (!r1 || !r2) {//有空树,直接返回非空的树
return r1 == NULL? r2 : r1;
}
queue<TreeNode* > qt;
qt.emplace(r1), qt.emplace(r2);
while(!qt.empty()) {
TreeNode* t1 = qt.front();
qt.pop();
TreeNode* t2 = qt.front();
qt.pop();
t1->val += t2->val;
if (t1->left && t2->left) {
qt.emplace(t1->left);
qt.emplace(t2->left);
}
else if (!t1->left){
t1->left = t2->left;
}
if (t1->right && t2->right) {
qt.emplace(t1->right);
qt.emplace(t2->right);
}
else if (!t1->right) {
t1->right = t2->right;
}
}
return r1;
}
};
DFS(递归)
TreeNode* dfs(TreeNode* r1, TreeNode* r2) {
if (!r1 || !r2) {
return r1 == NULL ? r2 : r1;
}
r1->val += r2->val;
r1->left = dfs(r1->left, r2->left);
r1->right = dfs(r1->right, r2->right);
return r1;
};
class Solution {
public:
TreeNode* mergeTrees(TreeNode* root1, TreeNode* root2) {
if (!root1 || !root2) {
return root1 == NULL? root2 : root1;
}
return dfs(root1, root2);
}
};
分析:题目的具体情况比较简单,如果两棵树左右子节点处都存在结点,将它们的值相加,如果第一棵树上的左结点没有,把第二棵上的结点搬过来,右结点同理。
BFS运行情况
DFS运行情况
结论:
可以看到BFS的运行速度显然更快(28ms比36ms),但DFS运行更慢,空间复杂度上差不多(31.7MB比31.4MB),,因为递归的底层需要系统堆栈,效率较低。但DFS也有一个很明显的优势代码简洁。如果大家还想进一步理解BFS(迭代),DFS(递归)的区别建议大家可以去看看这篇文章 递归与迭代的对比,更加深层地去了解他们。
总结
BFS是一种以迭代为基本方法的搜索方式,主要实现细节有扩散类型;扩散方式;扩散条件,扩散行为(个人认为^ o ^)。其实现代码较为复杂,但思路较为清晰,相较于递归的递推和回溯的方式,BFS更容易理解,而且运行速度较快,我个人更喜欢用BFS的方式做题,但是在一些特定的复杂情况下,用BFS的方式模拟过于复杂,我们也需要了解DFS的方式,即使它的内部比较难以理解。
终于写完了,累瘫