40.深度优先搜索¶
在上一章中,你看了广度优先搜索(BFS
),在这一章中,你必须在进入下一层次之前探索一个顶点的每一个邻居。在本章中,你将了解depth-first search
(DFS),这是一种用于遍历或搜索图形的算法。
DFS
有很多的应用:
- 拓扑结构的排序。
- 检测一个周期。
- 寻路,比如在迷宫游戏中。
- 寻找稀疏图中的连接组件。
为了执行DFS
,你从一个给定的源顶点开始,试图尽可能地探索一个分支,直到你到达终点。在这一点上,你会backtrack
(后退一步)并探索下一个可用的分支,直到你找到你正在寻找的东西或你已经访问了所有的顶点。
示例¶
让我们来看看DFS
的例子。下面的例子图与前一章相同。这是为了让你看到BFS
和DFS
之间的区别。
你将使用一个stack
来记录你所移动的关卡。堆栈的last-in-first-out
方法有助于回溯。栈上的每一个push
都意味着你向更深的层次移动。如果你走到了死胡同,你可以pop
返回到前一关。
- 和上一章一样,你选择
A
作为起始顶点,并将其加入堆栈。 - 只要堆栈不是空的,你就访问堆栈上的顶点,并推送尚未访问的第一个邻近顶点。在这种情况下,你访问
A
并推送B
。
Note
回顾上一章,添加边的顺序会影响搜索的结果。在本例中,添加到A
的第一条边是到B
的一条边,所以B
被首先推送。
- 你访问了
B
并推了E
,因为A
已经被访问了。 - 你访问
E
并推送F
。
请注意,每次你在堆栈中推送,你都会在一个分支中前进得更远。你不是访问每一个相邻的顶点,而是继续沿着一条路径前进,直到到达终点,然后回溯。
- 你访问
F
并推动G
。 - 你访问
G
并推C
。
- 下一个要访问的顶点是
C
。它有邻居[A, F, G]
,但所有这些都已经被访问过了。你已经到达了一个死胡同,所以现在是时候从堆栈中弹出C
进行回溯了。 - 这使你回到了
G
。它有邻居[F, C]
,但所有这些都已经被访问过。另一个死胡同,弹出G
。
F
也没有剩余的未访问的邻居,所以弹出F
。- 现在,你回到了
E
处。它的邻居H
仍然没有被访问,所以你把H
推到栈上。
- 访问
H
的结果是另一个死胡同,所以弹出H
。 E
也没有任何可用的邻居,所以弹出它。
- 对
B
来说也是如此,所以弹出B
。 - 这使你回到了
A
,它的邻居D
仍然需要被访问,所以你把D
推到栈上。
- 访问
D
的结果是另一个死胡同,所以弹出D
。 - 你回到
A
,但这次没有可用的邻居可以推送,所以你弹出A
。堆栈现在是空的,DFS
已经完成。
在探索顶点时,你可以构建一个树状结构,显示你所访问的分支。你可以看到与BFS
相比,DFS
走得有多深。
实施¶
打开本章的启动playground
。这个playground
包含一个图形的实现,以及一个堆栈,你将用它来实现DFS
。
在你的主playground
文件中,你会注意到一个预先建立的样本图。添加以下内容:
extension Graph where Element: Hashable {
func depthFirstSearch(from source: Vertex<Element>)
-> [Vertex<Element>] {
var stack: Stack<Vertex<Element>> = []
var pushed: Set<Vertex<Element>> = []
var visited: [Vertex<Element>] = []
stack.push(source)
pushed.insert(source)
visited.append(source)
// more to come ...
return visited
}
}
在这里,你定义了一个方法depthFirstSearch(from:)
,它接收一个起始顶点,并按照访问的顺序返回一个顶点的列表。它使用了三个数据结构。
stack
用于存储你在图中的路径。pushed
记住哪些顶点以前被推过,这样你就不会访问同一个顶点两次。它是一个Set
,以确保快速O(1)
查找。visited
是一个数组,存储顶点被访问的顺序。
为了开始这个算法,你把source
顶点加入到所有三个顶点中。
接下来,通过将注释替换为:完成该方法:
outer: while let vertex = stack.peek() { // 1
let neighbors = edges(from: vertex) // 2
guard !neighbors.isEmpty else { // 3
stack.pop()
continue
}
for edge in neighbors { // 4
if !pushed.contains(edge.destination) {
stack.push(edge.destination)
pushed.insert(edge.destination)
visited.append(edge.destination)
continue outer // 5
}
}
stack.pop() // 6
}
以下是发生的情况:
- 你继续检查堆栈顶部的顶点,直到堆栈为空。你把这个循环标记为
outer
,这样你就有办法继续到下一个顶点,即使是在嵌套循环中。 - 你找到当前顶点的所有邻接边。
- 如果没有边,你就从堆栈中弹出顶点并继续下一个顶点。
- 在这里,你循环浏览与当前顶点相连的每一条边,并检查是否已经看到了邻近的顶点。如果没有,你就把它推到堆栈中,并把它加入到
visited
数组中。把这个顶点标记为被访问的顶点似乎有点为时过早(你还没有偷看过它),但是,由于顶点是按照被添加到堆栈的顺序被访问的,所以它的结果是正确的。 - 现在你已经找到了一个要访问的邻居,你继续进行
outer
循环并移动到新推的邻居。 - 如果当前顶点没有任何未访问的邻居,你知道你已经到达了一个死胡同,可以把它从堆栈中弹出。
一旦堆栈为空,DFS算法就完成了!你所要做的就是按照访问过的顶点的顺序返回。你所要做的就是按照你访问的顺序返回所访问的顶点。
为了尝试你的代码,请在playground
上添加以下内容:
let vertices = graph.depthFirstSearch(from: a)
vertices.forEach { vertex in
print(vertex)
}
请注意,使用DFS
的访问节点的顺序:
0: A
1: B
4: E
5: F
6: G
2: C
7: H
3: D
性能¶
DFS
将至少访问每个顶点一次。这个过程的时间复杂度为O(V)
。
在DFS
中遍历一个图形时,你必须检查所有相邻的顶点以找到一个可供访问的顶点。这方面的时间复杂度为O(E)
,因为在最坏的情况下,你必须访问图中的每一条边。
总的来说,深度优先搜索的时间复杂度是O(V+E)
。
深度优先搜索的空间复杂度是O(V)
,因为你必须在三个独立的数据结构中存储顶点:stack
、pushed
和visited
。
关键点¶
- 深度优先搜索(
DFS
)是另一种遍历或搜索图的算法。 DFS
尽可能地探索一个分支,直到它到达终点。- 利用一个堆栈数据结构来跟踪你在图中的深度。只有当你到达死胡同时才会弹出堆栈。