DFS & BFS

1. Introduction

给定一个图,我们可以使用O(V+E)DFS(深度优先搜索)或BFS(广度优先搜索)算法来遍历该图并探索该图的特征/属性。每种算法都有自己的特点、特征和副作用,我们将在这个可视化中探讨。
这个可视化的内容很丰富,有很多DFS和BFS的变体(都以O(V+E)运行),比如:
  1. 拓扑排序算法(包括DFS和BFS/Kahn的算法版本),
  2. 二分图检查器算法(包括DFS和BFS版本),
  3. 切割顶点和桥的查找算法,
  4. 强连通分量(SCC)查找算法
    (Kosaraju的和Tarjan的版本),
  5. 以及2-SAT检查算法。

2. 可视化

当所选的图遍历算法运行时,将在次处显示动画。


我们使用节点 + 边颜色(颜色方案将很快阐述),偶尔使用节点下的额外的文本(红色字体)来突出显示更改。

所有的图遍历算法都适用于有向图(这是默认设置,其中每个边都有一个箭头指示其反向),但是 Bipartite Graph Check 算法和 Cut Vertex & Bridge 查找算法 需要无向图(通过这种可视化,转换是自动完成的)。

3. 指定一个输入图

对于指定一个输入图,有两种不同的方法:

  1. 绘制图: 您可以绘制任何未加权的有向图作为输入图(绘制双向边 (u, v) ,您可以绘制两个有向边 u → v and v → u )。
  2. 示例图: 您可以从我们选择的示例图列表中进行挑选,以帮助您入门。

4. 概括

如果您还没有探索/掌握 Binary Heap 的概念,尤其是 Binary Search Tree, ,我们建议您首先探索它们,因为遍历一个(二叉)树结构比遍历一般图简单得多。

Quiz: Mini pre-requisite check. What are the Pre-/In-/Post-order traversal of the binary tree shown (root = vertex 0), left and right child are as drawn?

Pre = 0, 2, 4, 3, 1
Post = 1, 3, 4, 2, 0
Pre = 0, 1, 2, 3, 4
Post = 4, 3, 2, 1, 0
In = 4, 2, 3, 0, 1
In = 1, 0, 3, 2, 4

4-1. 二叉树遍历 - 源 = 根

我们通常从(二叉)树的最重要的顶点:节点 开始。
如果给定的树不是“rooted”(参见示例图片),我们可以选择任何一个顶点(例如,示例图片中的顶点0)并将其指定为根。如果我们想象所有边都是相似长度的弦,那么在”实际向上拉指定的根“并让中立向下拉动其余部分之后,我们有一个有根的(向下)树 - 见下一张幻灯片。
PS:从技术上来讲,这种转换是通过运行我们即将探索的 DFS(0) 来实现的。

4-2. 二叉树遍历 - 前序-/中序-/后序-遍历

二叉树中,我们最多只有两个相邻的选择:从当前顶点开始,我们可以先到左边的子树,或者先到右边的子树。我们还可以选择在访问其中一个(或两个)子树之前或之后访问当前顶点。
这产生了个有代表性的:前序(访问当前顶点,访问其左子树,访问其右子树),中序(左,当前,右),和后序(左,右,当前)遍历。
讨论:您是否注意到还有其它三种可能的二叉树的遍历组合?他们是什么?

4-3. 答案

[This is a hidden slide]

4-4. 二叉树遍历 - 无环

一个二叉树中,或者概括来说 一个树结构,不包含大于三个不同的顶点(我们不考虑那些连通两个顶点的双向路径所产生的小圈 我们可以很容易的处理掉它们 - 往前翻三页)

4-5. 一般图的问题

在一般图中,我们没有根节点的概念。相反,我们需要选择一个不同的节点作为遍历的起始点,即源点 s

我们还有一个节点的 0, 1, ..., k 个邻点,而不仅仅是 ≤ 2。
我们可能或者实际上很可能)在我们的一般的图具有循环,无论是像 u → v → u 那样的简单的环,还是像 a → b → c → a 这样的不简单的环。
但不用担心,图的遍历是一个具有两种经典算法的简单问题:DFS 和 BFS。

5. DFS

最基本的图遍历算法之一是 O(V+E) 深度优先搜索(DFS)。
DFS 采用一个输入参数:源点 s
DFS 是最基本的图的算法之一,因此请花时间了解该算法的关键步骤。

5-1. 比喻

mazeDFS类比一个只有一个人口和一个出口的迷宫。您在 入口处,想要探索迷宫到达出口。显然你不能分身。

在继续之前,先思考这些反思性问题:如果您面前有分支选择,您会怎么做?如何避免进入循环?如何标记自己的路径?提示:你需要一只粉笔,石头(或任何其他标记物)和一根(长)线。

5-2. 尝试所有选项

顾名思义,DFS从一个已知的源顶点  s 使用递归(隐式堆)来控制访问顺序为走到最深再返回。

如果DFS在顶点 u 并且它有 X 个邻居,它会选择第一个邻居 V1 (通常是序号最小的那个顶点), 使用递归访问所有 V1可以到达的顶点, 最终返回顶点 u. DFS 接下来对其他的邻居做同样的事指导探索完成最后一个邻居 VX 和它所能触及到的顶点.

等下看了DFS的动画 这个冗长的解释会变得清晰起来。

5-3. 避免循环

如果一个图是圈,之前的“尝试所有”的方法可能让DFS陷入循环。所以DFS的基本形式用一个大小为 V 个顶点的数组 status[u] 来确定两种情况 分别为 u 已经被访问过了 或者没有被访问过。只有当 u 还没有被访问过的时候 DFS才可以访问顶点 u.


当DFS没有路可走的时候它会跳出当前的递归 回去 到之前的顶点 (p[u], 看下一页).

5-4. 记住这个路径

DFS 用另一个大小为 V 个顶点数组 p[u] 来记住在DFS遍历路径上每一个顶点 uparent/predecessor/previous(父/祖先/前)顶点。

最开始的顶点的祖先也就是 p[s] 被设定为-1也就是说它没有祖先 (因为最低的顶点是顶点0).

从一个源顶点 s 到一个可以到达的顶点 u 所生成的路径反过来 就是 DFS 生成树. 我们给这些 树边红色.

5-5. 动手实例

现在,忽略显示的伪代码中额外的 status[u] = explored 以及可视化中的 蓝色灰色 边的存在 (将很快会解释)。

不用多说,让我们在这个 e-Lecture 的默认示例图上执行 DFS(0) (CP3 Figure 4.1)。 Recap DFS Example
到目前为止,DFS 的基本版本已经足够用于大多数的简单案例。

5-6. O(V+E) 时间复杂度

DFS 的时间复杂度是 O(V+E) ,因为:

  1. 每个节点只访问过一次,因为 DFS 将仅递归地探索节点 u 如果 status[u] = unvisited — O(V)
  2. 每次访问完一个节点,都会探索其所有 k 个邻点,因此在访问所有节点之后,我们已检查了所有 E 边 — (O(E) ,因为i每个节点的邻点总数等于 E)。

5-7. 始终是 O(V+E) ?

DFS 的he O(V+E) 时间复杂度只有当我们可以在 O(k) 时间内访问一个顶点的所有 k 个邻点时才可以实现。

Quiz: Which underlying graph data structure support that operation?

Adjacency Matrix
Adjacency List
Edge List

讨论:为什么?

5-8. 答案

[This is a hidden slide]

6. BFS

另一种基本的图遍历算法是 O(V+E) 广度优先搜索 (BFS)。
与 DFS 一样,BFS 也采用一个输入参数:源点 s
DFS 和 BFS 都有自己的优点和缺点。学习两者并对正确的情况采用正确的图遍历算法是非常重要的。

6-1. 比喻

想象一下静止的水,然后你扔石头。石头撞击水面的第一个位置是源点的位置,并且随后在水面上的波纹效应类似于 BFS 遍历模式。

6-2. 尝试全部,避免循环,记住路径

BFS 与之前讨论过的非常相似,但有一些差异。

BFS 从源点 s 开始,但它在更深入之前使用 queue 尽最宽可能地将访问序列排序。


