DFS 的基本結構
網格結構要比二叉樹結構稍微複雜一些,它其實是一種簡化版的圖結構。要寫好網格上的 DFS 遍歷,我們首先要理解二叉樹上的 DFS 遍歷方法,再類比寫出網格結構上的 DFS 遍歷。我們寫的二叉樹 DFS 遍歷一般是這樣的:
void traverse(TreeNode root)
{
// 判斷base case
if(root == null)
{
return;
}
// 訪問兩個相鄰結點:左子節點,右子節點
traverse(root.left);
traverse(root.right);
}
可以看到,二叉樹的 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 遍歷的框架代碼:
void dfs(vector<vector<int>>& grid, int x, int y)
{
// 判斷base case
// 如果座標(r, c)超出了網格範圍,直接返回
if(!inArea(grid, r, c))
{
return;
}
// 訪問上、下、左、右四個相鄰結點
dfs(grid, r-1, c);
dfs(grid, r+1, c);
dfs(grid, r, c-1);
dfs(grid, r, c+1);
}
// 判斷座標(r,c)是否在網格中
bool inArea(vector<vector<int>>& grid, int x, int y)
{
return 0<=r && r<grid.size() && 0 <=c && c<grid[0].size();
}
如何避免重複遍歷
網格結構的 DFS 與二叉樹的 DFS 最大的不同之處在於,遍歷中可能遇到遍歷過的結點。這是因爲,網格結構本質上是一個「圖」,我們可以把每個格子看成圖中的結點,每個結點有向上下左右的四條邊。在圖中遍歷時,自然可能遇到重複遍歷結點。
這時候,DFS 可能會不停地「兜圈子」,永遠停不下來
如何避免這樣的重複遍歷呢?答案是標記已經遍歷過的格子。以島嶼問題爲例,我們需要在所有值爲 1 的陸地格子上做 DFS 遍歷。每走過一個陸地格子,就把格子的值改爲 2,這樣當我們遇到 2 的時候,就知道這是遍歷過的格子了。也就是說,每個格子可能取三個值:
0 —— 海洋格子
1 —— 陸地格子(未遍歷過)
2 —— 陸地格子(已遍歷過)
我們在框架代碼中加入避免重複遍歷的語句:
void dfs(vector<vector<int>>& grid, int x, int y)
{
// 判斷base case
// 如果座標(r, c)超出了網格範圍,直接返回
if(!inArea(grid, r, c))
{
return;
}
// 如果這個格子不是島嶼直接返回
if(grid[r][c]!=1)
{
return;
}
grid[r][c] = 2; // 將格子標記爲「已遍歷過」
// 訪問上、下、左、右四個相鄰結點
dfs(grid, r-1, c);
dfs(grid, r+1, c);
dfs(grid, r, c-1);
dfs(grid, r, c+1);
}
// 判斷座標(r,c)是否在網格中
bool inArea(vector<vector<int>>& grid, int x, int y)
{
return 0<=r && r<grid.size() && 0 <=c && c<grid[0].size();
}
這樣,我們就得到了一個島嶼問題、乃至各種網格問題的通用 DFS 遍歷方法。以下所講的幾個例題,其實都只需要在 DFS 遍歷框架上稍加修改而已
例題 1:島嶼的最大面積
LeetCode 695. Max Area of Island (Medium)
這道題目只需要對每個島嶼做 DFS 遍歷,求出每個島嶼的面積就可以了。求島嶼面積的方法也很簡單,代碼如下,每遍歷到一個格子,就把面積加一。
int area(vector<vector<int>>& grid, int r, int c)
{
return 1+area(grid, r-1, c)+area(grid, r, c+1)+area(grid, grid, r,c-1)+area(grid, r+1, c);
}
最終得到的完整解題代碼如下:
int maxAreaOfIsland(vector<vector<int>>& grid)
{
int res = 0;
for(int r = 0; r<grid.size(); r++)
{
for(int c = 0; c<grid[0].size(); c++)
{
if(grid[r][c]==1)
{
int a = area(grid, r, c);
res = Math.max(res, a)
}
}
}
return res;
}
int area(vector<vector<int>>& grid, int r, int c)
{
if(!inArea(grid, r, c))
{
return 0;
}
if(grid[r][c]!=1) // 不是未遍歷過的 陸地格子 直接返回
{
return 0;
}
}
bool inArea(vector<vector<int>>& grid, int r, int c)
{
return 0<=r && r<grid.size() && 0<=c && c < grid[0].size();
}
例題 2:填海造陸問題
LeetCode 827. Making A Large Island (Hard)
基本的思路文章說:
這道題是島嶼最大面積問題的升級版。現在我們有填海造陸的能力,可以把一個海洋格子變成陸地格子,進而讓兩塊島嶼連成一塊。那麼填海造陸之後,最大可能構造出多大的島嶼呢?
大致的思路我們不難想到,我們先計算出所有島嶼的面積,在所有的格子上標記出島嶼的面積。然後搜索哪個海洋格子相鄰的兩個島嶼面積最大。例如下圖中紅色方框內的海洋格子,上邊、左邊都與島嶼相鄰,我們可以計算出它變成陸地之後可以連接成的島嶼面積爲7+1+2=10
然而,這種做法可能遇到一個問題。如下圖中紅色方框內的海洋格子,它的上邊、左邊都與島嶼相鄰,這時候連接成的島嶼面積難道是7+7+1?顯然不是。這兩個7來自同一個島嶼,所以填海造陸之後得到的島嶼面積應該只有7+1=8
可以看到,要讓算法正確,我們得能區分一個海洋格子相鄰的兩個 7 是不是來自同一個島嶼。那麼,我們不能在方格中標記島嶼的面積,而應該標記島嶼的索引(下標),另外用一個數組記錄每個島嶼的面積,如下圖所示。這樣我們就可以發現紅色方框內的海洋格子,它的「兩個」相鄰的島嶼實際上是同一個。
可以看到,這道題實際上是對網格做了兩遍 DFS:第一遍 DFS 遍歷陸地格子,計算每個島嶼的面積並標記島嶼;第二遍 DFS 遍歷海洋格子,觀察每個海洋格子相鄰的陸地格子。
例題 3:島嶼的周長
LeetCode 463. Island Perimeter (Easy)
我們先回顧一下網格DFS遍歷的基本框架:
void dfs(vector<vector<int>>& grid, int r , int c)
{
// 判斷 base cade, 如何理解先污染後治理
// 先污染就是對[r, c]的領域進行DFS
// 後治理就是在DFS中再去做判斷是否搜索超過領域
if(!inArea(grid, r, c))
{
return;
}
// 如果這個格子不是島嶼,直接返回
if(grid[r][c] != 1)
{
return;
}
grid[r][c]=2; // 將格子標記爲「已遍歷過」
// 訪問上、下、左、右四個相鄰結點
dfs(grid, r-1, c);
dfs(grid, r+1, c);
dfs(grid, r, c-1);
dfs(grid, r, c+1);
}
// 判斷座標(r,c)是否在網格中
bool inArea(vector<vector<int>>& grid, int r, int c)
{
return 0<=r && r < grid.size() && 0<=c && c < grid[0].size();
}
可以看到,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 遍歷聯繫起來了,我們的題解代碼也呼之欲出:
void girth(vector<vector<int>>& grid, int r, int c)
{
for(int r = 0; r < grid,size() ; r++)
{
for(int c = 0 ; c< grid[0].size(); c++)
{
// 只有一個島嶼從這裏開始DFS
if(gird[r][c]==1)
dfs(grid, r , c);
}
}
}
void dfs(vector<vector<int>>& grid, int r, int c)
{
if(!inArea(grid, r, c))
{
// 越界黃色周長+1
return 1;
}
if(grid[r][c]==0)
{
// 島嶼越過海洋藍色周長+1
return 1;
}
// 函數因爲「當前格子是已遍歷的陸地格子」返回,和周長沒關係
if(grid[r][c]==2)
{
return 0;
}
grid[r][c]=2; // dfs遍歷過後要標記,不然會死循環
return dfs(grid, r, c-1)+dfs(grid, r, c+1)+dfs(grid, r-1, c)
+ dfs(grid, r+1, c);
}
bool inArea(vector<vector<int>>& grid, int r, int c)
{
return 0<=r && r < grid.size() && 0<=c && c < grid.size();
}