概論
深度優先搜索屬於圖算法的一種,是一個針對圖和樹的遍歷算法,英文縮寫為 DFS 即 Depth First Search。深度優先搜索是圖論中的經典算法,利用深度優先搜索算法可以產生目標圖的相應拓撲排序表,利用拓撲排序表可以方便的解決很多相關的圖論問題,如最大路徑問題等等。一般用堆數據結構來輔助實現 DFS 算法。其過程簡要來說是對每一個可能的分支路徑深入到不能再深入為止,而且每個節點只能訪問一次。
基本步奏
(1)對於下面的樹而言,DFS 方法首先從根節點1開始,其搜索節點順序是 1,2,3,4,5,6,7,8(假定左分枝和右分枝中優先選擇左分枝)。

(2)從 stack 中訪問棧頂的點;

(3)找出與此點鄰接的且尚未遍歷的點,進行標記,然后放入 stack 中,依次進行;

(4)如果此點沒有尚未遍歷的鄰接點,則將此點從 stack 中彈出,再按照(3)依次進行;

(5) 由於與節點 5 相連的的節點都被訪問過了,於是5被彈出,查找與 4 相鄰但沒有被訪問過的節點:

(6)直到遍歷完整個樹,stack 里的元素都將彈出,最后棧為空,DFS 遍歷完成。


// 用於記錄某個節點是否訪問過
private Map<String, Boolean> status = new HashMap<String, Boolean>();
// 用於保存訪問過程中的節點 private Stack<String> stack = new Stack<String>();
// 入口,這里選擇 1 為入口 public void DFSSearch(String startPoint) { stack.push(startPoint); status.put(startPoint, true); dfsLoop(); } private void dfsLoop() {
// 到達終點,結束循環 if(stack.empty()){ return; } // 查看棧頂元素,但並不出棧 String stackTopPoint = stack.peek(); // 找出與此點鄰接的且尚未遍歷的點,進行標記,然后全部放入list中。 List<String> neighborPoints = graph.get(stackTopPoint); for (String point : neighborPoints) { if (!status.getOrDefault(point, false)) { //未被遍歷 stack.push(point);
// 加上已訪問標記 status.put(point, true); dfsLoop(); } }
// 如果鄰接點都被訪問了,那么就彈出,相當於是恢復操作,也就是在遞歸后面做的。 String popPoint = stack.pop(); System.out.println(popPoint); }
通過上面的示例,基本了解 dfs 使用。
通用框架
其一般框架原理如下:
void dfs() { if(到達終點狀態) { ... //根據題意添加 return; } if(越界或不合法狀態) return; if(特殊狀態) // 剪枝 return; for(擴展方式) { if(擴張方式所到達狀態合法) { 修改操作; // 根據題意添加 標記; dfs(); (還原標記); //是否加上還原標記根據題意 //如果加上還原標記就是回溯法 } } }
通過這個 dfs 框架可以看出該方法主要有以下幾個規律:
-
訪問路徑的確定。根據不同的題目思考怎么才算是一條訪問路徑,如何去實現遍歷。
-
起點條件。從哪個點開始訪問?是否每個點都需要當作起點?第一次 dfs 調用至關重要。
-
遞歸參數。也就是 dfs 遞歸怎么在上一個節點的基礎上繼續遞歸,實現遞歸依賴什么參數?需要知道一條路徑上各個節點之間的關系,當前訪問節點。
-
終結條件。訪問的終結條件是什么?比如到達邊界點,所有點已經都訪問過了。終結條件需要在下一次遞歸前進行判斷。
-
訪問標志。當一條路走不通的時候,會返回上一個節點,嘗試另一個節點。為了避免重復訪問,需要對已經訪問過的節點加上標記,避免重復訪問。
- 剪枝。屬於算法優化。比如已經知道沿着當前路徑再走下去也不會滿足條件的時候,提前終止遞歸。
下面將結合幾道算法題來加深對深度優先搜索算法的理解。
1、全排列
問題:給定大於0的數字n,輸出數字 1 ~ n 之間的全排列。
對於這道題目,有些人可能會好奇為啥這到題目可以使用 dfs 算法。對於全排列,其實可以通過樹的形式來進行理解:
可以發現就是一個 n 叉樹,總共是 n 層,下面采用前面總結的規律來看看算法實現原理:
-
訪問路徑:從起始位置到葉節點就是一個排列,也就是一條路徑
-
起點條件:start 下面有 n 個節點,每個點都可以被當作起始點,說明需要采用 for 循環方式,。
-
遞歸參數:當前訪問的節點位置,定位下一個遞歸節點。需要一個變量記錄數字的排列,需要輸出。節點總數 n,便於知道何時遞歸結束。
-
終結條件:遞歸訪問到節點數到達 n 層的時候停止遞歸。
-
訪問標志:不需要,可重復訪問;
-
剪枝:不需要,沒有其他需要提前終止遞歸的條件。
下面就是算法實現:
// 調用入口,起始點
dfs(total, 0, "");
// 遞歸參數:tatal 表示數字n, index 當前訪問節點,s 記錄排列方式 public void dfs(int total, int index, String s) {
// 終結條件 if (index == total) { System.out.println(s); return; }
// 對於每個節點,當前有 total 種選擇 for (int i= 1;i<=total;i++) { dfs(total, index+1, s+i); } }
可以發現,代碼還是很簡單的。
695. 島嶼的最大面積
給定一個包含了一些 0 和 1 的非空二維數組 grid 。
一個 島嶼 是由一些相鄰的 1 (代表土地) 構成的組合,這里的「相鄰」要求兩個 1 必須在水平或者豎直方向上相鄰。你可以假設 grid 的四個邊緣都被 0(代表水)包圍着。
找到給定的二維數組中最大的島嶼面積。(如果沒有島嶼,則返回面積為 0 。)
示例 1:
[[0,0,1,0,0,0,0,1,0,0,0,0,0], [0,0,0,0,0,0,0,1,1,1,0,0,0], [0,1,1,0,1,0,0,0,0,0,0,0,0], [0,1,0,0,1,1,0,0,1,0,1,0,0], [0,1,0,0,1,1,0,0,1,1,1,0,0], [0,0,0,0,0,0,0,0,0,0,1,0,0], [0,0,0,0,0,0,0,1,1,1,0,0,0], [0,0,0,0,0,0,0,1,1,0,0,0,0]]
對於上面這個給定矩陣應返回 6。注意答案不應該是 11 ,因為島嶼只能包含水平或垂直的四個方向的 1 。
示例 2:
[[0,0,0,0,0,0,0,0]]
對於上面這個給定的矩陣, 返回 0。
注意: 給定的矩陣grid 的長度和寬度都不超過 50。
對於這道題目還是采用之前的分析方式:
-
訪問路徑:節點中相鄰的1構成一條路徑。0 直接無視。
-
起點條件:二維數組的每個點都可以當作起點。所以兩個 for 循環來進行調用。
-
遞歸參數:當前訪問的節點位置(x,y),二維數組表,從表中查找下一個節點
-
終結條件:到達二維數組的邊界,節點為0
-
訪問標志:需要,不可重復訪問;可以將訪問過的節點置為0,避免再次訪問,重復計算。
-
剪枝:只有在節點等於1的時候,才調用dfs。這樣可以減少調用次數。
題目解答如下:
class Solution { public int maxAreaOfIsland(int[][] grid) { if (grid == null || grid.length <1 || grid[0].length<1) { return 0; } int rx = grid.length; int cy = grid[0].length; int max = 0; for (int x =0; x< rx; x++) { for (int y= 0;y<cy; y++) { if (grid[x][y]==1) { //只有節點等於1才調用,這里就可以算作是剪枝,算法的優化 int num = dfs(grid,x,y); max = Math.max(max, num); } } } return max; } // 遞歸參數:節點位置x,y, 二維數組 private int dfs (int[][] grid, int x, int y){ int rx = grid.length; int cy = grid[0].length;
// 邊界條件,節點為0 if (x >= rx || x < 0 || y>=cy || y<0 || grid[x][y]==0 ) { return 0; }
// 直接修改原數組來標記已訪問 grid[x][y]=0;
// 每次遞歸就表示面積多了一塊 int num = 1;
// 每個節點有四種不同的選擇方向 num += dfs(grid, x-1, y); num += dfs(grid, x, y-1); num += dfs(grid, x+1, y); num += dfs(grid, x, y+1); return num; } }
200. 島嶼數量
給你一個由 '1'(陸地)和 '0'(水)組成的的二維網格,請你計算網格中島嶼的數量。
島嶼總是被水包圍,並且每座島嶼只能由水平方向或豎直方向上相鄰的陸地連接形成。
此外,你可以假設該網格的四條邊均被水包圍。
示例 1:
// 輸入: 11110 11010 11000 00000 // 輸出: 1
示例 2:
// 輸入: 11000 11000 00100 00011 // 輸出: 3
解釋: 每座島嶼只能由水平和/或豎直方向上相鄰的陸地連接而成。
可以發現,這道題目與前面的題目很類似,關於 dfs 規則這里就不在分析了,留給大家自己去分析。
題目解答如下:
class Solution { public int numIslands(char[][] grid) { if (grid == null || grid.length < 1 || grid[0].length<1) { return 0; } int num = 0; int rx = grid.length; int cy = grid[0].length;
// 起始點 for (int x =0;x<rx;x++) { for (int y =0;y<cy;y++) {
// 題目要求,'0'不符合路徑條件 if (grid[x][y]=='1') { dfs(grid,x,y); num++; } } } return num; } // 遞歸條件 private void dfs(char[][] grid, int x, int y) { int rx = grid.length; int cy = grid[0].length;
// 終結條件 if (x<0 || x>=rx || y<0 || y>= cy || grid[x][y] == '0') { return; }
// 訪問方向實質是由訪問路徑來決定的,就是你得想清楚怎么才算一條路徑 grid[x][y]='0'; dfs(grid,x-1,y); dfs(grid,x,y-1); dfs(grid,x+1,y); dfs(grid,x,y+1); return ; } }
到這里,深度優先搜索的理論和實踐就講完了,相信看到這里的小伙伴應該也掌握了其算法的原理,以及如何去書寫。
算法系列文章