DFS(深度優先)與BFS(廣度優先)是兩種非常重要的算法,要注意的是,這是算法,與其數據結構並無關系,任何數據結構都可以使用這種算法!其中樹和圖的數據結構使用該算法比較多。
這兩種算法原理非常好理解,但是他們的應用極其的靈活,而且實現步驟上極其講究,非常容易編寫錯誤,但又找不到問題的出處,希望這兩篇文章可以從原理到實現,從實現到應用完整的講解DFS與BFS
這篇文章為對DFS的整理,文末為Leetcode相關習題講解:
什么是DFS?說白了就是一直遍歷元素的方式而已,我們可以把它看成是一條小蛇,在每個分叉路口隨意選擇一條路線走,直到撞到南牆,才會調頭返回到上一個分叉路口,走另外一條路,有時候運氣很好,撞到了目標點,那么這個算法就結束了。
說實話,我總感覺DFS是一種撞大運的算法,每次想到那條迷路的蛇撞來撞去,就覺得很好笑。
手動模擬一下它的路徑:
遇到南牆要返回
這便是DFS的最基礎的原理。
應該如何來實現它呢?
DFS一般有兩種實現方法:棧和遞歸
其實遞歸便是應用了棧的思想,而一般遞歸的寫法非常簡單,因為在刷題中編寫簡單還是比較重要的,所以我主要講解遞歸的寫法(Java實現)
以下為偽代碼:
1 public 參數1 DFS(參數2)
2 {
3 if(返回條件成立) return 參數 ;
4 DFS(進行下一步的搜索遍歷) ;
5 }
先分析if語句:
這句話的作用就是告訴小蛇:是否撞到南牆啦?撞到就返回啦,或者,是否到達終點啦?到了就結束啦!
所以我們在思考使用DFS進行解決問題的時候需要思考這兩個問題:是否有條件不成立的信息(撞到南牆),是否有條件成立的信息(到達終點)。
還有一個非常重要的信息:是否需要標記訪問節點。
下面來談談為什么要標記訪問節點,以及如何來標記訪問節點。
還是以剛才的路徑為例:
注意當我們的小蛇走到了4號節點時,沒有選擇去到6號節點,而是去到了5號節點,並沿紅色路徑行進,這樣子是不是很有可能產生一個回環:
1->2->4->5->7->1,你會發現我們的小蛇在瘋狂繞圈,肯定是到不了終點6號了。如何才能幫助我們的小蛇呢?
當然是通過標記路徑了!
標記路徑的原理是什么呢?
小蛇每走過一個節點便標記這個節點為已經訪問,小蛇每次需要訪問新節點時不會選擇已經訪問過的節點,這樣就避免了出現回環的慘案。
如下圖所示,紅色的陰影表示已經訪問過的節點,小蛇在7號節點時發現1號節點已經訪問,所以只好返回,並標記7號節點為以訪問。
那么如何來標記一個節點是否訪問過呢?
有超級多的方法來表示,常見的方法有數組法和HashSet法
boolean[] visited = new boolean[length] ; //數組表示,每訪問過一個節點,數組將對應元素置為true Set<類型> set = new HashSet<>() ; //建立set,每訪問一個節點,將該節點加入到set中去
總結一下,在第一部分,我們要思考3個問題
1,是否有條件不成立的信息(撞南牆)
2,是否有條件成立的信息(到終點)
3,是否需要記錄節點(記軌跡)
下面,提一個小問題:如果我要遍歷一個圖中的所有節點,以上的3個問題如何回答?
答:
條件1:不成立的信息就是沒有節點訪問
條件2:沒有條件成立的信息(沒有終點)
條件3:需要記錄軌跡
所以這個問題的解就是讓小蛇沒有新節點訪問,便完成了整個圖的遍歷
——————————————————————————————————————————————————————————————————————
以上便是建立DFS的第一部分
下面來討論DFS結構的第二部分——遞歸調用
先把總結構放上來:
public 參數1 DFS(參數2) { if(返回條件成立) return 參數 ; DFS(進行下一步的搜索遍歷) ; }
遞歸調用的作用是什么?
在我看來,遞歸調用就像是一個方向盤,用來把控下一個節點應該訪問哪里,是左邊還是右邊?是上還是下?在小蛇的例子中遞歸的作用就是告訴小蛇分叉路口應該選擇哪個節點,所以DFS的參數為一些"方向"性質的參數。
同時遞歸還可以起到一個計數器的作用,可以記錄每一條岔路的信息(可能我這么說有一些抽象,但在后面的題目中,我會進一步講解),所以DFS的參數中也經常出現一些"記錄"性質的參數。
————————————————————————————————————————————————————————
下面來看Leetcode上的題目
#1 Number of Islands
Given a 2d grid map of '1'
s (land) and '0'
s (water), count the number of islands. An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. You may assume all four edges of the grid are all surrounded by water.
Example 1:
Input:
11110
11010
11000
00000
Output: 1
Example 2:
Input:
11000
11000
00100
00011
Output: 3
1為陸地 ,0為水,陸地的左右上下都為0時,為一個島,假定給定二維數組之外都是0(都是水),問給定一個二維數組確定有幾個島?
這是一個DFS應用於數組的典型例子,我們應用DFS將1相連的陸地遍歷一遍,看一共需要遍歷幾次,便是幾個島嶼
思考一下if語句的信息:
1.有無終止條件(撞南牆):當然有,當小蛇遇到水(0),超出數組邊界(為水),就算是撞南牆了;
2.有無成立條件(到終點):這個沒有,我們希望小蛇可以將這個島遍歷一遍;
3.是否需要記錄軌跡:需要,防止小蛇重復,讓小蛇不錯過島上的每一個土地(1);
那么我們再來想一想遍歷的信息:
1."方向性"問題:小蛇需要左右上下來進行移動;
2."記錄信息" :不需要,小蛇只管遍歷就ok;
DFS代碼如下:
public void dfs(int i , int j ,char[][] grid) //grid為輸入的二維數組,i,j為小蛇的位置 { if(i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] != '1') //“撞南牆” return ; grid[i][j] = '0' ; //記錄節點軌跡,這里的記錄方法非常巧妙,將訪問之后的陸地變成水,小蛇自然不會再去訪問了 dfs(i+1,j,grid); //遞歸調用,來控制小蛇的方向:左右上下 dfs(i-1,j,grid); dfs(i,j+1,grid); dfs(i,j-1,grid); }
所以在“主函數”中只需找到為“1”的陸地,然后調用DFS讓它變成一片海,記錄下來調用的次數便是有幾個島嶼了
完整代碼如下:
class Solution { public int numIslands(char[][] grid) { int res = 0 ; for(int i = 0 ; i < grid.length ; i ++) { for(int j = 0 ; j < grid[0].length ; j++) { if(grid[i][j] == '1') //找到為1的陸地,調用DFS使之變成大海 { res ++ ; //記錄調用的次數 dfs(i,j,grid) ; } } } return res ; } public void dfs(int i , int j ,char[][] grid) { if(i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] != '1') return ; grid[i][j] = '0' ; dfs(i+1,j,grid); dfs(i-1,j,grid); dfs(i,j+1,grid); dfs(i,j-1,grid); } }
#2 Clone Graph
Given the head of a graph, return a deep copy (clone) of the graph. Each node in the graph contains a label
(int
) and a list (List[UndirectedGraphNode]
) of its neighbors
. There is an edge between the given node and each of the nodes in its neighbors.
OJ's undirected graph serialization (so you can understand error output):
Nodes are labeled uniquely.
We use#
as a separator for each node, and
,
as a separator for node label and each neighbor of the node.
As an example, consider the serialized graph {0,1,2#1,2#2,2}
.
The graph has a total of three nodes, and therefore contains three parts as separated by #
.
- First node is labeled as
0
. Connect node0
to both nodes1
and2
. - Second node is labeled as
1
. Connect node1
to node2
. - Third node is labeled as
2
. Connect node2
to node2
(itself), thus forming a self-cycle.
Visually, the graph looks like the following:

1.有無終止條件(撞南牆):當然有,遇到一個節點沒有新的節點可以訪問了;
2.有無成立條件(到終點):這個沒有,我們希望可以將圖上的所有節點遍歷一遍;
3.是否需要記錄軌跡:需要,我們每次只遍歷新的節點 ;
遞歸:
1.方向是新節點的方向
2.不需要記錄信息
但這些只解決了圖上的所有節點的復制問題,對於每個節點的鄰節點卻無法完整復制,所以我們在DFS的同時還要將節點的鄰接點也復制上,代碼如下:
public void dfs(HashMap<UndirectedGraphNode,UndirectedGraphNode> hm , UndirectedGraphNode node) { if(node == null) return ; for(UndirectedGraphNode aneighbor : node.neighbors) //遍歷給定節點的鄰接點 { if(!hm.containsKey(aneighbor)) //如果為一個新的鄰接點(還沒有復制) { UndirectedGraphNode nd = new UndirectedGraphNode(aneighbor.label); //復制其節點 hm.put(aneighbor,nd); //新節點與原節點進行映射 dfs(hm,aneighbor); //遞歸新的節點,千萬注意dfs的位置,在這里調用是有記錄的,方向為新節點方向 } hm.get(node).neighbors.add(hm.get(aneighbor)); //復制其鄰接點 } }
在“主函數”中主要是建立原頭節點與新頭節點的映射,全部代碼如下:
/**
* Definition for undirected graph.
* class UndirectedGraphNode {
* int label;
* List<UndirectedGraphNode> neighbors;
* UndirectedGraphNode(int x) { label = x; neighbors = new ArrayList<UndirectedGraphNode>(); }
* };
*/
public class Solution { public UndirectedGraphNode cloneGraph(UndirectedGraphNode node) { if(node == null) return null ; HashMap<UndirectedGraphNode,UndirectedGraphNode> hm = new HashMap<UndirectedGraphNode,UndirectedGraphNode>(); UndirectedGraphNode head = new UndirectedGraphNode(node.label); hm.put(node,head); dfs(hm,node); return head ; } public void dfs(HashMap<UndirectedGraphNode,UndirectedGraphNode> hm , UndirectedGraphNode node) { if(node == null) return ; for(UndirectedGraphNode aneighbor : node.neighbors) { if(!hm.containsKey(aneighbor)) { UndirectedGraphNode nd = new UndirectedGraphNode(aneighbor.label); hm.put(aneighbor,nd); dfs(hm,aneighbor); } hm.get(node).neighbors.add(hm.get(aneighbor)); } } }
#3 Target Sum
You are given a list of non-negative integers, a1, a2, ..., an, and a target, S. Now you have 2 symbols +
and -
. For each integer, you should choose one from +
and -
as its new symbol.
Find out how many ways to assign symbols to make sum of integers equal to target S.
Example 1:
Input: nums is [1, 1, 1, 1, 1], S is 3.
Output: 5
Explanation:
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
There are 5 ways to assign symbols to make the sum of nums be target 3.
Note:
- The length of the given array is positive and will not exceed 20.
- The sum of elements in the given array will not exceed 1000.
- Your output answer is guaranteed to be fitted in a 32-bit integer.
題目大意:>_< 大家自己查一下吧。。。不想翻譯了。。。主要是我也翻譯的不清楚。。。
你可能會很奇怪,這是一個一維的數組,怎么還可以DFS呀?
對,這就是DFS的靈活與巧妙之處,正如我文章開頭所說,DFS是一種算法,或者說是一種思想,和數據結構沒有任何關系,一定要搞清楚數據結構與算法的關系。
重要能夠在問題中找到DFS中的那幾個問題的答案,就可以應用DFS
還是那3個條件,2個遞歸:
條件1:是否出現“撞南牆” :出現了,當遞歸次數等於數組大小時,“撞南牆”不可以繼續遞歸了
條件2:是否存在“終點” :存在,當“撞南牆”的時候,累加的結果剛好等於給定的結果時,到達終點
條件3:是否需要記錄軌跡:這是一個一維數組啊,沒有回環,所以不需要記錄
遞歸:
1.“方向”:兩個方向:“ + ” 或者 “ - ”
2.是否需要記錄信息:需要記錄,記錄每次遞歸之后和的值
所以DFS代碼如下:
public void dfs(int[] nums , int s , int sum , int k) //s為目標的數值 ,sum是記錄每次遞歸之后的和的值 { if(k == nums.length) { if(sum == s) { res ++ ;//每次成功后記錄成功次數 } return ; } dfs(nums,s,sum + nums[k],k+1) ; //遞歸方向 dfs(nums,s,sum - nums[k],k+1) ; }
總代碼如下:
class Solution { int res = 0 ; public int findTargetSumWays(int[] nums, int S) { if(nums == null) return 0 ; dfs(nums,S,0,0); return res ; } public void dfs(int[] nums , int s , int sum , int k) { if(k == nums.length) { if(sum == s) { res ++ ; } return ; } dfs(nums,s,sum + nums[k],k+1) ; dfs(nums,s,sum - nums[k],k+1) ; } }
#4 樹的遍歷
樹的遍歷也可以使用DFS,這是一種極為簡便的調用方式,一下給出樹的結構,以及3種遍歷方式的代碼,大家自行分析那5個問題分別是什么
//二叉樹節點 public class BinaryTreeNode { private int data; private BinaryTreeNode left; private BinaryTreeNode right; public BinaryTreeNode() {} public BinaryTreeNode(int data, BinaryTreeNode left, BinaryTreeNode right) { super(); this.data = data; this.left = left; this.right = right; } public int getData() { return data; } public void setData(int data) { this.data = data; } public BinaryTreeNode getLeft() { return left; } public void setLeft(BinaryTreeNode left) { this.left = left; } public BinaryTreeNode getRight() { return right; } public void setRight(BinaryTreeNode right) { this.right = right; } }
//前序遍歷遞歸的方式 public void preOrder(BinaryTreeNode root){ if(null!=root){ System.out.print(root.getData()+"\t"); preOrder(root.getLeft()); preOrder(root.getRight()); } } //中序遍歷采用遞歸的方式 public void inOrder(BinaryTreeNode root){ if(null!=root){ inOrder(root.getLeft()); System.out.print(root.getData()+"\t"); inOrder(root.getRight()); } } //后序遍歷采用遞歸的方式 public void postOrder(BinaryTreeNode root){ if(root!=null){ postOrder(root.getLeft()); postOrder(root.getRight()); System.out.print(root.getData()+"\t"); } }
最后來一道比較困難的題目:
#5 Evaluate Division
Equations are given in the format A / B = k
, where A
and B
are variables represented as strings, and k
is a real number (floating point number). Given some queries, return the answers. If the answer does not exist, return -1.0
.
Example:
Given a / b = 2.0, b / c = 3.0.
queries are: a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ? .
return [6.0, 0.5, -1.0, 1.0, -1.0 ].
The input is: vector<pair<string, string>> equations, vector<double>& values, vector<pair<string, string>> queries
, where equations.size() == values.size()
, and the values are positive. This represents the equations. Return vector<double>
.
According to the example above:
equations = [ ["a", "b"], ["b", "c"] ], values = [2.0, 3.0], queries = [ ["a", "c"], ["b", "a"], ["a", "e"], ["a", "a"], ["x", "x"] ].
The input is always valid. You may assume that evaluating the queries will result in no division by zero and there is no contradiction
題干比較長,大家可以找一下相關的翻譯,我就不翻譯了哈。
這道題目有兩個比較難的點:1.構造圖的結構,2.DFS的尋找路徑
首先來講解一下如何來構造圖的結構。
我們根據題目的案例可以畫出如下的圖:
已知 a/b = 2 , b/c = 3 ,所以可以得知a->b為2 ,b->c為3,同理 b/a 為 1/2 , c/b為1/3 ,所以如果我們想求a/c,只需將a->b->c路徑上的權值乘起來就ok 2*3=6,同理求c/a 則將c->b->a上的權值相乘就好,由此我們將這個問題轉化成了尋找兩點之間最短路徑的問題,具體構造圖的代碼如下:(具體如何構建圖,以后會在數據結構中進行總結,本篇文章重點為DFS)
public double[] calcEquation(String[][] equations, double[] values, String[][] queries) { double[] num = new double[queries.length] ; Map<String,List<Adam>> map = new HashMap<>(); for(int i = 0 ; i < equations.length ; i ++) { if(!map.containsKey(equations[i][0])) map.put(equations[i][0],new ArrayList<>()); map.get(equations[i][0]).add(new Adam(equations[i][1],values[i])) ; if(!map.containsKey(equations[i][1])) map.put(equations[i][1],new ArrayList<>()); map.get(equations[i][1]).add(new Adam(equations[i][0],1/values[i])) ; } } class Adam { String s ; double dis ; Adam(String s , double dis) { this.s = s ; this.dis = dis ; } }
將圖建立好之后,只需要將案例中的queries兩兩加入到DFS中尋找路徑上的乘積結果就好
for(int i = 0 ; i < queries.length ; i ++) { num[i] = findPath(queries[i][0],queries[i][1],1.0,new HashSet()) ; }
下面我們來討論DFS
還是那3個條件,2個迭代:
1.有“南牆”嗎:有,節點上沒有還未訪問的節點時,圖中沒有對應節點時,出現自環時“撞南牆”。
2.有“終點”嗎:有,當尋找到終點節點時“成功”。
3.需要記錄訪問節點嗎:需要的,防止出現“回環。”
迭代:
1.“方向”是什么:為還沒有訪問過的節點。
2.需要記錄迭代信息嗎:需要,要記錄每次迭代路徑相乘的結果。
代碼如下:
public double findPath(String start ,String end , double val,Set<String> visited) //這里我們使用hashset來記錄路徑 { if(visited.contains(start)) return -1.0 ; //自環出現,“南牆” || 訪問過該節點 if(!map.containsKey(start)) return -1.0 ; //圖中沒有相應節點 if(start.equals(end)) return val ; //成功的標志———找到終點 visited.add(start) ; //記錄路徑 for(Adam next : map.get(start)) { //遍歷每個節點 double sub = findPath(next.s, end, val* next.dis, visited); if(sub != -1.0) return sub; } return -1.0 ; }
完整代碼如下:
class Solution { Map<String,List<Adam>> map = new HashMap<>(); public double[] calcEquation(String[][] equations, double[] values, String[][] queries) { double[] num = new double[queries.length] ; //Set<String> visited = new HashSet<>() ; 要注意啊!!!!! for(int i = 0 ; i < equations.length ; i ++) { if(!map.containsKey(equations[i][0])) map.put(equations[i][0],new ArrayList<>()); map.get(equations[i][0]).add(new Adam(equations[i][1],values[i])) ; if(!map.containsKey(equations[i][1])) map.put(equations[i][1],new ArrayList<>()); map.get(equations[i][1]).add(new Adam(equations[i][0],1/values[i])) ; } for(int i = 0 ; i < queries.length ; i ++) { num[i] = findPath(queries[i][0],queries[i][1],1.0,new HashSet()) ; //注意,每次尋找新的路徑時需要新建一個hashset } return num ; } public double findPath(String start ,String end , double val,Set<String> visited) { if(visited.contains(start)) return -1.0 ; if(!map.containsKey(start)) return -1.0 ; if(start.equals(end)) return val ; visited.add(start) ; for(Adam next : map.get(start)) { double sub = findPath(next.s, end, val* next.dis, visited); if(sub != -1.0) return sub; } return -1.0 ; } class Adam { String s ; double dis ; Adam(String s , double dis) { this.s = s ; this.dis = dis ; } } }
這里有一點非常值得注意(代碼中以標出),每次尋找路徑時需要新建一個hashset ,這樣清空節點的標記,方便重新尋找新路徑。
—————————————————————————————————————————————————————————
總結:
我們什么時候應該使用DFS呢?
當我們遇到的問題與路徑相關,且不是尋找最短路徑(最短路徑為BFS,下次再說),或者需要遍歷一個集合中的所有元素,或者是查找某一種問題的全部情況時,我們可以考慮使用DFS來求解。
更重要的是,要將數據結構的概念與算法分離開,才可以將算法靈活運用。