1. 前言
上周刷了20来道LeetCode的题,总结出了一些关于深度优先搜索的小小的心得,于是有了这篇博客。这次的总体思路是:
- 深度优先搜索算法的工作过程
- 如何使用深度优先搜索算法来进行遍历
- 两个有趣的问题
- 深度优先搜索算法和循环的关系
那我们开始吧。
2. 深度优先搜索
深度优先搜索(Deep First Search, DFS)是一种先序遍历,先遇到的节点先访问,然后以这个节点为起点,继续向下遍历,直至所有的节点都被访问(连通分量 == 1的情况下)。所以,个人认为,DFS可以看做是一种暴力枚举的算法。多说无益,下面一个例子可能会让你对DFS有初步的了解。

这是一个有向、无环、连通分量为1的图,我们可以用邻接矩阵来存储它:

这个矩阵的意义是,如果graph[i][j] == 1
,那么节点i和节点j的关系是i -> j
,即有从节点i到节点j的通路。
我先不放代码,我先跟着DFS的思路来对上面的图,以节点1为起点深度优先遍历一次。
由于是从节点1开始遍历,因此我们先看看数组graph[0][0…5],从0到5扫一遍,发现graph[0][1]不为零,说明有节点1到节点2的边。由于DFS是先访问,再以此节点为起点继续向下遍历,所以我们先把节点1,节点2存起来,然后以节点2为起点,向下遍历,如下图:

接下来,扫一遍graph[1][0...5]
,发现graph[1][2]
不为0,说明节点2连向节点3,则把节点3存起来,然后以节点3为起点,向下遍历:

很好,接下来扫一遍graph[2][0...5]
,但是遗憾的是,数组graph[2][0...5]
并没有值为1的元素,这表明,节点3没有连向其他节点,即出度为0。因此,这时候需要回溯,回到上一层:

这时,上一步已经回溯到了上一层,此时我们需要扫描graph[1][3...5]
,此时发现graph[1][3]
不为0,说明节点2连向节点4,那么应该把节点4存起来,接下来以节点4为起点继续向下遍历:

很好,接下来扫一遍graph[3][0...5]
,发现graph[3][4]
不为0,说明节点4连向节点5,我们应该把节点5存起来,然后以节点5为起点,向下遍历:

此时,我们需要扫描一遍graph[4][0...5]
,发现graph[4][5]
不为0,说明节点5连向节点6,我们应该把节点6存起来。然后以节点6位节点,向下遍历:

然而,graph[5][0...5]
并没有不为0的元素,这说明,节点6的出度为0,应该回溯,回到上一层:

此时,我们回到了graph[4][0...5]
,但是graph[4][0...5]
已经扫完了,我们继续回溯,回到graph[4][0...5]
的上一层graph[3][5...5]
,但是graph[3][5...5]
已经没有不为0的元素了,所以我们继续回溯,回到上一层:

此时,我们回到了graph[1][4...5]
,graph[1][4...5]
已经没有不为0的元素了,继续回溯,回到上一层:

终于回到graph[0][2...5]
了,我们继续扫描,发现graph[0][4]
不为0,但是节点5(graph[0][4]
的4表示节点5)已经访问过了,我们没必要,也不能继续存节点5了,所以我们忽略graph[0][4]
,继续扫描,最后扫描完毕,遍历结束。

