島嶼問題是經典的面試高頻題,雖然基本的島嶼問題并不難,但是島嶼問題有一些有意思的擴展,比如求子島嶼數量,求形狀不同的島嶼數量等等,本文就來把這些問題一網打盡。
島嶼系列問題的核心考點就是用 DFS/BFS 算法遍歷二維數組 。
本文主要來講解如何用 DFS 算法來秒殺島嶼系列問題,不過用 BFS 算法的核心思路是完全一樣的,無非就是把 DFS 改寫成 BFS 而已。
那么如何在二維矩陣中使用 DFS 搜索呢?如果你把二維矩陣中的每一個位置看做一個節點,這個節點的上下左右四個位置就是相鄰節點,那么整個矩陣就可以抽象成一幅網狀的「圖」結構。
根據 [學習數據結構和算法的框架思維],完全可以根據二叉樹的遍歷框架改寫出二維矩陣的 DFS 代碼框架:
// 二叉樹遍歷框架
void traverse(TreeNode root) {
traverse(root.left);
traverse(root.right);
}
// 二維矩陣遍歷框架
void dfs(int[][] grid, int i, int j, boolean[] visited) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引邊界
return;
}
if (visited[i][j]) {
// 已遍歷過 (i, j)
return;
}
// 前序:進入節點 (i, j)
visited[i][j] = true;
dfs(grid, i - 1, j); // 上
dfs(grid, i + 1, j); // 下
dfs(grid, i, j - 1); // 左
dfs(grid, i, j + 1); // 右
// 后序:離開節點 (i, j)
// visited[i][j] = true;
}
因為二維矩陣本質上是一幅「圖」,所以遍歷的過程中需要一個visited
布爾數組防止走回頭路,如果你能理解上面這段代碼,那么搞定所有島嶼問題都很簡單。
這里額外說一個處理二維數組的常用小技巧,你有時會看到使用「方向數組」來處理上下左右的遍歷,和前文 [圖遍歷框架]的代碼很類似:
// 方向數組,分別代表上、下、左、右
int[][] dirs = new int[][]{{-1,0}, {1,0}, {0,-1}, {0,1}};
void dfs(int[][] grid, int i, int j, boolean[] visited) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引邊界
return;
}
if (visited[i][j]) {
// 已遍歷過 (i, j)
return;
}
// 進入節點 (i, j)
visited[i][j] = true;
// 遞歸遍歷上下左右的節點
for (int[] d : dirs) {
int next_i = i + d[0];
int next_j = j + d[1];
dfs(grid, next_i, next_j);
}
// 離開節點 (i, j)
// visited[i][j] = true;
}
這種寫法無非就是用 for 循環處理上下左右的遍歷罷了,你可以按照個人喜好選擇寫法。
島嶼數量
這是力扣第 200 題「島嶼數量」,最簡單也是最經典的一道島嶼問題,題目會輸入一個二維數組grid
,其中只包含0
或者1
,0
代表海水,1
代表陸地,且假設該矩陣四周都是被海水包圍著的。
我們說連成片的陸地形成島嶼,那么請你寫一個算法,計算這個矩陣grid
中島嶼的個數,函數簽名如下:
int numIslands(char[][] grid);
比如說題目給你輸入下面這個grid
有四片島嶼,算法應該返回 4:
思路很簡單,關鍵在于如何尋找并標記「島嶼」,這就要 DFS 算法發揮作用了,我們直接看解法代碼:
// 主函數,計算島嶼數量
int numIslands(char[][] grid) {
int res = 0;
int m = grid.length, n = grid[0].length;
// 遍歷 grid
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
// 每發現一個島嶼,島嶼數量加一
res++;
// 然后使用 DFS 將島嶼淹了
dfs(grid, i, j);
}
}
}
return res;
}
// 從 (i, j) 開始,將與之相鄰的陸地都變成海水
void dfs(char[][] grid, int i, int j) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引邊界
return;
}
if (grid[i][j] == '0') {
// 已經是海水了
return;
}
// 將 (i, j) 變成海水
grid[i][j] = '0';
// 淹沒上下左右的陸地
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
為什么每次遇到島嶼,都要用 DFS 算法把島嶼「淹了」呢?主要是為了省事,避免維護visited
數組 。
因為dfs
函數遍歷到值為0
的位置會直接返回,所以只要把經過的位置都設置為0
,就可以起到不走回頭路的作用。
PS:這類 DFS 算法還有個別名叫做 [FloodFill 算法],現在有沒有覺得 FloodFill 這個名字還挺貼切的~
這個最最基本的島嶼問題就說到這,我們來看看后面的題目有什么花樣。
封閉島嶼的數量
上一題說二維矩陣四周可以認為也是被海水包圍的,所以靠邊的陸地也算作島嶼。
力扣第 1254 題「統計封閉島嶼的數目」和上一題有兩點不同:
1、用0
表示陸地,用1
表示海水。
2、讓你計算「封閉島嶼」的數目。所謂「封閉島嶼」就是上下左右全部被1
包圍的0
,也就是說 靠邊的陸地不算作「封閉島嶼」 。
函數簽名如下:
int closedIsland(int[][] grid)
比如題目給你輸入如下這個二維矩陣:
算法返回 2,只有圖中灰色部分的0
是四周全都被海水包圍著的「封閉島嶼」。
那么如何判斷「封閉島嶼」呢?其實很簡單,把上一題中那些靠邊的島嶼排除掉,剩下的不就是「封閉島嶼」了嗎 ?
有了這個思路,就可以直接看代碼了,注意這題規定0
表示陸地,用1
表示海水:
// 主函數:計算封閉島嶼的數量
int closedIsland(int[][] grid) {
int m = grid.length, n = grid[0].length;
for (int j = 0; j < n; j++) {
// 把靠上邊的島嶼淹掉
dfs(grid, 0, j);
// 把靠下邊的島嶼淹掉
dfs(grid, m - 1, j);
}
for (int i = 0; i < m; i++) {
// 把靠左邊的島嶼淹掉
dfs(grid, i, 0);
// 把靠右邊的島嶼淹掉
dfs(grid, i, n - 1);
}
// 遍歷 grid,剩下的島嶼都是封閉島嶼
int res = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 0) {
res++;
dfs(grid, i, j);
}
}
}
return res;
}
// 從 (i, j) 開始,將與之相鄰的陸地都變成海水
void dfs(int[][] grid, int i, int j) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
return;
}
if (grid[i][j] == 1) {
// 已經是海水了
return;
}
// 將 (i, j) 變成海水
grid[i][j] = 1;
// 淹沒上下左右的陸地
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
只要提前把靠邊的陸地都淹掉,然后算出來的就是封閉島嶼了。
PS:處理這類島嶼問題除了 DFS/BFS 算法之外,Union Find 并查集算法也是一種可選的方法,前文 [Union Find 算法運用] 就用 Union Find 算法解決了一道類似的問題。
這道島嶼題目的解法稍微改改就可以解決力扣第 1020 題「飛地的數量」,這題不讓你求封閉島嶼的數量,而是求封閉島嶼的面積總和。
其實思路都是一樣的,先把靠邊的陸地淹掉,然后去數剩下的陸地數量就行了,注意第 1020 題中1
代表陸地,0
代表海水:
int numEnclaves(int[][] grid) {
int m = grid.length, n = grid[0].length;
// 淹掉靠邊的陸地
for (int i = 0; i < m; i++) {
dfs(grid, i, 0);
dfs(grid, i, n - 1);
}
for (int j = 0; j < n; j++) {
dfs(grid, 0, j);
dfs(grid, m - 1, j);
}
// 數一數剩下的陸地
int res = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 1) {
res += 1;
}
}
}
return res;
}
// 和之前的實現類似
void dfs(int[][] grid, int i, int j) {
// ...
}
篇幅所限,具體代碼我就不寫了,我們繼續看其他的島嶼問題。
島嶼的最大面積
這是力扣第 695 題「島嶼的最大面積」,0
表示海水,1
表示陸地,現在不讓你計算島嶼的個數了,而是讓你計算最大的那個島嶼的面積,函數簽名如下:
int maxAreaOfIsland(int[][] grid)
比如題目給你輸入如下一個二維矩陣:
其中面積最大的是橘紅色的島嶼,算法返回它的面積 6。
這題的大體思路和之前完全一樣,只不過dfs
函數淹沒島嶼的同時,還應該想辦法記錄這個島嶼的面積 。
我們可以給dfs
函數設置返回值,記錄每次淹沒的陸地的個數,直接看解法吧:
int maxAreaOfIsland(int[][] grid) {
// 記錄島嶼的最大面積
int res = 0;
int m = grid.length, n = grid[0].length;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 1) {
// 淹沒島嶼,并更新最大島嶼面積
res = Math.max(res, dfs(grid, i, j));
}
}
}
return res;
}
// 淹沒與 (i, j) 相鄰的陸地,并返回淹沒的陸地面積
int dfs(int[][] grid, int i, int j) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引邊界
return 0;
}
if (grid[i][j] == 0) {
// 已經是海水了
return 0;
}
// 將 (i, j) 變成海水
grid[i][j] = 0;
return dfs(grid, i + 1, j)
+ dfs(grid, i, j + 1)
+ dfs(grid, i - 1, j)
+ dfs(grid, i, j - 1) + 1;
}
解法和之前相比差不多,我也不多說了,接下來的兩道島嶼問題是比較有技巧性的,我們重點來看一下。
子島嶼數量
如果說前面的題目都是模板題,那么力扣第 1905 題「統計子島嶼」可能得動動腦子了:
這道題的關鍵在于,如何快速判斷子島嶼 ?肯定可以借助 [Union Find 并查集算法] 來判斷,不過本文重點在 DFS 算法,就不展開并查集算法了。
什么情況下grid2
中的一個島嶼B
是grid1
中的一個島嶼A
的子島?
當島嶼B
中所有陸地在島嶼A
中也是陸地的時候,島嶼B
是島嶼A
的子島。
反過來說,如果島嶼B
中存在一片陸地,在島嶼A
的對應位置是海水,那么島嶼B
就不是島嶼A
的子島 。
那么,我們只要遍歷grid2
中的所有島嶼,把那些不可能是子島的島嶼排除掉,剩下的就是子島。
依據這個思路,可以直接寫出下面的代碼:
int countSubIslands(int[][] grid1, int[][] grid2) {
int m = grid1.length, n = grid1[0].length;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid1[i][j] == 0 && grid2[i][j] == 1) {
// 這個島嶼肯定不是子島,淹掉
dfs(grid2, i, j);
}
}
}
// 現在 grid2 中剩下的島嶼都是子島,計算島嶼數量
int res = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid2[i][j] == 1) {
res++;
dfs(grid2, i, j);
}
}
}
return res;
}
// 從 (i, j) 開始,將與之相鄰的陸地都變成海水
void dfs(int[][] grid, int i, int j) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
return;
}
if (grid[i][j] == 0) {
return;
}
grid[i][j] = 0;
dfs(grid, i + 1, j);
dfs(grid, i, j + 1);
dfs(grid, i - 1, j);
dfs(grid, i, j - 1);
}
這道題的思路和計算「封閉島嶼」數量的思路有些類似,只不過后者排除那些靠邊的島嶼,前者排除那些不可能是子島的島嶼。
不同的島嶼數量
這是本文的最后一道島嶼題目,作為壓軸題,當然是最有意思的。
力扣第 694 題「不同的島嶼數量」,題目還是輸入一個二維矩陣,0
表示海水,1
表示陸地,這次讓你計算 不同的 (distinct) 島嶼數量,函數簽名如下:
int numDistinctIslands(int[][] grid)
比如題目輸入下面這個二維矩陣:
其中有四個島嶼,但是左下角和右上角的島嶼形狀相同,所以不同的島嶼共有三個,算法返回 3。
很顯然我們得想辦法把二維矩陣中的「島嶼」進行轉化,變成比如字符串這樣的類型,然后利用 HashSet 這樣的數據結構去重,最終得到不同的島嶼的個數。
如果想把島嶼轉化成字符串,說白了就是序列化,序列化說白了遍歷嘛,前文 [二叉樹的序列化和反序列化]講了二叉樹和字符串互轉,這里也是類似的。
首先,對于形狀相同的島嶼,如果從同一起點出發,dfs
函數遍歷的順序肯定是一樣的 。
因為遍歷順序是寫死在你的遞歸函數里面的,不會動態改變:
void dfs(int[][] grid, int i, int j) {
// 遞歸順序:
dfs(grid, i - 1, j); // 上
dfs(grid, i + 1, j); // 下
dfs(grid, i, j - 1); // 左
dfs(grid, i, j + 1); // 右
}
所以,遍歷順序從某種意義上說就可以用來描述島嶼的形狀,比如下圖這兩個島嶼:
假設它們的遍歷順序是:
下,右,上,撤銷上,撤銷右,撤銷下
如果我用分別用1, 2, 3, 4
代表上下左右,用-1, -2, -3, -4
代表上下左右的撤銷,那么可以這樣表示它們的遍歷順序:
2, 4, 1, -1, -4, -2
你看,這就相當于是島嶼序列化的結果,只要每次使用dfs
遍歷島嶼的時候生成這串數字進行比較,就可以計算到底有多少個不同的島嶼了 。
要想生成這段數字,需要稍微改造dfs
函數,添加一些函數參數以便記錄遍歷順序:
void dfs(int[][] grid, int i, int j, StringBuilder sb, int dir) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n
|| grid[i][j] == 0) {
return;
}
// 前序遍歷位置:進入 (i, j)
grid[i][j] = 0;
sb.append(dir).append(',');
dfs(grid, i - 1, j, sb, 1); // 上
dfs(grid, i + 1, j, sb, 2); // 下
dfs(grid, i, j - 1, sb, 3); // 左
dfs(grid, i, j + 1, sb, 4); // 右
// 后序遍歷位置:離開 (i, j)
sb.append(-dir).append(',');
}
dir
記錄方向,dfs
函數遞歸結束后,sb
記錄著整個遍歷順序,其實這就是前文 [回溯算法核心套路]說到的回溯算法框架,你看到頭來這些算法都是相通的。
有了這個dfs
函數就好辦了,我們可以直接寫出最后的解法代碼:
int numDistinctIslands(int[][] grid) {
int m = grid.length, n = grid[0].length;
// 記錄所有島嶼的序列化結果
HashSet
這樣,這道題就解決了,至于為什么初始調用dfs
函數時的dir
參數可以隨意寫,這里涉及 DFS 和回溯算法的一個細微差別,前文 [圖算法基礎]有寫,這里就不展開了。
以上就是全部島嶼系列問題的解題思路,也許前面的題目大部分人會做,但是最后兩題還是比較巧妙的,希望本文對你有幫助。
-
數據結構
+關注
關注
3文章
573瀏覽量
40132 -
DFS
+關注
關注
0文章
26瀏覽量
9165 -
BFS
+關注
關注
0文章
9瀏覽量
2170
發布評論請先 登錄
相關推薦
評論