算法笔记 揭开广度优先遍历BFS的神秘面纱 HERODING的算法之路
Posted HERODING23
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法笔记 揭开广度优先遍历BFS的神秘面纱 HERODING的算法之路相关的知识,希望对你有一定的参考价值。
前言
最近到了面试的高峰时期,前段时间也刷了不少广度优先遍历的算法题,算是对广度优先遍历有了一点点的理解与思考吧,希望在此记录下来,为后人以及未来的自己提供一个回忆的捷径~
1. 审题
既然是使用广度优先算法,那么首先得明白遇到的题目是否能够使用BFS,这又分为两种情况,一个是树的层次遍历,一个是有关图的遍历(包括邻接矩阵和链表),下面我将分别对这两种情况进行说明。
1.1 树的BFS
经常遇到的树的层次遍历一般就是用到BFS,比如统计某个节点的深度,统计所有节点的总和(DFS也可),统计叶子节点的总和(DFS也可),以及拷贝二叉树和二叉树的序列化,这些题目都属于同一种类型,解决方法也都是异曲同工之妙,解法将在下面进行介绍。
1.2 图的BFS
图的BFS规律特别好找,虽然一般审题上经常感觉是动态规划(确实有不少可以用dp去解决),如果你有幸学过编译原理,那么对有限自动机有所了解,有限自动机就是遇到一个新的字母或者数值会从当前状态跳到下一个状态,一般从初始状态到最终状态有多条路径,不同路径的步数可能相同可能不同,但是都会有最短的路径,即步数最少。图的BFS也是同样道理,经常是求比如图中从一个点到另一个点的最短路径,也就是从初始状态到目标状态的步数,下面是解法的详细介绍。
2. 解法
使用过BFS的朋友都会有这样的认识,即BFS就是通过队列实现的,这确实是BFS的核心,一般都是将初始位置或者节点放入队列中,以队列非空为条件,不断循环,一直到得到最后的结果退出或者队列空退出。
2.1 树的BFS
在树的BFS中,通常是将树的根节点放入到定义好的queue<TreeNode*> q
中,然后不断遍历左右子树,如果存在则放入到队列中,循环遍历直到队列为空,中间的操作步骤根据题意进行即可,下面将举一个例子强化记忆。
比如LeetCode 297题,是关于二叉树的序列化过程,其实就是将二叉树转成字符串数组的形式存起来,那对于这个过程很容易想到层次遍历的方式,即使用广度优先遍历,过程如下:
- 利用队列广度优先遍历二叉树;
- 所遍历到的无论是否空的节点都加入到string中,空节点用null标记,节点之间用,分割;
- 最终得到的string是序列化的结果。
代码如下:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Codec {
public:
// Encodes a tree to a single string.
string serialize(TreeNode* root) {
string res;
// 如果根为空
if(root == nullptr) {
return res;
}
queue<TreeNode*> q;
q.push(root);
while(!q.empty()) {
TreeNode* demo = q.front();
q.pop();
if(demo != nullptr) {
res += to_string(demo -> val) + ",";
q.push(demo -> left);
q.push(demo -> right);
} else {
res += "null,";
}
}
return res;
}
// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
if(data == "") {
return nullptr;
}
vector<string> vals;
// 分割字符串
string s;
for(int i = 0; i < data.size(); i ++) {
if(data[i] == ',') {
vals.push_back(s);
s = "";
} else {
s += data[i];
}
}
TreeNode* root = new TreeNode(atoi(vals[0].c_str()));
queue<TreeNode*> q;
q.push(root);
// 标记当前根的位置
int i = 0;
while(!q.empty()) {
TreeNode* demo = q.front();
q.pop();
// 有左节点
if(vals[i * 2 + 1] != "null") {
demo -> left = new TreeNode(atoi(vals[i * 2 + 1].c_str()));
q.push(demo -> left);
}
// 有右节点
if(vals[i * 2 + 2] != "null") {
demo -> right = new TreeNode(atoi(vals[i * 2 + 2].c_str()));
q.push(demo -> right);
}
i ++;
}
return root;
}
};
// Your Codec object will be instantiated and called as such:
// Codec codec;
// codec.deserialize(codec.serialize(root));
观察代码中serialize函数部分,首先把根节点放入队列中,然后循环遍历队列,然后在将左右子节点放入队列,如此反复,中间掺杂着把节点的值存入字符串中的操作,最后便能得出层次遍历的二叉树用string存储的结果。
2.2 图的BFS
图的BFS正如我在审题部分解释那样,队列中存放的节点表示处于同一状态的节点,一旦某个节点可以到达目的状态,那么此时的步数(状态从初始开始变换的次数)就是到达目的状态的最短长度,所以就是不断遍历队列中的数,并把获得的下一状态装入队列中,每次到达新的状态,step(步数)就要更新,一直到达目的状态,返回此时的step。注意,每次遍历过一个点都要记得标记。下面举几个例子。
第一个例子是最经典的二进制矩阵中的最短路径问题,从左上角出发,到达右下角位置,求最短路径,左上角是初始状态,入队列,其右、下、右下是它所能到的下一个状态(还要判断一下是否是0),全部放入队列中,step+1,再次遍历新的状态,如此反复,直到到达右下角,返回step,代码如下:
class Solution {
public:
int shortestPathBinaryMatrix(vector<vector<int>>& grid) {
// 所给的地图为空
if(grid.size() == 0) {
return -1;
}
// 确定方向
int direction[8][2] = {{1, -1}, {1, 0}, {1, 1}, {0, 1}, {0, -1}, {-1, -1}, {-1, 0}, {-1, 1}};
int m = grid.size(), n = grid[0].size();
// 用队列存储每一轮能够经过的地方
queue<vector<int>> q;
// 把第一个点放进去
q.push({0, 0});
int length = 0;
// 如果队列不为空,说明还能走,空了说明走不下去了直接返回-1
while(!q.empty()) {
// 每一轮步长+1
length ++;
int size = q.size();
// 遍历每一轮中能够走到的位置
while(-- size >= 0) {
vector<int> site = q.front();
q.pop();
int x = site[0], y = site[1];
// 如果不能走
if(grid[x][y] == 1) {
continue;
}
// 如果走到右下角
if(x == m - 1 && y == n - 1) {
return length;
}
// 走过的位置标记为不能走
grid[x][y] = 1;
// 八个方向遍历下一步能走的位置
for(auto d : direction) {
int x1 = x + d[0], y1 = y + d[1];
if(x1 < 0 || x1 >= m || y1 < 0 || y1 >= n) {
continue;
}
if(grid[x1][y1] == 1) {
continue;
}
q.push({x1, y1});
}
}
}
return -1;
}
};
/*作者:heroding
链接:https://leetcode-cn.com/problems/shortest-path-in-binary-matrix/solution/c-bfsdui-lie-by-heroding-biko/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/
几乎每一行都有注释,我想自己看完这些注释大家都可以明白,所以这里我不再对代码细说了。
第二个例子本来想说蛇梯棋,但是该题的本质和上一个例子完全一样,只是换成了左下角到右上角(或者左上角),只不过多了一个蛇梯子的判断,以及蛇形的序列与数组序列的转换问题,所以不细说了。重点说的是另一种应用,就是与Map结合的方式,LeetCode 815 公交线路就是我想说的这种问题,与单纯的点集中不同方向遍历不同,这种线路遍历相当于行式遍历,每次遍历都是一整个线路,所以标记的话就直接按照行(线路)标记了,Map的作用在这里是为了统计经过同一个点的线路有哪些,代码如下:
class Solution {
public:
int numBusesToDestination(vector<vector<int>>& routes, int source, int target) {
// 如果起点终点相同
if(source == target) {
return 0;
}
// 标记站点出现的线路
unordered_map<int, vector<int>> mp;
// 标记访问的线路
vector<bool> visited(routes.size(), false);
// 记录站点出现的线路
for(int i = 0; i < routes.size(); i ++) {
for(int j = 0; j < routes[i].size(); j ++) {
mp[routes[i][j]].push_back(i);
}
}
// 定义队列并初始化
queue<int> q;
q.push(source);
int step = 0;
while(!q.empty()) {
step ++;
int len = q.size();
for(int i = 0; i < len; i ++) {
int site = q.front();
q.pop();
for(int& r : mp[site]) {
if(!visited[r]) {
for(int j = 0; j < routes[r].size(); j ++) {
if(routes[r][j] == target) {
return step;
}
q.push(routes[r][j]);
}
visited[r] = true;
}
}
}
}
return -1;
}
};
/*作者:heroding
链接:https://leetcode-cn.com/problems/bus-routes/solution/cbfs-mapxiang-jie-by-heroding-9we4/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/
观察代码可以感受到这道题目的趣味性,为啥和以前按照点遍历不一样,如果按照点不能处理吗?能,但是特别占用内存和耗时,因为这里不能对访问过的站点标记,你标记完了,别的路线不能访问了,这样不标记的方法肯定会导致时间复杂度爆炸。
3. 总结
本文中我将BFS的方法总结为树和图两种,可能会因为我见识短浅接触过少从而有其他的数据结构类型也可以使用BFS,在此深感抱歉,希望读者可以指出,我将对此进行更新。但是不得不说,如上所述方法可以应对大部分的BFS题型,套路模板都是一样的,直接套用即可,只要读者耐心读下去,一定能有所收获,当然实践是检验真知的唯一手段,还是需要大家勤练习,才能真正掌握BFS的精妙之处,从而以不变应万变。
以上是关于算法笔记 揭开广度优先遍历BFS的神秘面纱 HERODING的算法之路的主要内容,如果未能解决你的问题,请参考以下文章
数据结构学习笔记——图的遍历算法(深度优先搜索和广度优先搜索)