BFS 还是用大小为 V 节点的布尔数组来区分两种不同的状态:已访问节点和未访问节点(我们不会像使用 DFS 那样使用 BFS 来检测反向边)。

在此可视化中,我们还展示从未加权图中的相同源点 s 开始,此图的 BFS 生成树等于其 SSSP spanning tree.

6-3. 动手实例

不多说,让我们在默认的示例图上执行 BFS(5) (CP3 Figure 4.3). Recap BFS Example.


注意这里是 宽度优先 式的探索 因为我们用了 先进先出的数据结构:队列?

6-4. O(V+E) 时间复杂度

BFS的时间复杂度是 O(V+E),因为:

  1. 每一个顶点都被访问一次 因为它们只能进入队列一次— O(V)
  2. 每当一个顶点从队列中出队时,所有它的 k 个邻居都会被探索 所以当所有的顶点都被访问过后,我们一共探索了 E 条路径 — (O(E) 因为每个顶点的邻居总数为 E).

对于DFS来说 O(V+E) 只有在用 邻接表 图数据结构 — 和DFS分析相同

7. 简单的 DFS/BFS 应用

到现在为止,我们可以用 DFS/BFS 去解决一些图的遍历问题变种:

  1. 检测可达性
  2. 显示出遍历路径
  3. 分辨/计数/标记 一个无向图的连通分量(CCs)
  4. 探测图是否有圈(cyclic)
  5. 拓补排序(只在有向无圈图 DAG中)

多数的数据结构和算法课程只传授这些 DFS/BFS 的基本应用,尽管它们可以做更多...

7-1. 可达性测试

如果您被要求测试图中的节点 s 和一个(不同的)节点 t 是否可达,即直接连接(通过一条边)或间接连接(通过简单的非环路径),则可以调用 O(V+E) DFS(s) (或 BFS(s)) 并检查是否 status[t] = visited。

例子 1:s = 0t = 4, 运行 DFS(0) 并注意 status[4] = visited. 例子 2: s = 0t = 7, 运行 DFS(0) 并注意 status[7] = unvisited.

7-2. 打印遍历路径

回想:每次用DFS或BFS从定点u到顶点v(一个生成树中的树边)时,我们设置p[v] = u。我们可以使用以下简单的递归函数来打印出存储在数组 p 中的路径。可能的后续讨论:您能以迭代的形式写出来吗?(容易解决)

method backtrack(u)
if (u == -1) stop
backtrack(p[u]);
output vertex u

要打印图中从源点到目标顶点 t 的路径,可以调用 O(V+E) DFS(s) (或 BFS(s)) ,然后调用 O(V)

backtrack 去返回 (t)。示例: s = 0t = 4,您可以调用 DFS(0) 然后backtrack(4)Elaborate

7-3. 识别一个连接分量(CC)

我们可以通过简单地调用 O(V+E) DFS(s) (或 BFS(s)) ,并枚举所有 status[v] = visited 的节点 v,来枚举从无向图中的节点 s 可到达的所有节点(如上图的示例图所示)。

示例: s = 0,运行 DFS(0) 并注意 status[{0,1,2,3,4}] = visited,因此它们都是从节点 0 可到达的节点,即它们形成一个连通分量(CC)

7-4. 计算 CC 的数量/ 标记 CC

我们可以用如下的伪代码来计算连通分量(CCs)的数量:

CC = 0
for all u in V, set status[u] = unvisited
for all u in V
if (status[u] == unvisited)
CC++ // 我们可以用CC计数器的数量来作为CC的标记
DFS(u) // 或者 BFS(u), 来标记它的成员为已访问
output CC // 上面的示例图的答案是3
// CC 0 = {0,1,2,3,4}, CC 1 = {5}, CC 2 = {6,7,8}

如果你想要给每一个CC你自己的标记,你可以简单修改 DFS(u)/BFS(u) 的代码。

7-5. 等等,时间复杂性是什么?

Quiz: What is the time complexity of Counting the Number of CCs algorithm?

Calling O(V+E) DFS/BFS V times, so O(V*(V+E)) = O(V^2 + VE)
Trick question, the answer is none of the above, it is O(_____)
It is still O(V+E)