这个过程如果明白了,代码就很容易写出来:
1 | private static void deepFirstSearch(int[][] graph, List<Integer> result, int[] accessFlag, int startVertex) { |
解释一下,for循环对应上面所说的扫描,startVertex
是当前的起点。如果graph[startVertex][i]
不为0,说明有从startVertex到i的通路,即startVertex -> i
,并且如果节点i并未被访问(访问节点i时accessFlag[i]会被置为1),那就以节点i为起点向下遍历;而if语句是为了检查输入数据是否合法。
方法已经写好,我们只需要如下调用,就能拿到DFS的结果:
1 | List<Integer> result = new ArrayList<>(); |
总结一下,DFS就是先遇到先访问,再以此为起点继续访问,直至全部访问完毕,很像先序遍历。
3. DFS应该怎么用来遍历?
通过上面的说明,想必已经对DFS有初步的理解了吧。那我们继续吧。
前面我已经说过,DFS可以看做是一种暴力枚举算法。结合上面的例子,我们可以看到,邻接矩阵graph的所有元素都被DFS算法扫描了一遍,所以至少从这个例子来看DFS是一种暴力枚举算法。我认为确实也是。
OK,既然我们已经知道DFS可以看做一种暴力枚举算法,我们应该怎么用它来枚举?在这里,我想通过分析二叉树的先序遍历过程乃至多叉树的遍历过程,进而推广到一般的情况来说明。
首先定义一下树的数据结构:
1 | private class Tree { |
自然地,该二叉树的先序遍历就可以这么写:
1 | private static void preOrderTraversal(Tree parent) { |
二叉树的遍历如何推广到多叉树的遍历?如果可以用for循环将这个节点的所有孩子节点列举出来那就好了。确实可以。我们只需要稍微改写一下数据结构:
1 | private class Tree { |
然后如下遍历:
1 | private static void preOrderTraversal(Tree parent) { |
为什么要这么写,这段代码背后的思路是什么?
如果children.length == 2
那就是二叉树的情形。自然地,如果children.length
为任意值,那就是任意多叉树的情形,即可以对任意多叉树进行遍历。其实思路就是将孩子节点转化成可以被for循环列举的形式(如上面是把孩子节点转成数组)。
现在,我们看看多叉树的子结构:

这个多叉树的子结构,是一个父节点带着n个子节点(n是任意一个大于0的整数)的结构。我们如何对这个子结构进行遍历?
- 首先我们访问父节点,即
accessVertex(parent)
- 因为父节点有n个子节点,所以我们挨个的访问它们。由于孩子节点存在数组中,于是我们可以用for循环解决。
因此遍历一个子结构就可以这么写:
1 | // traversal the substructure of tree |
由于多叉树所有的子结构共同组成整个多叉树(即可以被递归定义),所以我们可以用递归来完成遍历。
如何使用递归?只需要确认好边界条件即可。初始条件当然是放入一个树的根,停止向下递归则是遇到了叶子节点,即这个节点的children域为空。所以:
1 | // Traversal tree |
说了那么多,我想表达的是:如果一个节点,它的子节点可以用循环来列举的话,那我们可以用循环+递归的形式来进行遍历这个图。
更近一步来说,我们可以用递归+一个for循环来实现n重循环,进而进行遍历。n重循环天生就适合拿来作为枚举的工具。所以,我们可以把问题写成n重循环的形式,然后再转化成递归,就可以用DFS来解决遍历问题。
因此,DFS可以写成如下形式:
1 | void DFS(Graph graph) { |
4. 两个问题
在刷题的过程中,我遇到了两个很有意思的问题,都可以用DFS来解决,这里分享一下,分别是迷宫问题和不重复字符的字符串。
4.1. 迷宫问题
长话短说,迷宫问题是这么被描述的(LeetCode上的描述很长,我就不贴了):
给定一个二维数组来表示一个迷宫。这个数组里的元素要么为0要么为1;0代表可以通过,1代表是墙壁,不可通过。
比如说,给定一个二维数组:
1 | int[][] maze = { |
它表示这个迷宫,灰色为墙壁,白色为通路:

现在,给定一个起点和终点,找出一条可行的路径来走出这个迷宫。
不妨以上图为例子,起点为(1, 1),终点为(6, 5)。
如何用DFS的实现来解决迷宫问题?我们走到一个点时(必然的,这个点的值为0,即为通路),我们可以选择向上、向下、向左或者是向右走;而前面的问题,都是一个父节点带着n个子节点的情形,我们可以照葫芦画瓢,父节点就是当前节点,而子节点就是上、下、左以及右的节点,画成图的话是这样的:

先对这个子结构进行遍历,伪代码:
1 | // Traversal currentPoint and its North, South, West and East Point |
接下来确定好边界条件,初始条件当然是给一个起点,停止向下递归的条件是:
- 遇上墙壁
- 遇上终点
所以,对每个子结构遍历的伪代码:
1 | // Solve Maze Problem |
因此,相应的Java代码实现有:
1 | private static Point[] getPoints(Point currentPoint) { |
其中isValidPoint()会判断是否越界或者遇上墙壁;而hasNotAccess()会判断节点是否被访问过,这么做可以防止North -> South -> North ...
或者East -> West -> East ...
的死循环。
代码的思路就是把所有子节点转换成可以被for循环枚举,每走一步,以当前节点为父节点继续枚举(即向下遍历),直到遇到终点或者墙壁后回溯。其实,这段DFS代码的可以转换成一个n重循环。
4.2. 不重复的字符的字符串
其实这题不是LeetCode上的,是我从同学那里听说的。听完题目之后觉得挺有意思的,觉得用DFS可解,然后码了下代码,发现真的可以。
这道题是这么描述的:
给定一个没有重复字符的字符串s,字符串的第i个字符用si表示,即s = s1s2s3…si…sn
请给出k个不重复的,由s[1…n]组成的,没有重复字符,长度为n的字符串。
Example 1:
Input:
s = “01234567”, k = 3
Output:
”01234567”, “74625310”, “45367201”
Example 2:
Input:
s =”abc”, k = 5
Output:
”abc”, “bca”, “cba”, “acb”, “bac”
给定的字符串s是一维的,明显不能直接用DFS来搜索。能使用DFS,数据必须是二维的。于是我们可以构造一个矩阵,矩阵就是二维的,以s = “abcd”为例,如下:

为什么我会想到构造一个矩阵出来?原因不难理解。前面我不断的说:DFS代码可以转换成一个n重循环
。n重循环很好枚举,所以我就构造出一个4*4的矩阵,每一行都是s
这样我就能写成如下形式:
1 | for i in 0 ~ 3: |
然后我就可以转成递归的形式:
1 | for i in 0 ~ 3: |
转换成递归形式,可能就就不大容易看懂了,我们来看看这张图:

当我们走到了第i
个字符串时,接下来该往s1...sn
走,于是我们可以用循环来枚举。
所以,相应的实现代码:
1 | private static void bruteSearch(String[] targetCharMatrix, StringBuilder stringBuilder, List<String> result, int[] accessFlag, int depth) { |
先解释一下参数列表:
其中targetCharMatrix是根据s构造出来的矩阵;
stringBuilder是用来拼接字符的对象;
result是用来存放结果的对象;
accessFlag是存放访问结果,如果第i个元素为0则表示第i个字符还没被访问(拼接),否则如果为1则表明已经被访问(拼接)过;
depth表示已经拼接字符的个数。
然后就是方法体:
首先判断是否超过最大深度,即depth
是否等于4。
如果小于4:那就遍历targetCharMatrix[depth][0...3]
。如果targetCharMatrix[depth][i]
没被访问过,那就把accessFlag[i]
置1,然后用stringBuilder
把targetCharMatrix[depth][i]
拼接进来,然后向下遍历,直至回溯回来后,将访问标志accessFlag[i]
置0,把targetCharMatrix[depth][i]
从stringBuilder
中移除。
否则,如果depth
等于4:说明此时stringBuilder.length() == 4
,那就把它加到结果result
里就ok了。其实还是一个n重循环问题。
其实,直接传一个矩阵(String[] targetCharMatrix)没有必要,因为这个矩阵的每行都一样。我们只需要传一个原字符串(即矩阵的任意一行),再用depth来控制最大深度(矩阵的行数)就可以了。代码如下:
1 | private static void bruteSearch(String targetChar, StringBuilder stringBuilder, List<String> result, int[] accessFlag, int depth) { |
5. 由两个问题所引发的思考
上面已经解释了,DFS可以写成n重循环的形式。所以DFS和n重循环有什么关系?
DFS的基本形式,就是一个循环内调用自身。即一个循环+递归的形式:
1 | function DFS(graph, depth): |
如果graph
是4*4的,如果调用DFS(graph, 0)
,
那么第一层:
1 | for i in 0 ~ 3: |
第二层:
1 | for i in 0 ~ 3: |
第三层:
1 | for i in 0 ~ 3: |
第四层:
1 | for i in 0 ~ 3: |
把第i层的DFS(graph, (i - 1) + 1)
替换成第i + 1层的代码,可以得到:
1 | for i in 0 ~ 3: |
从上面可以看出,DFS本质就是一个n重循环,DFS自然可以用n重循环来表示。
其实,观察每一层,每一层不一样的地方也就只有传的参数的值不同,也能看出这就是一个n重循环,有点像数学归纳法的递推。
还有就是,我上面不断的提子结构
,其实我个人觉得能写成递归解决的问题,都很像数学中的分形。
6. 总结
- 如果一个枚举问题,它的子结构共同组成该问题,就像上面提到的迷宫问题一样,每走一步后,可以上下左右走,这是一个子结构,共同组成原问题,那么这个问题就可以写成n重循环的形式。既然能够写成n重循环的形式,自然就能转化成递归的形式,就能用DFS的思想解决。
- 如果一个枚举问题,它的子结构不能组成该问题,那我们就可以转化一下,如果能转化成让它可以由子结构组成,就像上面提到的不重复字符的字符串一样,一维的字符串自然没有子结构能够组成这个问题,那我们就构造一个矩阵,这样我们就能在递归深度为depth时,访问第1,第2 … 第n个元素,写成n重循环的形式,从而转化成递归进而用DFS的实现解决。
- DFS是解决枚举问题的基础,但不是所有问题都能照搬DFS的代码得到解决,需要具体情况具体分析。
7. 感想
在这之前,我对DFS的理解,也仅仅只是对二叉树的遍历。刷了一定量的题,我慢慢发现,只要给初始条件和停止条件,递归可以解决需要多重循环的问题,也就是DFS的实现。所以刷题还是有用的!想要对算法有理解还是要多刷题(不会可以看看Discussion,里面各路大神各种骚操作,都能解决问题)。就这样吧。如果本文有什么问题,请指教,共同进步,谢谢~!