小白式紧脏,尽量少说废话。如有问题,还望批评指教。
BFS(Breath First Search),又称广度优先遍历。
顾名思义,它是一种在优先在广度范围上遍历的一种方式,它用博大的胸怀一次包容”一层”节点。与之对应的就是DFS(Deep First Search),深度优先遍历,一直深入到头后再返回。
1. BFS是如何工作的?
在学习数据结构课程的时候,初遇BFS是在图的遍历。但是BFS的应用场景不仅限于真正的图结构。更多时候我们可以遇到树的访问,二维矩阵图,HashMap邻接表等等场景。但无论什么场景,都要发现BFS的本质所在,就是 队列和访问标记集合。下面就单独来聊聊他们。
什么是队列?数据结构中说过,队列是一种FIFO(先进先出)的数据结构,类似于食堂排队买饭,先来的就要先买到饭。根据BFS的特性,我们要对一个结构进行广度优先的访问就必须要用到队列这个工具。
以你眼前的这课二叉树为例。
初始操作都是将根节点入队,这个根节点可以作为BFS的一个“源”,是BFS的开始。
进入主循环操作后,从队列中取出第一个节点0,将与之相邻的下一层(这里是0的孩子节点1和2)入队,此时队列中是1和2,接下来继续循环,然后取出第一个节点1,将与之相邻的下一层入队,此时队列中是2,4,继续循环下去。循环的终止条件是队列为空,因为从图中可以看到,叶子节点再没有与之相邻的下层元素了,就什么都不添加,如此反复队列终会为空。
那到这里就可以看出为什么BFS用的是队列而不是栈了,我们知道栈是一种后进先出的数据结构,假如说BFS用了栈,某个节点入栈之后又接着出栈访问他相邻的元素,达到的是一种深度优先的效果。
2. 如何用代码实现BFS
在Java中实现一个基础的BFS的模板很简单,借助于LinkedList实现队列效果。主要的4个步骤都写在注释里了。而代码中所说的也在上面的文字叙述中提到过。
模板1【适用于二叉树】
void bfs(TreeNode treeNode) {
LinkedList<TreeNode> queue = new LinkedList<>();
//1. 添加源
queue.offerLast(treeNode);
//2. 队列不空持续循环
while (!queue.isEmpty()) {
//3.队首出队
TreeNode node = queue.pollFirst();
System.out.println(node.val);
//4.添加与之相邻的节点
if (node.left != null) queue.offerLast(node.left);
if (node.right != null) queue.offerLast(node.right);
}
}
那掌握了这个模板我该怎么用呢?不知道你有没有注意到上面代码的3和4步骤中的打印语句,如果单纯的是遍历,的确打印语句就可以满足要求,但是如果是更复杂的计数?或者其他操作你该怎么办?举个简单的例子,如何计算一棵树有多少个节点。相信不用我说你也知道将打印语句替换为 count++
。代码就是可以如此灵活的变通。
在掌握了上面的代码之后,我们可以小试牛刀。实现LeetCode 559. N叉树的最大深度 这个题。
要求求一棵N叉树的最大深度,也就是有多少层,这就要发挥我们BFS的作用了,因为BFS在树中又有另一个名字叫 “层序遍历”。那么我们先把模板Ctrl CV一下吧。
public int maxDepth(Node root) {
LinkedList<Node> queue = new LinkedList<>();
//1. 添加源
queue.offerLast(root);
//2. 队列不空持续循环
while (!queue.isEmpty()) {
//3.队首出队
Node cur = queue.pollFirst();
//4.添加与之相邻的节点
List<Node> children = cur.children;
if (children == null) continue;
for (int i = 0; i < children.size(); i++) {
Node child = cur.children.get(i);
if(child != null)queue.offerLast(child);
}
}
return 0;
}
上述代码和之前的代码几乎一样,只是因为这是一棵N叉树,所以我们第4步做了一个循环入队操作。那么现在在于我们怎么统计层数,在3,4之间count++
似乎完不成这个任务。最能直觉想到的是如果我能记录每个节点所在的层数就好了,每次访问他的孩子节点的时候就层数加一,最终统计最大的层数就是答案。
public int maxDepth(Node root) {
if(root == null)return 0;
int maxLevel = 1;
LinkedList<NodeWithLevel> queue = new LinkedList<>();
//1. 添加源
queue.offerLast(new NodeWithLevel(root, 1));
//2. 队列不空持续循环
while (!queue.isEmpty()) {
//3.队首出队
NodeWithLevel cur = queue.pollFirst();
//4.添加与之相邻的节点
List<Node> children = cur.node.children;
if (children == null) continue;
for (Node child : children) {
if (child != null) {
queue.offerLast(new NodeWithLevel(child, cur.level + 1));
maxLevel = Math.max(maxLevel, cur.level + 1);
}
}
}
return maxLevel;
}
class NodeWithLevel {
Node node;
int level;
public NodeWithLevel(Node node, int level) {
this.node = node;
this.level = level;
}
}
上面的代码是一个正确的解答,其实也没什么特别之处,只是用一个NodeWithLevel结构记录了每个节点的所在层,并在level发生改变的时候统计最大值。
但这就是最佳答案了吗?有没有更简单的方法呢?答案当然是有。我们完全可以省略定义的结构改成如下样子。
public int maxDepth(Node root) {
if (root == null) return 0;
int maxLevel = 0;
LinkedList<Node> queue = new LinkedList<>();
//1. 添加源
queue.offerLast(root);
//2. 队列不空持续循环
while (!queue.isEmpty()) {
//3.当前层全部出队
maxLevel++;
//3.1 需要单独计算长度
int size = queue.size();
for (int i = 0; i < size; i++) {
Node cur = queue.pollFirst();
//4.添加与之相邻的节点
List<Node> children = cur.children;
if (children == null) continue;
for (Node child : children) {
if (child != null) {
queue.offerLast(child);
}
}
}
}
return maxLevel;
}
可以看到第三步发生了变化。通过for循环将队列中现存的节点全部出队。这样会保证每次执行到while循环开始的时候都是准备处理下一层。在这个时机我们让层数计数自增以达到最终结果。
注意3.1注释地方,一定要缓存下队列长度,for循环中使用队列长度,否则队列长度一直在变,会影响for循环语句正常运行。
通过上面这个例子我们可以看出来,围绕着BFS模板,我们可以改它的打印语句部分,3部分和4部分以适应不同题目要求。
3. 这样就结束了吗?
如此,你便掌握了树的BFS操作。并能胜任大部分N叉树的BFS套题,那这样就是掌握了BFS了吗?并不是,因为上面给到的模板仅仅是针对于二叉树,或者灵活变通为兼容N叉树。
我们说初次遇见BFS的时候是在图中,那么接下来会遇到什么挑战呢?试想有这样一幅图。
这是一幅有环的图,假设我们选1为源点,复现模板1的操作步骤,1入队后出队,与之有直接指向关系的2入队,2出队,3入队。最后3出队,又遇到了1。如果此时我们再继续执行下去就会 “CPU不想跟你说话,并抛出了一个死循环警告⚠️” 了,那我们如何控制呢?最直观的想法,大不了我们就标志一下访问过不就好了。因此我们的访问标志集合诞生了。
下面是使用上面的多叉树的第一份代码来改进的。
模板2【适用于图或者二维矩阵结构】
public void bfs(Node root) {
LinkedList<Node> queue = new LinkedList<>();
//访问标记集合
Set<Node> set = new HashSet<>();
//1. 添加源
queue.offerLast(root);
set.add(root);
//2. 队列不空持续循环
while (!queue.isEmpty()) {
//3.队首出队
Node cur = queue.pollFirst();
//4.添加与之相邻的节点
List<Node> children = cur.children;
if (children == null) continue;
for (int i = 0; i < children.size(); i++) {
Node child = cur.children.get(i);
//已访问的不会再添加
if (set.contains(child)) continue;
if (child != null) {
queue.offerLast(child);
//标志已访问
set.add(child);
}
}
}
}
上面的代码添加了Set集合来标志已访问的对象。对于已经访问过的对象,下次再遇到的时候就不会再加入队列了。
通常情况下,我们不需要掌握第一个模板,因为它并不是一个通用的板子,我自我认为不管是什么结构,都加入访问标志为好,这样不仅可以做一些计数功能,还能防止重复访问或者陷入死循环的情况,而在代码上面也不会有太多的消耗(除非对空间比较严格)
4. 使用模板做各种扩展
前面说到过,BFS其实可以用在各种场景,比如leetcode很“火爆”的海岛问题就是BFS的一个非常好的应用。695. 岛屿的最大面积 让你求一个二维矩阵(0代表海洋,1代表陆地)所表示的图中陆地的最大面积。求岛屿的最大面积,无非是把所有可能的岛屿的面积都比较一下,那么我们如何求一个岛屿有多大呢?用BFS就可以啊,于是有了如下代码。
int bfs(int[][] nums, int sX, int sY) {
int ans = 0;
int xLen = nums.length;
int yLen = nums[0].length;
int[] x = new int[]{-1, 1, 0, 0};
int[] y = new int[]{0, 0, -1, 1};
//队列,必备。
LinkedList<Point> queue = new LinkedList<>();
//添加源,这里是用一个Point标记坐标,Point里包含了x,y坐标两个字段
queue.offerLast(new Point(sX, sY));
//访问后,我们直接改为其他值做访问标记,这里就不需要Set那么麻烦了。
nums[sX][sY] = 2;
//队列不空就循环
while (!queue.isEmpty()) {
//取出队头结点
Point point = queue.pollFirst();
//统计面积
ans++;
//对其周围上下左右4个方向来判断是否合法,如果合法就入队,下面提到了几种不合法的情况
for (int i = 0; i < 4; i++) {
int curX = point.x + x[i];
int curY = point.y + y[i];
//如果产生的越界,就跳过本次
if (curX < 0 || curX >= xLen || curY < 0 || curY >= yLen) continue;
//如果已经访问过,就跳过本次,如果是海洋也跳过。
if (nums[curX][curY] == 2 || nums[curX][curY] == 0) continue;
//否则就入队
queue.offerLast(new Point(curX, curY));
//将此节点标志为已访问
nums[curX][curY] = 2;
}
}
return ans;
}
可以看到上面的代码对于几个地方做了一点小技巧。
- 访问数组的地方,我们不用Set了,而是直接操作了数组,因为数组天生访问快捷,且不需要再浪费额外的空间。对于本题破坏数组不会影响答案正确性,所以我们直接用一个自定的与众不同的数来标识访问。
- for循环的地方我用了一个x,y数组来实现的4临界点访问,这个技巧也很常用,一方面是对于越界的处理更快捷了(数组环境中不是判断个null就可以过的,因为数组可以抛出访问越界异常),这个地方你可以仔细品味一下4临界的实现,同理还有8临界访问。
纵观全部代码,我们在开篇提到过的内容其实都有,只是写法更贴近实际场景。有了单个岛屿的面积,接下来求所有的岛屿面积进行比较
如果你也学会了灵活变通,那么恭喜你。你掌握了基本的广度优先遍历套路。
5. 总结与练习
上面给出的是基础的BFS框架,BFS可以应用于各种场景,同时也可以有多个源的版本。在后面还会有一篇文章专门聊聊多源BFS。那么为了更好的了解多源BFS,先做几个单源的题目吧。广度优先遍历合集 可以从easy开始练习。