leecode島嶼問題總結DFS

關於島嶼問題的總結

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 的兩個要素:

  1. 首先,網格結構中的格子有多少相鄰結點?答案是上下左右四個。對於格子 (r, c) 來說(r 和 c 分別代表行座標和列座標),四個相鄰的格子分別是 (r-1, c)、(r+1, c)、(r, c-1)、(r, c+1)。換句話說,網格結構是「四叉」的。

  2. 其次,網格 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();
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章