網格類問題的DFS遍歷方法


網格問題的基本概念

我們首先明確一下島嶼問題中的網格結構是如何定義的,以方便我們后面的討論。

網格問題是由 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM