網格問題的基本概念
我們首先明確一下島嶼問題中的網格結構是如何定義的,以方便我們后面的討論。
網格問題是由 m*n個小方格組成一個網格,每個小方格與其上下左右四個方格認為是相鄰的,要在這樣的網格上進行某種搜索。
島嶼問題是一類典型的網格問題。每個格子中的數字可能是 0 或者 1。我們把數字為 0 的格子看成海洋格子,數字為 1 的格子看成陸地格子,這樣相鄰的陸地格子就連接成一個島嶼。
在這樣一個設定下,就出現了各種島嶼問題的變種,包括島嶼的數量、面積、周長等。不過這些問題,基本都可以用 DFS 遍歷來解決。
DFS 的基本結構
網格結構要比二叉樹結構稍微復雜一些,它其實是一種簡化版的圖結構。要寫好網格上的 DFS 遍歷,我們首先要理解二叉樹上的 DFS 遍歷方法,再類比寫出網格結構上的 DFS 遍歷。我們寫的二叉樹 DFS 遍歷一般是這樣的:
1 void traverse(TreeNode root) { 2 // 判斷 base case 3 if (root == null) { 4 return; 5 } 6 // 訪問兩個相鄰結點:左子結點、右子結點 7 traverse(root.left); 8 traverse(root.right); 9 }
可以看到,二叉樹的 DFS 有兩個要素:「訪問相鄰結點」和「判斷 base case」。
要素一:訪問相鄰結點。二叉樹的相鄰結點非常簡單,只有左子結點和右子結點兩個。二叉樹本身就是一個遞歸定義的結構:一棵二叉樹,它的左子樹和右子樹也是一棵二叉樹。那么我們的 DFS 遍歷只需要遞歸調用左子樹和右子樹即可。
要素二:判斷 base case。一般來說,二叉樹遍歷的 base case 是 root == null
。這樣一個條件判斷其實有兩個含義:一方面,這表示 root
指向的子樹為空,不需要再往下遍歷了。另一方面,在 root == null
的時候及時返回,可以讓后面的 root.left
和 root.right
操作不會出現空指針異常。
對於網格上的 DFS,我們完全可以參考二叉樹的 DFS,寫出網格 DFS 的兩個要素:
首先,網格結構中的格子有多少相鄰結點?答案是上下左右四個。對於格子 (r, c)
來說(r 和 c 分別代表行坐標和列坐標),四個相鄰的格子分別是 (r-1, c)
、(r+1, c)
、(r, c-1)
、(r, c+1)
。換句話說,網格結構是「四叉」的。
其次,網格 DFS 中的 base case 是什么?從二叉樹的 base case 對應過來,應該是網格中不需要繼續遍歷、grid[r][c]
會出現數組下標越界異常的格子,也就是那些超出網格范圍的格子。
這一點稍微有些反直覺,坐標竟然可以臨時超出網格的范圍?這種方法我稱為「先污染后治理」—— 甭管當前是在哪個格子,先往四個方向走一步再說,如果發現走出了網格范圍再趕緊返回。這跟二叉樹的遍歷方法是一樣的,先遞歸調用,發現 root == null
再返回。
這樣,我們得到了網格 DFS 遍歷的框架代碼:
1 void dfs(int[][] grid, int r, int c) { 2 // 判斷 base case 3 // 如果坐標 (r, c) 超出了網格范圍,直接返回 4 if (!inArea(grid, r, c)) { 5 return; 6 } 7 // 訪問上、下、左、右四個相鄰結點 8 dfs(grid, r - 1, c); 9 dfs(grid, r + 1, c); 10 dfs(grid, r, c - 1); 11 dfs(grid, r, c + 1); 12 } 13 14 // 判斷坐標 (r, c) 是否在網格中 15 boolean inArea(int[][] grid, int r, int c) { 16 return 0 <= r && r < grid.length 17 && 0 <= c && c < grid[0].length; 18 }
如何避免重復遍歷
網格結構的 DFS 與二叉樹的 DFS 最大的不同之處在於,遍歷中可能遇到遍歷過的結點。這是因為,網格結構本質上是一個「圖」,我們可以把每個格子看成圖中的結點,每個結點有向上下左右的四條邊。在圖中遍歷時,自然可能遇到重復遍歷結點。
這時候,DFS 可能會不停地「兜圈子」,永遠停不下來,如下圖所示:
如何避免這樣的重復遍歷呢?答案是標記已經遍歷過的格子。以島嶼問題為例,我們需要在所有值為 1 的陸地格子上做 DFS 遍歷。每走過一個陸地格子,就把格子的值改為 2,這樣當我們遇到 2 的時候,就知道這是遍歷過的格子了。也就是說,每個格子可能取三個值:
- 0 —— 海洋格子
- 1 —— 陸地格子(未遍歷過)
- 2 —— 陸地格子(已遍歷過)
我們在框架代碼中加入避免重復遍歷的語句:
1 void dfs(int[][] grid, int r, int c) { 2 // 判斷 base case 3 if (!inArea(grid, r, c)) { 4 return; 5 } 6 // 如果這個格子不是島嶼,直接返回 7 if (grid[r][c] != 1) { 8 return; 9 } 10 grid[r][c] = 2; // 將格子標記為「已遍歷過」 11 12 // 訪問上、下、左、右四個相鄰結點 13 dfs(grid, r - 1, c); 14 dfs(grid, r + 1, c); 15 dfs(grid, r, c - 1); 16 dfs(grid, r, c + 1); 17 } 18 19 // 判斷坐標 (r, c) 是否在網格中 20 boolean inArea(int[][] grid, int r, int c) { 21 return 0 <= r && r < grid.length 22 && 0 <= c && c < grid[0].length; 23 }
這樣,我們就得到了一個島嶼問題、乃至各種網格問題的通用 DFS 遍歷方法。以下所講的幾個例題,其實都只需要在 DFS 遍歷框架上稍加修改而已。
1 int area(int[][] grid, int r, int c) { 2 return 1 3 + area(grid, r - 1, c) 4 + area(grid, r + 1, c) 5 + area(grid, r, c - 1) 6 + area(grid, r, c + 1); 7 }
最終我們得到的完整題解代碼如下:
1 public int maxAreaOfIsland(int[][] grid) { 2 int res = 0; 3 for (int r = 0; r < grid.length; r++) { 4 for (int c = 0; c < grid[0].length; c++) { 5 if (grid[r][c] == 1) { 6 int a = area(grid, r, c); 7 res = Math.max(res, a); 8 } 9 } 10 } 11 return res; 12 } 13 14 int area(int[][] grid, int r, int c) { 15 if (!inArea(grid, r, c)) { 16 return 0; 17 } 18 if (grid[r][c] != 1) { 19 return 0; 20 } 21 grid[r][c] = 2; 22 23 return 1 24 + area(grid, r - 1, c) 25 + area(grid, r + 1, c) 26 + area(grid, r, c - 1) 27 + area(grid, r, c + 1); 28 } 29 30 boolean inArea(int[][] grid, int r, int c) { 31 return 0 <= r && r < grid.length 32 && 0 <= c && c < grid[0].length; 33 }
例題 2:島嶼的周長
LeetCode 463. Island Perimeter (Easy)
給定一個包含 0 和 1 的二維網格地圖,其中 1 表示陸地,0 表示海洋。網格中的格子水平和垂直方向相連(對角線方向不相連)。整個網格被水完全包圍,但其中恰好有一個島嶼(一個或多個表示陸地的格子相連組成島嶼)。
島嶼中沒有“湖”(“湖” 指水域在島嶼內部且不和島嶼周圍的水相連)。格子是邊長為 1 的正方形。計算這個島嶼的周長。
實話說,這道題用 DFS 來解並不是最優的方法。對於島嶼,直接用數學的方法求周長會更容易。不過這道題是一個很好的理解 DFS 遍歷過程的例題,
我們再回顧一下 網格 DFS 遍歷的基本框架:
1 void dfs(int[][] grid, int r, int c) { 2 // 判斷 base case 3 if (!inArea(grid, r, c)) { 4 return; 5 } 6 // 如果這個格子不是島嶼,直接返回 7 if (grid[r][c] != 1) { 8 return; 9 } 10 grid[r][c] = 2; // 將格子標記為「已遍歷過」 11 12 // 訪問上、下、左、右四個相鄰結點 13 dfs(grid, r - 1, c); 14 dfs(grid, r + 1, c); 15 dfs(grid, r, c - 1); 16 dfs(grid, r, c + 1); 17 } 18 19 // 判斷坐標 (r, c) 是否在網格中 20 boolean inArea(int[][] grid, int r, int c) { 21 return 0 <= r && r < grid.length 22 && 0 <= c && c < grid[0].length; 23 }可以看到,
dfs
函數直接返回有這幾種情況:
!inArea(grid, r, c)
,即坐標(r, c)
超出了網格的范圍,也就是我所說的「先污染后治理」的情況grid[r][c] != 1
,即當前格子不是島嶼格子,這又分為兩種情況:
grid[r][c] == 0
,當前格子是海洋格子grid[r][c] == 2
,當前格子是已遍歷的陸地格子那么這些和我們島嶼的周長有什么關系呢?實際上,島嶼的周長是計算島嶼全部的「邊緣」,而這些邊緣就是我們在 DFS 遍歷中,
dfs
函數返回的位置。觀察題目示例,我們可以將島嶼的周長中的邊分為兩類,如下圖所示。黃色的邊是與網格邊界相鄰的周長,而藍色的邊是與海洋格子相鄰的周長。
當我們的
dfs
函數因為「坐標(r, c)
超出網格范圍」返回的時候,實際上就經過了一條黃色的邊;而當函數因為「當前格子是海洋格子」返回的時候,實際上就經過了一條藍色的邊。這樣,我們就把島嶼的周長跟 DFS 遍歷聯系起來了,我們的題解代碼也呼之欲出:
1 public int islandPerimeter(int[][] grid) { 2 for (int r = 0; r < grid.length; r++) { 3 for (int c = 0; c < grid[0].length; c++) { 4 if (grid[r][c] == 1) { 5 // 題目限制只有一個島嶼,計算一個即可 6 return dfs(grid, r, c); 7 } 8 } 9 } 10 return 0; 11 } 12 13 int dfs(int[][] grid, int r, int c) { 14 // 函數因為「坐標 (r, c) 超出網格范圍」返回,對應一條黃色的邊 15 if (!inArea(grid, r, c)) { 16 return 1; 17 } 18 // 函數因為「當前格子是海洋格子」返回,對應一條藍色的邊 19 if (grid[r][c] == 0) { 20 return 1; 21 } 22 // 函數因為「當前格子是已遍歷的陸地格子」返回,和周長沒關系 23 if (grid[r][c] != 1) { 24 return 0; 25 } 26 grid[r][c] = 2; 27 return dfs(grid, r - 1, c) 28 + dfs(grid, r + 1, c) 29 + dfs(grid, r, c - 1) 30 + dfs(grid, r, c + 1); 31 } 32 33 // 判斷坐標 (r, c) 是否在網格中 34 boolean inArea(int[][] grid, int r, int c) { 35 return 0 <= r && r < grid.length 36 && 0 <= c && c < grid[0].length; 37 }文章出處:https://mp.weixin.qq.com/s?__biz=MzA5ODk3ODA4OQ==&mid=2648167208&idx=1&sn=d8118c7c0e0f57ea2bdd8aa4d6ac7ab7&chksm=88aa236ebfddaa78a6183cf6dcf88f82c5ff5efb7f5c55d6844d9104b307862869eb9032bd1f&token=1064083695&lang=zh_CN#rd