讨论:为什么?

7-6. 答案

[This is a hidden slide]

7-7. 检测圈 - 第一部分

为了深入我们对底层图的理解,我们可以 扩大 DFS算法的基础。


在这个动画里, 我们用 蓝色 来表示DFS生成树的 返回 边。 如果发现了至少一个返回边 , 那代表被遍历的图(部分)是圈 的,而如果没有返回边的话,那就代表从出发点开始的部分并不是圈的。

7-8. 探测圈 - 第2部分

为了侦测 返回边,我们可以通过修改 status[u] 来记录 种不同的状态:

  1. 未访问: 和之前一样,DFS还没有访问过 u
  2. 已探索: DFS已经访问了 u, 但是至少有一个 u 的邻居还没有被探索 (DFS会先 深度优先 式的先去探索那个顶点的邻居),
  3. 已访问: 增强版的定义:顶点 u 的所有邻居都已经被探索过了并且DFS正要从顶点 u 原路返回去顶点 p[u].

如果DFS现在在顶点 x ,同时正在在探索边 x → y 并且遇到 status[y] = explored, 我们可以确认 x → y 是一个 返回边 (我们找到了一个圈因为我们之前在顶点 y (因此 status[y] = explored), 走更深去探索 y 的邻居等等, 但是我们现在在顶点 x ,它是一个可以从 y 出发所抵达的顶点 但是顶点 x 引领我们走回了 y).

7-9. 实践例子(细节)

图中不是 树边 也不是 返回边 的路径都以 灰色 显示。他们叫做 前进或交叉边,对于现在的我们来说用处不大, 不再赘述。


现在,综合新的理解,在上面的示例图中试试 DFS(0), 特别留意一个顶点的三种不同状态 (未访问/正常的黑色圆, 已探索/蓝色的圆, 已访问/橘色的圆) 还有 返回边。 2 → 1 被探测到是一个返回边 因为它是圈(circle) 1 → 3 → 2 → 1 的一部分(从”已探索“的顶点2到”已探索“的顶点1)(相似的 6 → 4 是圈 4 → 5 → 7 → 6 → 4的一部分)。

注意,如果2 → 1和6 → 4被逆转成1 → 2和4 → 6,那么这个图将会被正确地归类为无环图,因为3 → 2和4 → 6从”已探索“移动到”已访问“。如果我们只用两种状态”已访问“和”未访问“,那么我们将无法把这两种情况分开。

7-10. 拓扑排序 - 定义

还有另一个可以被视为”简单“的 DFS(以及 BFS)应用:执行有向无环图(DAG)的拓扑排序 — 参见上面的示例。

DAG 的拓扑排序是此 DAG 的节点的线性排序,其中每个节点位于其传出边所连接的所有节点之前。
每个 DAG (可以用之前的DFS来检查)至少有一个但可能更多的拓扑排序/秩序。
DAG拓扑排序的主要目的是用于 Dynamic Programming (DP) 技术。例如,此拓扑排序过程在 DP solution for SSSP on DAG内部使用。

7-11. 拓扑排序

我们可以使用 O(V+E) DFS 或 BFS 来执行有向无环图(DAG)的拓扑排序。

与普通 DFS 相比,DFS 版本只需要额外的一行,基本上是此图的后序遍历。在示例的DAG上尝试 Toposort (DFS)
BFS 版本基于没有传入边的节点的概念,也称为 Kahn 算法.。在示例的DAG上尝试 Toposort (BFS/Kahn's)

8. 更多高级的 DFS/BFS 的应用

目前为止,你已经看到了DFS/BFS(加上轻微改动)可以解决的问题。有一些更高级的应用需要更多的改动,更先进的学生可以自己探索:
  1. 检测二分图 (DFS 和 BFS 变种),
  2. 寻找无向图的衔接点(切顶)和桥梁(仅DFS),
  3. 寻找有向图的强连通分量(SCC)(Tarjan和Kosaraju的算法), 以及
  4. 2-SAT(可满足性) 检查算法.


广告:细节写在Competitive Programming book一书中

9. 二分图检查

我们可以使用O(V+E)DFS或BFS(它们的工作原理相似)来检查一个给定的图是否是一个二分图(Bipartite Graph),方法是在相邻的顶点之间给出交替的颜色(在这个可视化中是橙色和蓝色),如果我们最终给两个相邻的顶点分配了相同的颜色,则报告 "非二分",如果有可能进行这样的 "二分上色"过程,则报告 "二分"。试试DFS_CheckerBFS_Checker在双子星图的例子中。
二分图在图形匹配问题上有很好的应用。
请注意,二分图通常只对无向图进行定义,所以在继续之前,这个可视化将自动把有向的输入图转换成无向的版本。这个动作是不可逆的,你可能不得不为其他目的再次重绘有向输入图。

10. 查找切割节点 & 桥

我们可以将O(V+E)DFS算法修改为寻找无向图的切顶和桥的算法(但不幸的是,这并不简单)。
切割顶点,或衔接点,是指无向图中的一个顶点,移除该顶点会使图断开连接。同样地,桥是无向图中的一条边,除去这条边就会断开图的连接。
请注意,这种寻找切割点和桥的算法只适用于无向图,所以在继续之前,这个可视化会自动将有向图的输入转化为无向图的版本。这个动作是不可逆的,你可能不得不为其他目的再次重绘有向输入图。你可以在上面的例子图上尝试Find Cut Vertices & Bridges

11. 找到强联通分量

我们可以将O(V+E)DFS算法修改为寻找有向图G的强连通分量(SCC)的算法(但不幸的是,这并不简单)。
有向图G的SCC定义为G的一个子图S,对于S中的任何两个顶点u和v,顶点u可以直接或通过路径到达顶点v,而顶点v也可以直接或通过路径返回到顶点u。
有两种已知的算法用于寻找有向图的SCC。Kosaraju的和Tarjan的。这两种算法都可以在这个可视化中找到。在上面的有向图例子上试试Kosaraju's Algorithm和/或Tarjan's Algorithm

12. 2-SAT Checker 算法

我们也有2-SAT检查器算法。给出一个2-SAT(2-Satisfiability)实例,其形式是子句(clause)的组合:(clause1) ^ (clause2) ^ ... ^ (clausen),并且每个子句都是由最多两个变量(vara v varb)组成的二元析取形式,请确定我们是否可以给这些变量分配真/假值,从而使整个2-SAT实例被评估为真(即可满足)。
事实证明,每个子句(a v b)可以变成四个顶点a、not a、b和not b,和两条边(not a → b)和(not b → a)。因此,我们有一个有向图。如果在这种图的强连通分量内至少有一个变量和它的否定,我们知道它不可能满足2-SAT实例。
在这样的有向图建模之后,我们可以运行一个SCC查找算法(Kosaraju的或Tarjan的算法)来确定2-SAT实例的可满足性。

13. 哪一个更好?

Quiz: Which Graph Traversal Algorithm is Better?

It Depends on the Situation
Always BFS
Both are Equally Good
Always DFS

讨论:为什么?

13-1. 答案

[This is a hidden slide]

14. 额外的

我们仍然可以只用 DFS/BFS 做很多事情......

14-1. 在线测试

关于这两个图的遍历算法有一些有趣的问题:DFS + BFS 和图遍历问题的变体,请在 Graph Traversal 培训模块中练习(不需要登陆,但是您最多只能做短和中等难度的问题)。

但是,对于注册用户,您应该登陆后转到 Main Training Page 以正式清除此模块,此类成就将记录在您的用户账户中。

14-2. 在线评判练习

我们有一些Kattis问题多多少少用到了DFS和/或BFS: Kattis - reachableroadsKattis - breakingbad.


试试解出它们并且尝试 更多 有趣的对这个简单的 图遍历问题/图遍历算法 的 变种/改变。你可以使用或者修改我们的DFS/BFS的源代码:dfs_cc.cpp/bfs.cpp dfs_cc.java/bfs.java dfs_cc.py/bfs.py dfs_cc.ml/bfs.ml

14-3. 讨论

[This is a hidden slide]