解題思路
本文將會講解為什么這道題適合用廣度優先搜索(BFS),以及 BFS 適用於什么樣的場景。
DFS(深度優先搜索)和 BFS(廣度優先搜索)就像孿生兄弟,提到一個總是想起另一個。然而在實際使用中,我們用 DFS 的時候遠遠多於 BFS。那么,是不是 BFS 就沒有什么用呢?
如果我們使用 DFS/BFS 只是為了遍歷一棵樹、一張圖上的所有結點的話,那么 DFS 和 BFS 的能力沒什么差別,我們當然更傾向於更方便寫、空間復雜度更低的 DFS 遍歷。不過,某些使用場景是 DFS 做不到的,只能使用 BFS 遍歷。這就是本文要介紹的兩個場景:「層序遍歷」、「最短路徑」。
本文包括以下內容:
- DFS 與 BFS 的特點比較
- BFS 的適用場景
- 如何用 BFS 進行層序遍歷
- 如何用 BFS 求解最短路徑問題
DFS 與 BFS
讓我們先看看在二叉樹上進行 DFS 遍歷和 BFS 遍歷的代碼比較。
DFS 遍歷使用遞歸:
void dfs(TreeNode root) {
if (root == null) {
return;
}
dfs(root.left);
dfs(root.right);
}
BFS 遍歷使用隊列數據結構:
void bfs(TreeNode root) {
Queue<TreeNode> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll(); // Java 的 pop 寫作 poll()
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
}
只是比較兩段代碼的話,最直觀的感受就是:DFS 遍歷的代碼比 BFS 簡潔太多了!這是因為遞歸的方式隱含地使用了系統的 棧,我們不需要自己維護一個數據結構。如果只是簡單地將二叉樹遍歷一遍,那么 DFS 顯然是更方便的選擇。
雖然 DFS 與 BFS 都是將二叉樹的所有結點遍歷了一遍,但它們遍歷結點的順序不同。
這個遍歷順序也是 BFS 能夠用來解「層序遍歷」、「最短路徑」問題的根本原因。下面,我們結合幾道例題來講講 BFS 是如何求解層序遍歷和最短路徑問題的。
BFS 的應用一:層序遍歷
BFS 的層序遍歷應用就是本題了:
LeetCode 102. Binary Tree Level Order Traversal 二叉樹的層序遍歷(Medium)
給定一個二叉樹,返回其按層序遍歷得到的節點值。 層序遍歷即逐層地、從左到右訪問所有結點。
什么是層序遍歷呢?簡單來說,層序遍歷就是把二叉樹分層,然后每一層從左到右遍歷:
乍一看來,這個遍歷順序和 BFS 是一樣的,我們可以直接用 BFS 得出層序遍歷結果。然而,層序遍歷要求的輸入結果和 BFS 是不同的。層序遍歷要求我們區分每一層,也就是返回一個二維數組。而 BFS 的遍歷結果是一個一維數組,無法區分每一層。
那么,怎么給 BFS 遍歷的結果分層呢?我們首先來觀察一下 BFS 遍歷的過程中,結點進隊列和出隊列的過程:
截取 BFS 遍歷過程中的某個時刻:
可以看到,此時隊列中的結點是 3、4、5,分別來自第 1 層和第 2 層。這個時候,第 1 層的結點還沒出完,第 2 層的結點就進來了,而且兩層的結點在隊列中緊挨在一起,我們無法區分隊列中的結點來自哪一層。
因此,我們需要稍微修改一下代碼,在每一層遍歷開始前,先記錄隊列中的結點數量 nnn(也就是這一層的結點數量),然后一口氣處理完這一層的 nnn 個結點。
// 二叉樹的層序遍歷
void bfs(TreeNode root) {
Queue<TreeNode> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
int n = queue.size();
for (int i = 0; i < n; i++) {
// 變量 i 無實際意義,只是為了循環 n 次
TreeNode node = queue.poll();
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
}
}
這樣,我們就將 BFS 遍歷改造成了層序遍歷。在遍歷的過程中,結點進隊列和出隊列的過程為:
可以看到,在 while 循環的每一輪中,都是將當前層的所有結點出隊列,再將下一層的所有結點入隊列,這樣就實現了層序遍歷。
最終我們得到的題解代碼為:
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
Queue<TreeNode> queue = new ArrayDeque<>();
if (root != null) {
queue.add(root);
}
while (!queue.isEmpty()) {
int n = queue.size();
List<Integer> level = new ArrayList<>();
for (int i = 0; i < n; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
res.add(level);
}
return res;
}
BFS 的應用二:最短路徑
在一棵樹中,一個結點到另一個結點的路徑是唯一的,但在圖中,結點之間可能有多條路徑,其中哪條路最近呢?這一類問題稱為最短路徑問題。最短路徑問題也是 BFS 的典型應用,而且其方法與層序遍歷關系密切。
在二叉樹中,BFS 可以實現一層一層的遍歷。在圖中同樣如此。從源點出發,BFS 首先遍歷到第一層結點,到源點的距離為 1,然后遍歷到第二層結點,到源點的距離為 2…… 可以看到,用 BFS 的話,距離源點更近的點會先被遍歷到,這樣就能找到到某個點的最短路徑了。
小貼士:
很多同學一看到「最短路徑」,就條件反射地想到「Dijkstra 算法」。為什么 BFS 遍歷也能找到最短路徑呢?
這是因為,Dijkstra 算法解決的是帶權最短路徑問題,而我們這里關注的是無權最短路徑問題。也可以看成每條邊的權重都是 1。這樣的最短路徑問題,用 BFS 求解就行了。
在面試中,你可能更希望寫 BFS 而不是 Dijkstra。畢竟,敢保證自己能寫對 Dijkstra 算法的人不多。
最短路徑問題屬於圖算法。由於圖的表示和描述比較復雜,本文用比較簡單的網格結構代替。網格結構是一種特殊的圖,它的表示和遍歷都比較簡單,適合作為練習題。在 LeetCode 中,最短路徑問題也以網格結構為主。
最短路徑例題講解
LeetCode 1162. As Far from Land as Possible 離開陸地的最遠距離(Medium)
你現在手里有一份大小為 n×nn \times nn×n 的地圖網格
grid
,上面的每個單元格都標記為 0 或者 1,其中 0 代表海洋,1 代表陸地,請你找出一個海洋區域,這個海洋區域到離它最近的陸地區域的距離是最大的。我們這里說的距離是「曼哈頓距離」。(x0,y0)(x_0, y_0)(x0,y0) 和 (x1,y1)(x_1, y_1)(x1,y1) 這兩個區域之間的距離是 ∣x0−x1∣+∣y0−y1∣|x_0 - x_1| + |y_0 - y_1|∣x0−x1∣+∣y0−y1∣ 。
如果我們的地圖上只有陸地或者海洋,請返回 -1。
這道題就是一個在網格結構中求最短路徑的問題。同時,它也是一個「島嶼問題」,即用網格中的 1 和 0 表示陸地和海洋,模擬出若干個島嶼。
在上一篇文章中,我們介紹了網格結構的基本概念,以及網格結構中的 DFS 遍歷。其中一些概念和技巧也可以用在 BFS 遍歷中:
- 格子
(r, c)
的相鄰四個格子為:(r-1, c)
、(r+1, c)
、(r, c-1)
和(r, c+1)
; - 使用函數
inArea
判斷當前格子的坐標是否在網格范圍內; - 將遍歷過的格子標記為 2,避免重復遍歷。
對於網格結構的性質、網格結構的 DFS 遍歷技巧不是很了解的同學,可以復習一下上一篇文章:LeetCode 例題精講 | 12 島嶼問題:網格結構中的 DFS。
上一篇文章講過了網格結構 DFS 遍歷,這篇文章正好講解一下網格結構的 BFS 遍歷。要解最短路徑問題,我們首先要寫出層序遍歷的代碼,仿照上面的二叉樹層序遍歷代碼,類似地可以寫出網格層序遍歷:
// 網格結構的層序遍歷
// 從格子 (i, j) 開始遍歷
void bfs(int[][] grid, int i, int j) {
Queue<int[]> queue = new ArrayDeque<>();
queue.add(new int[]{r, c});
while (!queue.isEmpty()) {
int n = queue.size();
for (int i = 0; i < n; i++) {
int[] node = queue.poll();
int r = node[0];
int c = node[1];
if (r-1 >= 0 && grid[r-1][c] == 0) {
grid[r-1][c] = 2;
queue.add(new int[]{r-1, c});
}
if (r+1 < N && grid[r+1][c] == 0) {
grid[r+1][c] = 2;
queue.add(new int[]{r+1, c});
}
if (c-1 >= 0 && grid[r][c-1] == 0) {
grid[r][c-1] = 2;
queue.add(new int[]{r, c-1});
}
if (c+1 < N && grid[r][c+1] == 0) {
grid[r][c+1] = 2;
queue.add(new int[]{r, c+1});
}
}
}
}
以上的層序遍歷代碼有幾個注意點:
- 隊列中的元素類型是
int[]
數組,每個數組的長度為 2,包含格子的行坐標和列坐標。 - 為了避免重復遍歷,這里使用到了和 DFS 遍歷一樣的技巧:把已遍歷的格子標記為 2。注意:我們在將格子放入隊列之前就將其標記為 2。想一想,這是為什么?
- 在將格子放入隊列之前就檢查其坐標是否在網格范圍內,避免將「不存在」的格子放入隊列。
這段網格遍歷代碼還有一些可以優化的地方。由於一個格子有四個相鄰的格子,代碼中判斷了四遍格子坐標的合法性,代碼稍微有點啰嗦。我們可以用一個 moves
數組存儲相鄰格子的四個方向:
int[][] moves = {
{-1, 0}, {1, 0}, {0, -1}, {0, 1},
};
然后把四個 if 判斷變成一個循環:
for (int[][] move : moves) {
int r2 = r + move[0];
int c2 = c + move[1];
if (inArea(grid, r2, c2) && grid[r2][c2] == 0) {
grid[r2][c2] = 2;
queue.add(new int[]{r2, c2});
}
}
寫好了層序遍歷的代碼,接下來我們看看如何來解決本題中的最短路徑問題。
這道題要找的是距離陸地最遠的海洋格子。假設網格中只有一個陸地格子,我們可以從這個陸地格子出發做層序遍歷,直到所有格子都遍歷完。最終遍歷了幾層,海洋格子的最遠距離就是幾。
那么有多個陸地格子的時候怎么辦呢?一種方法是將每個陸地格子都作為起點做一次層序遍歷,但是這樣的時間開銷太大。
BFS 完全可以以多個格子同時作為起點。我們可以把所有的陸地格子同時放入初始隊列,然后開始層序遍歷,這樣遍歷的效果如下圖所示:
這種遍歷方法實際上叫做「多源 BFS」。多源 BFS 的定義不是今天討論的重點,你只需要記住多源 BFS 很方便,只需要把多個源點同時放入初始隊列即可。
需要注意的是,雖然上面的圖示用 1、2、3、4 表示層序遍歷的層數,但是在代碼中,我們不需要給每個遍歷到的格子標記層數,只需要用一個 distance
變量記錄當前的遍歷的層數(也就是到陸地格子的距離)即可。
最終,我們得到的題解代碼為:
public int maxDistance(int[][] grid) {
int N = grid.length;
Queue<int[]> queue = new ArrayDeque<>();
// 將所有的陸地格子加入隊列
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
if (grid[i][j] == 1) {
queue.add(new int[]{i, j});
}
}
}
// 如果地圖上只有陸地或者海洋,返回 -1
if (queue.isEmpty() || queue.size() == N * N) {
return -1;
}
int[][] moves = {
{-1, 0}, {1, 0}, {0, -1}, {0, 1},
};
int distance = -1; // 記錄當前遍歷的層數(距離)
while (!queue.isEmpty()) {
distance++;
int n = queue.size();
for (int i = 0; i < n; i++) {
int[] node = queue.poll();
int r = node[0];
int c = node[1];
for (int[] move : moves) {
int r2 = r + move[0];
int c2 = c + move[1];
if (inArea(grid, r2, c2) && grid[r2][c2] == 0) {
grid[r2][c2] = 2;
queue.add(new int[]{r2, c2});
}
}
}
}
return distance;
}
// 判斷坐標 (r, c) 是否在網格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
總結
可以看到,「BFS 遍歷」、「層序遍歷」、「最短路徑」實際上是遞進的關系。在 BFS 遍歷的基礎上區分遍歷的每一層,就得到了層序遍歷。在層序遍歷的基礎上記錄層數,就得到了最短路徑。
BFS 遍歷是一類很值得反復體會和練習的題目。一方面,BFS 遍歷是一個經典的基礎算法,需要重點掌握。另一方面,我們需要能根據題意分析出題目是要求最短路徑,知道是要做 BFS 遍歷。
本文講解的只是兩道非常典型的例題。LeetCode 中還有許多層序遍歷和最短路徑的題目
層序遍歷的一些變種題目:
- LeetCode 103. Binary Tree Zigzag Level Order Traversal 之字形層序遍歷
- LeetCode 199. Binary Tree Right Side View 找每一層的最右結點
- LeetCode 515. Find Largest Value in Each Tree Row 計算每一層的最大值
- LeetCode 637. Average of Levels in Binary Tree 計算每一層的平均值
對於最短路徑問題,還有兩道題目也是求網格結構中的最短路徑,和我們講解的距離島嶼的最遠距離非常類似:
還有一道在真正的圖結構中求最短路徑的問題:
經過了本文的講解,相信解決這些題目也不是難事。
本文作者 nettee,致力於寫各種 LeetCode 解題套路,讓你在解題之外舉一反三。歡迎 閱讀原文(排版更好),或者在力扣關注我,我會不定期更新題解文章。