DFS & BFS
1. Introduction
给定一个图,我们可以使用O(V +E )DFS(深度优先搜索)或BFS(广度优先搜索)算法来遍历该图并探索该图的特征/属性。每种算法都有自己的特点、特征和副作用,我们将在这个可视化中探讨。 这个可视化的内容很丰富,有很多DFS和BFS的变体(都以O(V +E )运行),比如:拓扑排序算法(包括DFS和BFS/Kahn的算法版本), 二分图检查器算法(包括DFS和BFS版本), 切割顶点和桥的查找算法, 强连通分量(SCC)查找算法 (Kosaraju的和Tarjan的版本), 以及2-SAT检查算法。
2. 可视化
当所选的图遍历算法运行时,将在次处显示动画。
我们使用节点 + 边颜色(颜色方案将很快阐述),偶尔使用节点下的额外的文本( 红色字体 )来突出显示更改。
所有的图遍历算法都适用于有向图(这是默认设置,其中每个边都有一个箭头指示其反向),但是 Bipartite Graph Check 算法和 Cut Vertex & Bridge 查找算法 需要无向图(通过这种可视化,转换是自动完成的)。
3. 指定一个输入图
There are two different sources for specifying an input graph:
Edit Graph : You can draw a new graph or edit an example unweighted directed graph as the input graph (to draw bidirectional edge (u, v), you can draw two directed edges u → v and v → u).Example Graphs : You can select from the list of our selected example graphs to get you started.
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?
Post = 1, 3, 4, 2, 0 Post = 4, 3, 2, 1, 0 Pre = 0, 2, 4, 3, 1 In = 4, 2, 3, 0, 1 In = 1, 0, 3, 2, 4 Pre = 0, 1, 2, 3, 4Submit
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. 比喻
DFS类比一个只有一个人口和一个出口的迷宫。您在 入口处,想要探索迷宫到达出口。显然你不能分身。
在继续之前,先思考这些反思性问题:如果您面前有分支选择,您会怎么做?如何避免进入循环?如何标记自己的路径?提示:你需要一只粉笔,石头(或任何其他标记物)和一根(长)线。
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遍历路径上每一个顶点 u 的 parent/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 ) ,因为:
每个节点只访问过一次,因为 DFS 将仅递归地探索节点 u 如果 status[u] = unvisited — O(V ) 每次访问完一个节点,都会探索其所有 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?
Submit 讨论:为什么?
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 ),因为:
每一个顶点都被访问一次 因为它们只能进入队列一次— O(V ) 每当一个顶点从队列中出队时,所有它的 k 个邻居都会被探索 所以当所有的顶点都被访问过后,我们一共探索了 E 条路径 — (O(E ) 因为每个顶点的邻居总数为 E ). 对于DFS来说 O(V +E ) 只有在用 邻接表 图数据结构 — 和DFS分析相同
7. 简单的 DFS/BFS 应用
到现在为止,我们可以用 DFS/BFS 去解决一些图的遍历问题变种:
检测可达性 显示出遍历路径 分辨/计数/标记 一个无向图的连通分量(CCs) 探测图是否有圈(cyclic) 拓补排序(只在有向无圈图 DAG中) 多数的数据结构和算法课程只传授这些 DFS/BFS 的基本应用,尽管它们可以做更多...
7-1. 可达性测试
如果您被要求测试图中的节点 s 和一个(不同的)节点 t 是否可达,即直接连接(通过一条边)或间接连接(通过简单的非环路径),则可以调用 O(V +E ) DFS(s) (或 BFS(s) ) 并检查是否 status[t] = visited。
例子 1:s = 0 和 t = 4 , 运行 DFS(0) 并注意 status[4] = visited . 例子 2: s = 0 和 t = 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 = 0 和 t = 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?
Submit 讨论:为什么?
7-6. 答案
[This is a hidden slide]
7-7. 检测圈 - 第一部分
为了深入我们对底层图的理解,我们可以 扩大 DFS算法的基础。
在这个动画里, 我们用 蓝色 来表示DFS生成树的 返回 边。 如果发现了至少一个返回边 , 那代表被遍历的图(部分)是圈 的,而如果没有返回边的话,那就代表从出发点开始的部分并不是圈的。
7-8. 探测圈 - 第2部分
为了侦测 返回边,我们可以通过修改 status[u] 来记录 三 种不同的状态:
未访问 : 和之前一样,DFS还没有访问过 u ,已探索 : DFS已经访问了 u , 但是至少有一个 u 的邻居还没有被探索 (DFS会先 深度优先 式的先去探索那个顶点的邻居),已访问 : 增强版的定义:顶点 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(加上轻微改动)可以解决的问题。有一些更高级的应用需要更多的改动,更先进的学生可以自己探索:检测二分图 (DFS 和 BFS 变种), 寻找无向图的衔接点(切顶)和桥梁(仅DFS), 寻找有向图的强连通分量(SCC)(Tarjan和Kosaraju的算法), 以及 2-SAT(可满足性) 检查算法. 广告:细节写在Competitive Programming book 一书中
9. 二分图检查
我们可以使用O(V +E )DFS或BFS(它们的工作原理相似)来检查一个给定的图是否是一个二分图(Bipartite Graph),方法是在相邻的顶点之间给出交替的颜色(在这个可视化中是橙色和蓝色),如果我们最终给两个相邻的顶点分配了相同的颜色,则报告 "非二分",如果有可能进行这样的 "二分上色"过程,则报告 "二分"。试试DFS_Checker 或BFS_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?
Submit 讨论:为什么?
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 - reachableroads 和 Kattis - 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]