“”前面接触了深度优先搜索(DFS),现在来介绍一下广度优先搜索(BFS)。
如果我们 只是为了遍历一棵树、一张图上的所有结点的话,那么 DFS 和 BFS 的能力没什么差别,我们当然更倾向于更方便写、空间复杂度更低的 DFS 遍历。不过,某些使用场景是 DFS 做不到的,只能使用 BFS 遍历。这就是本文要介绍的两个场景:「层序遍历」、「最短路径」。
代码比较:
看以下两段代码,最直观的感受就是DFS比BFS的代码要简洁的多,这是因为递归调用的方式隐含的使用了系统的栈,我们不需要去自己在维护一个数据结构。如果简单的去遍历二叉树,DFS无疑是更加方便的一个选择。
DSF:
void dfs(TreeNode root) {
if (root == null) {
return;
}
dfs(root.left);
dfs(root.right);
}
BFS
void bfs(TreeNode root) {
Queue queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
// Java 的中队列采用offer/poll两个动作
//堆栈采用push/pop,不要混合使用,也不要采用list的add方法,避免思维混乱
TreeNode node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
遍历顺序
虽然 DFS 与 BFS 都是将二叉树的所有结点遍历了一遍,但它们遍历结点的顺序不同。 DFS是向下遍历,而BFS是按照层去遍历,这个遍历顺序也就是BFS能够用来解「层序遍历」、「最短路径」两个问题的根本原因。在 BFS 遍历的基础上区分遍历的每一层,就得到了层序遍历。在层序遍历的基础上记录层数,就得到了最短路径。
BFS解题模板
public int bfs() {
// 定义一个队列
Queue queue = new LinkedList<>();
// 定义层数,默认为0
int level = 0;
// 将最初的beginWord加入到队列,后面开始BFS遍历
queue.offer("beginWord");
// 开始BFS遍历的循环
// 循环条件是,定义的队列中有元素
while (!queue.isEmpty()) {
// 只要进入一次遍历,就代表层数+1;
// 例如level初始化为0,遍历根元素的时候,就要从0加到1,
level++;
// 首先获得当前层,元素的个数
// 因为后面遍历的过程中会不断的往queue中添加元素,如果不先记下这个个数,就可能会遍历到其他层去
int size = queue.size();
for (int i = 0; i < size; i++) {
// 当前层遍历到一个元素,就要把这个元素poll出来
String poll = queue.poll();
// 这下面的代码就要根据题目要求进行编写了
if("等于目标值") {
// 一般会要求返回最小路径长度之类的
return level;
}
if("符合路径要求的元素") {
// 将符合路径要求的元素加入队列中,后面进行下一层的遍历
queue.offer("符合路径要求的元素");
// 一般这个位置还会定义一个set,防止同一个元素被多次遍历
}
}
}
}
例题
127. 单词接龙
题目
字典
wordList
中从单词beginWord
和endWord
的 转换序列 是一个按下述规格形成的序列beginWord -> s1 -> s2 -> ... -> sk
:
- 每一对相邻的单词只差一个字母。
- 对于
1 <= i <= k
时,每个si
都在wordList
中。注意,beginWord
不需要在wordList
中。sk == endWord
给你两个单词
beginWord
和endWord
和一个字典wordList
,返回 从beginWord
到endWord
的 最短转换序列 中的 单词数目 。如果不存在这样的转换序列,返回0
。分析
“最短转换序列 ” 考虑用 BFS求解
代码
public int ladderLength(String beginWord, String endWord, List
wordList) { Set set = new HashSet<>(wordList); // 根据题意,如果给的wordlist为空或者不包括endWord,则没有转换路径,返回0; if (set.isEmpty() || !set.contains(endWord)) { return 0; } // 下面就是BFS的模板代码 // 定义一个队列 Queue queue = new LinkedList<>(); // 定义转换次数,默认为0 int level = 0; // 将最初的beginWord加入到队列,后面开始BFS遍历 queue.offer(beginWord); // 只要队列不为空,就遍历 while (!queue.isEmpty()) { // 只要进入一次遍历,就代表层数+1; level++; //遍历当前队列中的所有元素,先取得当前size int size = queue.size(); for (int i = 0; i < size; i++) { // 遍历的时候记得把当前这个字母从队列拿出来 String currWord = queue.poll(); // 如果遍历到了目标值,就直接返回层数 if (currWord.equals(endWord)) { return level; } // 这个方法就是BFS遍历时做的判断 for (int k = 0; k < currWord.length(); k++) { // 每一位都从a 遍历到 z for (char j = 'a'; j <= 'z'; j++) { // 组装当前遍历到的单词 String newWord = currWord.substring(0, k) + j + currWord.substring(k + 1); // 如果set中存在当前单词,代表这个单词是符合要求的,这个时候就可以进行下一层的遍历了 if (set.contains(newWord)) { // 想要遍历下一层,首先要把当前单词加到队列中 queue.offer(newWord); // 为了避免重复遍历,把已经遍历到的单词扔出去。 set.remove(newWord); } } } } } return 0; }
752. 打开转盘锁
题目
你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字:
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
。每个拨轮可以自由旋转:例如把'9'
变为'0'
,'0'
变为'9'
。每次旋转都只能旋转一个拨轮的一位数字。锁的初始数字为
'0000'
,一个代表四个拨轮的数字的字符串。列表
deadends
包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。字符串
target
代表可以解锁的数字,你需要给出解锁需要的最小旋转次数,如果无论如何不能解锁,返回-1
。分析
“最小旋转次数” ---“最短路径”,直接考虑BFS求解
代码
public int openLock(String[] deadends, String target) { // 如果目标值直接等于0000,则不需要拨动就可以打开,返回0 if ("0000".equals(target)) { return 0; } // 将不能拨的数放到一个set中 Set
dead = new HashSet (); Collections.addAll(dead, deadends); // 如果目标值就在不能拨的值中,则返回-1 if (dead.contains("0000")) { return -1; } // 下面开始BFS的代码模板 int step = 0; // 定义一个队列,存放当前层要遍历的元素 Queue queue = new LinkedList<>(); // 把根元素加到队列中,即最开始的0000 queue.offer("0000"); // 定义一个set ,存放已经遍历过的元素,防止重复遍历 Set seen = new HashSet<>(); seen.add("0000"); // 开始BFS遍历 while (!queue.isEmpty()) { // 只要进入循环,层数就+1 step++; // 遍历当前层的所有元素 // queue先进先出,这个位置先定义当前queue的数量, // 即便后面queue在添加进新的元素,也没有关系,当前层结束后,就会结束这个循环 int size = queue.size(); for (int i = 0; i < size; i++) { // 遍历到一个元素,就把他取出来 String status = queue.poll(); // 循环遍历当前元素通过一次拨动所有的结果 for (String nextStatus : get(status)) { // 如果拨动一次得到的结果没有遍历过,并且不在死亡数组中,就可以进行下一步判断 if (!seen.contains(nextStatus) && !dead.contains(nextStatus)) { // 如果当前结果正好是目标值,则可以直接返回step if (nextStatus.equals(target)) { return step; } // 否则,将当前值加入到queue中 queue.offer(nextStatus); // 此外,还要将当前值加入到seen中,避免后续重复遍历。 seen.add(nextStatus); } } } } return -1; } // 当前数字,往小了拨动 public char numPrev(char x) { return x == '0' ? '9' : (char) (x - 1); } // 当前数字往大了拨动 public char numSucc(char x) { return x == '9' ? '0' : (char) (x + 1); } // 枚举 status 通过一次旋转得到的数字 // 如0000旋转一次可以得到:9000, 1000, 0900, 0100, 0090, 0010, 0009, 0001 public List get(String status) { List ret = new ArrayList (); char[] array = status.toCharArray(); // 因为是4位数字,因此要循环遍历四次 for (int i = 0; i < 4; ++i) { // num存储当前位置的原始数字 char num = array[i]; // 往小了拨动得到的数放到list中 array[i] = numPrev(num); ret.add(new String(array)); // 往大了拨动得到的数放到list中 array[i] = numSucc(num); ret.add(new String(array)); // 记得将当前位置的数字复原 array[i] = num; } return ret; }
529. 扫雷游戏
题目:
分析:
可以尝试BFS进行解题
代码:
public class Test529_2 { // 因为每次遍历都要朝8个方向进行遍历,因此事先定义两个数组, // 这样就可以通过循环的方式去计算八个方向的数组位置 int[] dx = {-1, 1, 0, 0, -1, 1, -1, 1}; int[] dy = {0, 0, -1, 1, -1, 1, 1, -1}; public char[][] updateBoard(char[][] board, int[] click) { int x = click[0]; int y = click[1]; // 1. 若起点是雷,游戏结束,直接修改 board 并返回。 if (board[x][y] == 'M') { board[x][y] = 'X'; return board; } int m = board.length; int n = board[0].length; // 定义一个二维数组,代表board对应位置已经访问过了。 boolean[][] visited = new boolean[m][n]; // 创建一个队列 Queue
queue = new LinkedList<>(); // 把当前click添加到队列中 queue.offer(click); // 当前点是开始点,肯定是访问过了,对应位置赋值为true visited[x][y] = true; // 开始循环遍历queue while (!queue.isEmpty()) { // 根据题意,不需要最短路径,因此不需要分层,上文也没有定义level // 取出最先插入的元素 int[] poll = queue.poll(); // 定义一个计数器,代表该点周围地雷的数量 int count = 0; // 循环遍历八个方向的点,看是否存在地雷 for (int k = 0; k < 8; k++) { int a = poll[0] + dx[k]; int b = poll[1] + dy[k]; // 超出数组范围就不用访问了 if (a < 0 || a >= m || b < 0 || b >= n) { continue; } // 如果遇到地雷,计数器就加1 if (board[a][b] == 'M') { count++; } } // 周围八个位置遍历完了,就可以对该点进行赋值操作了 if (count > 0) { // 如果存在地雷,那么把地雷的数量赋值给该点 board[poll[0]][poll[1]] = (char) (count + '0'); } else { // 如果不存在地雷,那么就赋值为B,同时,还需要继续遍历周围的八个方向是否还存在空白方块 board[poll[0]][poll[1]] = 'B'; // 遍历八个方向 for (int k = 0; k < 8; k++) { int a = poll[0] + dx[k]; int b = poll[1] + dy[k]; // 此处做优化,超出数组范围的不访问 // 不为E的不访问(代表该处已经被挖过了) // 已经访问过了不在访问 if (a < 0 || a >= m || b < 0 || b >= n || board[a][b] != 'E' || visited[a][b]) { continue; } // 如果不满足上述的几个剔除条件,就要把该点放入队列中,进行下次遍历 // 同时,意味着该点已经被访问过了。visited标记为true visited[a][b] = true; queue.offer(new int[] {a, b}); } } } return board; } } 尝试用DFS解决问题:
public class Test529 { // 因为每次遍历都要朝8个方向进行遍历,因此事先定义两个数组, // 这样就可以通过循环的方式去计算八个方向的数组位置 int[] dx = {-1, 1, 0, 0, -1, 1, -1, 1}; int[] dy = {0, 0, -1, 1, -1, 1, 1, -1}; public char[][] updateBoard(char[][] board, int[] click) { int x = click[0]; int y = click[1]; // 1. 若起点是雷,游戏结束,直接修改 board 并返回。 if (board[x][y] == 'M') { board[x][y] = 'X'; } else { // 2. 若起点是空地,则从起点开始向 8 邻域的空地进行深度优先搜索。 dfs(board, x, y); } return board; } private void dfs(char[][] board, int i, int j) { // 定义计数器 int cnt = 0; for (int k = 0; k < 8; k++) { int x = i + dx[k]; int y = j + dy[k]; // 超过数组范围的,跳过 if (x < 0 || x >= board.length || y < 0 || y >= board[0].length) { continue; } // 如果存在地雷,那么计数器加1 if (board[x][y] == 'M') { cnt++; } } // 如果该点存在地雷,那么就赋响应的数字,然后就可以退出递归了。 if (cnt > 0) { board[i][j] = (char)(cnt + '0'); return; } // 若空地 (i, j) 周围没有雷,则将该位置修改为 ‘B’,向 8 邻域的空地继续搜索。 board[i][j] = 'B'; for (int k = 0; k < 8; k++) { int x = i + dx[k]; int y = j + dy[k]; // 超过数组范围的,跳过 // 不为E的,代表已经访问过了,因此也跳过。 if (x < 0 || x >= board.length || y < 0 || y >= board[0].length || board[x][y] != 'E') { continue; } // 递归调用 dfs(board, x, y); } } }
994. 腐烂的橘子
题目:
在给定的 m x n 网格 grid 中,每个单元格可以有以下三个值之一:
值 0 代表空单元格;
值 1 代表新鲜橘子;
值 2 代表腐烂的橘子。
每分钟,腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。返回 直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能,返回 -1分析:
最小分钟数,可以用BFS搜索
代码:
public int orangesRotting(int[][] grid) { int m = grid.length; int n = grid[0].length; // 定义四个方向 int[] dx = {1, -1, 0, 0}; int[] dy = {0, 0, 1, -1}; // 定义队列和visited Queue
queue = new LinkedList<>(); boolean[][] visited = new boolean[m][n]; // 注意这个位置,如果原始数组中就没有等于1的,那么要直接返回0; boolean hasFresh = false; // 遍历数组,将等于2的都放到队列中,并标记为已经访问 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (grid[i][j] == 2) { queue.offer(new int[] {i, j}); visited[i][j] = true; } if (grid[i][j] == 1) { hasFresh = true; } } } if (!hasFresh) { return 0; } // 开始进行BFS int level = 0; while (!queue.isEmpty()) { level++; int size = queue.size(); for (int i = 0; i < size; i++) { int[] poll = queue.poll(); // 对队列中的每个点进行上下左右四个方向的遍历 for (int j = 0; j < dx.length; j++) { int x = poll[0] + dx[j]; int y = poll[1] + dy[j]; // 超出数组范围就跳过 if (x < 0 || y < 0 || x >= m || y >= n) { continue; } // 如果没有被访问过,并且值为1,就说明这个被污染了 if (!visited[x][y] && grid[x][y] == 1) { // 将这个位置加入队列,并标记为2 visited[x][y] = true; grid[x][y] = 2; queue.offer(new int[] {x,y}); } } } } // 最后在遍历数组,如果存在等于1 的,说明有没被污染的,返回-1 for (int[] ints : grid) { for (int j = 0; j < n; j++) { if (ints[j] == 1) { return -1; } } } // 否则返回level-1; return level - 1; }
934. 最短的桥
题目
给你一个大小为 n x n 的二元矩阵 grid ,其中 1 表示陆地,0 表示水域。岛 是由四面相连的 1 形成的一个最大组,即不会与非组内的任何其他 1 相连。grid 中 恰好存在两座岛 。你可以将任意数量的 0 变为 1 ,以使两座岛连接起来,变成 一座岛 。返回必须翻转的 0 的最小数目。
分析
反转的0的最小数目,可以考虑用BFS来做。
题目中说岛恰好是两个,可以考虑如下思路:将其中一座岛上的数组统一标记为-1,将这个岛进行外扩,每次扩的距离是1,如果扩n次后,两个岛屿相连在一起,说明已经连上了,这个n就是我们要求的值。
代码
public class Test934 { // 定义四个方向 int[] dx = {1, -1, 0, 0}; int[] dy = {0, 0, 1, -1}; // 定义一个set,防止queue里面插入重复的数据 Set
arraySet = new HashSet<>(); public int shortestBridge(int[][] grid) { int length = grid.length; // 定义一个队列 Queue queue = new LinkedList<>(); // 遍历二维数组,遇到一个为1的点后,就dfs遍历,将值改成-1; // 因为题目中有两个岛,我们只需要将一个岛的值改成-1就可以了, // 因此定义一个bool值,只要遍历一个岛后,就赋值为true,防止后续继续遍历 boolean a = false; for (int i = 0; i < grid.length; i++) { for (int j = 0; j < grid.length; j++) { if (!a && grid[i][j] == 1) { dsf(grid, i, j,queue); break; } } } // 定义一个visited,开始BFS遍历 boolean[][] visited = new boolean[length][length]; // 题目要求最短的桥,这个level应该从-1开始 int level = -1; while (!queue.isEmpty()) { level++; int size = queue.size(); for (int k = 0; k < size; k++) { int[] poll = queue.poll(); if (isConnect(grid, poll[0], poll[1], visited, queue)) { return level; } } } return level; } // 判断两个桥是否连通了,判断条件就是该点的上下左右,既有-1,也有1(另外一座桥) private boolean isConnect(int[][] grid, int x, int y, boolean[][] visited, Queue queue) { Set set = new HashSet<>(); for (int i = 0; i < dx.length; i++) { int m = x + dx[i]; int n = y + dy[i]; if (m < 0 || n < 0 || m >= grid.length || n >= grid.length) { continue; } set.add(grid[m][n]); // 如果当前值为0,并且没被访问过,并且queue中也不含有这个点,就加入队列 if (grid[m][n] == 0 && !visited[m][n] && !arraySet.contains(Arrays.toString(new int[] {m, n}))) { // 加入队列同时,设置该点值为-1,并且标记为已经访问 queue.offer(new int[] {m, n}); arraySet.add(Arrays.toString(new int[] {m, n})); visited[m][n] = true; grid[m][n] = -1; } } return set.contains(-1) && set.contains(1); } // dfs调用,吧其中一个岛的值都改成-1,同时将这个到的点都放到queue中 private void dsf(int[][] grid, int x, int y,Queue queue) { if (x < 0 || y < 0 || x >= grid.length || y >= grid.length) { return; } if (grid[x][y] != 1) { return; } grid[x][y] = -1; queue.offer(new int[] {x, y}); arraySet.add(Arrays.toString(new int[] {x, y})); dsf(grid, x + 1, y,queue); dsf(grid, x - 1, y,queue); dsf(grid, x, y + 1,queue); dsf(grid, x, y - 1,queue); } }