本站内容由 AI 生成,可能存在错误。如发现问题,欢迎到 GitHub Issues 反馈。

BFS 与 DFS:图的两种基本呼吸方式

BFS 与 DFS:图的两种基本呼吸方式

更新于 2026-04-24

上一篇全景图建立了整条路径的概念地图——图能建模什么、有哪些核心问题、用什么范式去解。现在我们正式进入 Part 1”探”的第一站:系统地遍历一张图

想象你拿到一张全新的地图,第一件事不是量距离或找最短路,而是——走遍所有地方,搞清楚哪里连着哪里。在图论中,这个”走遍所有地方”的操作就是图遍历(graph traversal)。没有高效、系统的遍历手段,后续几乎所有图算法都无从谈起:最短路径需要逐层扩展(BFS),连通分量需要深入探索(DFS),拓扑排序需要后序时间戳(DFS),网络流需要找增广路(BFS)。BFS 和 DFS 不只是两个算法——它们是整个图算法大厦的地基。

算法范式:穷举搜索——系统地访问所有可能,不遗漏

核心问题:怎么系统地访问图的每个角落?

给定一张图 G=(V,E)G = (V, E)n=Vn = |V| 个顶点、m=Em = |E| 条边。从某个起点 ss 出发,我们想做到两件事:

  1. 可达性(reachability):找到从 ss 出发能到达的所有节点
  2. 系统性(completeness):保证不遗漏、不重复——每个可达节点恰好访问一次

如果图是连通的(从任意节点能到达任意其他节点),遍历就能访问所有 nn 个节点。如果不连通,对每个连通分量分别遍历即可。

这两件事听起来简单,但对于可能有数百万节点和数千万边的图,“随便走走”是不行的——你需要一个策略来决定下一步去哪。不同的策略产生了两种根本不同的遍历方式:

  • BFS(Breadth-First Search,广度优先搜索):先处理离起点近的,再处理远的——逐层扩散
  • DFS(Depth-First Search,深度优先搜索):一条路走到底,走不通再回头——递归深入

两种策略的时间复杂度相同(O(n+m)O(n + m)),但它们的扩展顺序完全不同,导致它们擅长的问题也不同。理解这个区别,是掌握图算法的第一步。

高层直觉:水波 vs 迷宫

在进入具体算法之前,先建立一个几何直觉:

BFS 像一颗石头投入水中 —— 从起点出发,先到达距离为 1 的所有邻居,再到达距离为 2 的所有邻居……一圈一圈向外扩散。每个节点被发现时,它的”层数”就是离起点的最短跳数。

BFS 层序扩散:从起点一圈一圈向外扩展BFS 层序扩散 — 像水波一样一圈圈扩展s1.11.21.32.12.22.32.43.13.23.3起点 d=0第 1 层 d=1第 2 层 d=2第 3 层 d=3核心数据结构:队列先进先出 (FIFO)保证逐层处理→ 最短跳数每个节点的层数 = 离起点的最短跳数

DFS 像在迷宫中探路 —— 沿着一条路一直走到死胡同(没有未访问的邻居),然后回头到最近的岔路口,换一条路继续深入。这种”先深后广”的策略天然地产生递归结构。

DFS 递归深入:走到底再回头DFS 递归深入 — 走到死胡同再回头回溯回溯s#1A#2B#3C#4D#5E#6F#7G#8核心数据结构:栈后进先出 (LIFO) / 递归调用栈总是深入当前路径→ 发现回路 / 拓扑排序深度 →DFS 顺序 = 深入一条路走到底,然后回溯尝试下一条路

两种策略的关键区别在于数据结构

  • BFS 用队列(FIFO,先进先出):先发现的先处理 → 保证逐层
  • DFS 用(LIFO,后进先出)或等价的递归调用栈:后发现的先处理 → 保证深入

BFS 核心:队列、层序、最短跳数

算法流程

BFS 的核心思想可以用一句话概括:把起点放入队列,然后不断从队列取出节点、将其未访问的邻居加入队列,直到队列为空。

详细步骤:

  1. 初始化:创建一个队列 QQ,将起点 ss 入队,标记 ss 为”已发现”
  2. 循环:当 QQ 非空时:
    • QQ 头部取出一个节点 uu(出队)
    • uu 的每个邻居 vN(u)v \in N(u)
      • 如果 vv 未被发现:标记 vv 为”已发现”,记录 parent[v]=u\text{parent}[v] = u,将 vv 入队
  3. 结束:队列为空,所有从 ss 可达的节点都已访问

用伪代码表示:

BFS(G, s):
    for each v ∈ V:
        visited[v] = false
    visited[s] = true
    Q = empty queue
    Q.enqueue(s)
    while Q is not empty:
        u = Q.dequeue()
        for each v ∈ N(u):
            if not visited[v]:
                visited[v] = true
                parent[v] = u
                Q.enqueue(v)

为什么 BFS 能给出最短跳数?

这是 BFS 最重要的性质,值得仔细理解。

定理(BFS 最短路径性质,CLRS Theorem 20.5):对于无权图,BFS 从源点 ss 到任意可达节点 vv 的路径是最短路径(以边数计)。即 d(s,v)=d(s, v) = BFS 中 vv 的层数。

直觉证明

  • BFS 的队列保证了”先发现的先处理”——距离为 kk 的节点全部处理完后,才开始处理距离为 k+1k+1 的节点
  • 一个节点被首次发现时,发现它的那条路径一定是最短的(因为更短的路径意味着更早的层,而更早的层已经处理完了)
  • 所以每个节点的 parent\text{parent} 指针追溯回 ss,就是最短路径

走查示例

下面的交互组件展示了 BFS 和 DFS 在同一张 7 节点图上的完整执行过程。你可以切换 BFS/DFS 模式,逐步观察节点状态和队列/栈的变化。

BFS vs DFS 走查动画
ABCDEFG未访问已发现当前处理已完成队列:(空)
步骤 1/32: 初始状态:所有节点未访问。选择 A 作为起点。
1 / 32

仔细观察 BFS 的走查过程:

  • 第 0 层只有起点 A
  • 第 1 层包含 A 的所有邻居(B、C、G)——它们同时被加入队列
  • 第 2 层是 B 和 C 的邻居中尚未访问的(D、E)
  • 第 3 层是 D 和 E 的邻居中尚未访问的(F)
  • 每个节点被发现的层数就是它到 A 的最短跳数

复杂度分析

  • 时间复杂度O(n+m)O(n + m)(最坏情况)
    • 每个节点入队和出队各一次 → O(n)O(n)
    • 每条边被检查最多两次(无向图中 (u,v)(u,v)(v,u)(v,u))→ O(m)O(m)
  • 空间复杂度O(n)O(n)
    • 队列最多存储一整层的节点。最坏情况:星形图中队列大小为 n1n-1

DFS 核心:栈/递归、时间戳、发现/结束时间

算法流程

DFS 的核心思想:一条路走到底,走不通再回头。 具体来说:从起点出发,每次选一个未访问的邻居深入,直到没有未访问的邻居(死胡同),然后回溯(backtrack) 到上一个节点,尝试其他未访问的邻居。

DFS 有两种等价实现:

递归版(更自然)

DFS(G, s):
    for each v ∈ V:
        visited[v] = false
    DFS-Visit(s)

DFS-Visit(u):
    visited[u] = true
    time = time + 1
    d[u] = time           // 发现时间
    for each v ∈ N(u):
        if not visited[v]:
            parent[v] = u
            DFS-Visit(v)   // 递归深入
    time = time + 1
    f[u] = time           // 结束时间

显式栈版

DFS-Iterative(G, s):
    for each v ∈ V:
        visited[v] = false
    S = empty stack
    S.push(s)
    while S is not empty:
        u = S.pop()
        if not visited[u]:
            visited[u] = true
            for each v ∈ N(u) (reverse order):
                if not visited[v]:
                    S.push(v)

递归版和栈版在访问顺序上有细微差异(因为栈是后进先出,需要反向压入邻居),但核心思想一致。本文后续的分析基于递归版,因为它的时间戳语义更清晰。

时间戳:发现时间与结束时间

DFS 有一个 BFS 没有的独特武器:时间戳

对于每个节点 vv,DFS 记录两个时间:

  • 发现时间 d(v)d(v)(discover time):DFS 第一次访问到 vv 的时刻
  • 结束时间 f(v)f(v)(finish time):vv 的所有后代都处理完、DFS 从 vv 回溯的时刻

时间从 1 开始递增,每次发现或结束一个节点时 +1。所以对一个 nn 节点的图,所有时间戳的范围是 [1,2n][1, 2n]

DFS 发现时间与结束时间DFS 时间戳:发现 (d) 与结束 (f) 时间括号定理:对任意两个节点 u, v,区间 [d(u), f(u)] 和 [d(v), f(v)] 要么嵌套要么不相交1234567891011时间A.dB.dD.dD.fE.dE.fB.fC.dC.fA.f活跃区间A[1, 10]B[2, 7]C[8, 9]D[3, 4]E[5, 6]

括号定理

时间戳有一个优美的结构性质:

括号定理(Parenthesis Theorem, CLRS Theorem 20.7):对于 DFS 树中的任意两个节点 uuvv,它们的活跃区间 [d(u),f(u)][d(u), f(u)][d(v),f(v)][d(v), f(v)] 恰好满足以下三种关系之一:

  1. 完全嵌套[d(u),f(u)][d(v),f(v)][d(u), f(u)] \subset [d(v), f(v)] —— uuvv 的后代
  2. 完全嵌套(反向)[d(v),f(v)][d(u),f(u)][d(v), f(v)] \subset [d(u), f(u)] —— vvuu 的后代
  3. 完全不相交:两个区间没有交集 —— uuvv 不在同一条祖先-后代链上

不存在”部分重叠”的情况。 就像合法的括号序列 (()()) 中,任意两对括号要么嵌套要么不相交,不会出现 (() 这样交叉的情况。

这个性质是 DFS 在连通性分析(SCC、桥、割点)中的核心工具,将在 Art. 2: 连通性 中详细展开。

复杂度分析

  • 时间复杂度O(n+m)O(n + m)(最坏情况),与 BFS 相同
    • 每个节点被 DFS-Visit 调用恰好一次 → O(n)O(n)
    • 每条边在邻居遍历中被检查最多两次 → O(m)O(m)
  • 空间复杂度O(n)O(n)(最坏情况)
    • 递归版:调用栈深度最坏为 nn(路径图)
    • 显式栈版:栈大小最坏为 nn

边分类:DFS 的结构分析工具

DFS 的真正威力不在于遍历本身,而在于它在遍历过程中对每条边的分类。对于有向图,DFS 将所有边分为四种类型:

DFS 边分类:树边、回边、前向边、横跨边DFS 边分类 — 四种边揭示图的结构回边前向边横跨边A1/10B2/9C5/8D6/7E3/4标注: 发现时间/结束时间边分类判定规则树边 (Tree)DFS 发现新节点回边 (Back)指向祖先 → 有环!前向边 (Forward)指向后代(非树边)横跨边 (Cross)指向已完成的非祖先/后代关键洞察:有向图存在 back edge ⟺ 图有环。这是 DFS 最重要的应用之一。

四种边类型

设 DFS 正在处理节点 uu,检查边 (u,v)(u, v)

  1. 树边(Tree Edge)vv 尚未被发现(visited[v]=false\text{visited}[v] = \text{false})。DFS 沿这条边深入 vv。所有树边构成一棵 DFS 树(或 DFS 森林)。

  2. 回边(Back Edge)vv 已被发现但未结束(d(v)<d(u)d(v) < d(u)f(v)f(v) 尚未设定)——即 vvuu 在 DFS 树中的祖先。这条边”回”到了祖先节点。

  3. 前向边(Forward Edge)vv 已经结束(f(v)f(v) 已设定),且 d(u)<d(v)d(u) < d(v)——即 vvuu 的后代,但这条边不是树边(uuvv 在 DFS 树中已经有一条树边路径)。

  4. 横跨边(Cross Edge)vv 已经结束(f(v)f(v) 已设定),且 d(u)>d(v)d(u) > d(v)——即 vv 不是 uu 的祖先也不是后代,它在另一棵 DFS 子树中。

判定方法

用时间戳可以精确判定:

(u,v)(u, v) 类型条件
树边vv 未发现
回边d(v)<d(u)d(v) < d(u)vv 未结束
前向边d(v)>d(u)d(v) > d(u)vv 已结束
横跨边d(v)<d(u)d(v) < d(u)vv 已结束

关键洞察

  • 有向图有环 \Longleftrightarrow DFS 发现了 back edge。 这是环检测最经典的方法。
  • 无向图中只有 tree edge 和 back edge(无 forward 和 cross)——因为如果存在一条边 (u,v)(u, v),在无向图中 DFS 访问 uu 时一定会检查 vv,要么 vv 未访问(tree edge),要么 vv 是祖先(back edge)。
  • DAG(有向无环图)的 DFS 不产生 back edge —— 这正是判定 DAG 的方法。

BFS 应用模式

BFS 的”逐层扩散”特性催生了一系列应用模式。这里简述每种模式的核心思想,详细展开留给后续专题文章。

BFS 应用模式BFS 应用模式 — 层序扩散的五种用法共同本质:从起点出发,逐层向外扩展Flood Fill油漆桶 / 扫雷 / 连通区域1多源 BFS多起点同时扩散20123无权最短路最少跳数 / 六度分隔3二部图判定奇偶层着色4网络广播洪泛/广播协议5核心共性:BFS 保证按距离远近逐层处理 → 天然的"最短/最近"语义每种模式的详细展开见后续专题文章

1. Flood Fill — 连通区域标记

场景:图像处理中的”油漆桶”工具、扫雷的空白区域展开、地图中的连通区域标记。

做法:从一个种子像素/节点出发,BFS 扩展到所有满足条件的相邻节点(如颜色相同),标记为同一区域。本质上就是 BFS 求连通分量。

复杂度O(n+m)O(n + m),其中 nn 是区域内节点数,mm 是区域内边数。

2. 多源 BFS — 多起点同时扩散

场景:LeetCode 994 “腐烂的橘子”——多个腐烂源同时向四周扩散;“多个消防站同时响应,求最远点的最近消防站距离”。

做法:将所有源节点同时加入 BFS 的初始队列,然后正常 BFS。效果等价于添加一个虚拟超级源 ss' 连接所有真实源节点,然后从 ss' 做单源 BFS。

复杂度:仍然是 O(n+m)O(n + m),不比单源 BFS 更贵。

3. 无权最短路 — 最少跳数

场景:社交网络中两人之间的”六度分隔”(最少中间人数)、网络中两台主机间的最少跳数。

做法:直接从源节点 BFS,层数就是最短跳数。parent\text{parent} 数组可以回溯出具体路径。

复杂度O(n+m)O(n + m)。这是无权图最短路的最优算法。

4. 二部图判定 — 奇偶层着色

场景:判断一个图是否是二部图(bipartite)。等价于判断图能否用两种颜色着色,使得每条边的两端颜色不同。

做法:BFS 时给每层交替标记颜色(偶数层红色,奇数层蓝色)。如果某条边的两端颜色相同(同层),则不是二部图。

关键洞察:图是二部图 \Longleftrightarrow 图不含奇数长度的环。BFS 的层序结构天然地提供了这个检测。

5. 网络洪泛/广播

场景:计算机网络中的广播协议——一条消息从一台主机发出,需要到达所有其他主机。

做法:本质上就是 BFS,每个节点收到消息后转发给所有邻居。BFS 的层序保证了消息以最少跳数到达每个节点。

DFS 应用模式

DFS 的”深入回溯”特性和时间戳结构催生了另一套应用模式。

DFS 应用模式DFS 应用模式 — 深入回溯的六种用法共同本质:沿一条路走到底,回溯时收集信息1环检测back edge → 有环2拓扑排序后序反转3回溯搜索排列/组合/N-Queen4SCC / 桥 / 割点low-link / Tarjan5路径枚举迷宫 / 所有路径6树遍历前序/中序/后序核心共性:DFS 的时间戳和回溯顺序蕴含丰富的结构信息模式 2-4 将在后续文章中展开(Art. 2, Art. 3)

1. 环检测 — back edge 意味着有环

场景:编译器检查头文件的循环依赖、任务调度检查循环等待(死锁检测)。

做法:对有向图做 DFS,如果遇到 back edge(指向当前路径上的祖先),则图有环。对无向图更简单——任何指向已访问且不是父节点的邻居,就是一个环。

核心有向图有环DFS 存在 back edge\text{有向图有环} \Longleftrightarrow \text{DFS 存在 back edge}

2. 拓扑排序引子 — 后序反转

场景:任务调度(先修课程排序)、编译顺序(依赖在前的先编译)。

做法:对 DAG 做 DFS,按照 f(v)f(v)(结束时间)的递减顺序排列节点,就是一个合法的拓扑排序。因为如果 (u,v)E(u, v) \in E,则 f(u)>f(v)f(u) > f(v)uuvv 前结束)。

详细展开Art. 3: 拓扑排序与 DAG

3. 强连通分量(SCC)引子 — Kosaraju / Tarjan

场景:社交网络中的互相关注群组、网页之间的相互可达性分析。

做法:Kosaraju 算法对图和反转图各做一次 DFS;Tarjan 算法用 DFS + low-link 值在一次遍历中找出所有 SCC。核心都依赖 DFS 的时间戳结构。

详细展开Art. 2: 连通性

场景:网络中的关键链路(断了就不连通)、关键路由器(坏了就分裂网络)。

做法:在 DFS 过程中计算每个节点的 low-link 值(通过 back edge 能到达的最早祖先)。如果一条边的终点无法通过 back edge “绕回”起点上方,这条边就是桥。

详细展开Art. 2: 连通性

5. 回溯搜索 — DFS 搜索解空间

场景:N 皇后问题、排列组合生成、数独求解、图着色。

做法:把所有可能的”选择序列”组织成一棵隐式搜索树,DFS 遍历这棵树。每个节点代表一个”部分解”,叶子代表完整解或不可行解。当发现当前路径不可能产生更优解时,立即回溯(剪枝)。

6. 路径枚举与迷宫求解

场景:找两点之间的所有路径、迷宫从入口到出口的路径。

做法:DFS 天然地沿着一条路走到底再回头,非常适合枚举所有路径。配合 visited 标记的”加入/移除”操作,可以实现回溯式路径枚举。

7. 树遍历 — 前序/中序/后序

场景:二叉树的三种经典遍历方式其实都是 DFS 的特例。

做法

  • 前序遍历:在 DFS-Visit(uu) 的开头处理 uu(发现时间对应前序位置)
  • 后序遍历:在 DFS-Visit(uu) 的末尾处理 uu(结束时间对应后序位置)
  • 中序遍历:在二叉树中,先递归左子树,然后处理 uu,再递归右子树

BFS vs DFS:什么时候用哪个?

这是最实用的问题。下面的对比表和经验法则帮助你做出选择:

BFS vs DFS 对比BFS vs DFS — 什么时候用哪个?BFSDFS数据结构队列 (FIFO)栈 / 递归 (LIFO)扩展策略逐层 — 先处理距离近的深入 — 先走到底再回头时间复杂度O(V + E)O(V + E)空间复杂度O(V) 最坏 O(宽度)O(V) 最坏 O(深度)最短路无权图最短路 ✓不保证 ✗环检测无向图可以有向图首选 (back edge)拓扑排序Kahn (入度法)后序反转连通分量可以可以 (SCC 首选)经验法则:需要"最短/最近" → BFS;需要"完整探索/时间戳/回溯" → DFS

经验法则

  1. 需要最短路径(跳数)→ BFS。BFS 的层序结构天然保证无权图最短路。DFS 不保证。

  2. 需要判断/利用环 → DFS。DFS 的 back edge 是检测有向图环的标准方法。BFS 也能检测无向图环,但 DFS 的时间戳提供了更丰富的结构信息。

  3. 需要拓扑排序 → DFS。DFS 的后序反转直接给出拓扑序。(BFS 也能做——Kahn 算法基于入度计数,但本质是不同的思路。)

  4. 需要连通性结构(SCC、桥、割点)→ DFS。这些全部依赖 DFS 的时间戳和 low-link 值。

  5. 需要按距离分层处理 → BFS。比如多源 BFS、二部图判定。

  6. 需要枚举所有路径/解空间 → DFS(回溯)。DFS 的递归结构天然适合回溯搜索。

  7. 空间受限、图很深 → 注意 DFS 可能栈溢出。对于非常深的图(如 10510^5 层的链),递归 DFS 可能导致栈溢出。这时可以用显式栈版 DFS,或者根据问题特点选择 BFS(BFS 的队列大小取决于”宽度”而非”深度”)。

复杂度对比表

维度BFSDFS
时间复杂度(最坏)O(V+E)O(V + E)O(V+E)O(V + E)
空间复杂度(最坏)O(V)O(V)(队列 + visited)O(V)O(V)(栈 + visited)
空间特征正比于图的”宽度”正比于图的”深度”
无权最短路保证不保证
环检测(有向图)不直接back edge
时间戳有(dd, ff
适用于最短路、层序、广播环检测、拓扑排序、SCC、回溯

为什么这是一切的基础

BFS 和 DFS 的重要性远超”两个遍历算法”。后续路径中大约 80% 的算法把它们作为核心子程序:

BFS/DFS 是后续 80% 图算法的子程序BFS / DFS图算法的通用基础设施最短路径Dijkstra / A*连通性SCC / Bridges拓扑排序DAG scheduling网络流Ford-Fulkerson匹配Hopcroft-Karp社区发现Label Propagation后续 80% 的图算法把 BFS 或 DFS 作为核心子程序

具体来说:

理解 BFS 和 DFS,就获得了理解这些高级算法的”通行证”。

🔁 迭代机器视角BFSEQ: 方程 / 不动点

Graph原图
State[v]visited (Boolean)
SELECTFIFO
COMBINE遍历邻接表 → 标记已发现
重入
TERMINATEFrontier 空
生命周期Unseen → Queued → Done
求解方法FI

→ 框架详解:Art. 0A — 图上的通用迭代机器

🔁 迭代机器视角DFSEQ: 方程 / 不动点

Graph原图
State[v]visited + 时间戳 (d, f)
SELECTLIFO
COMBINE遍历邻接表 → 标记已发现 + 时间戳
重入
TERMINATEFrontier 空
生命周期Unseen → Active → Done
求解方法FI

💡 对比: BFS: 同一框架,仅 SELECT 不同(FIFO vs LIFO)

→ 框架详解:Art. 0A — 图上的通用迭代机器

总结

本文建立了图算法最核心的基础设施——BFS 和 DFS:

  • BFS(队列/FIFO):逐层扩散,保证无权最短路,适合距离相关问题
  • DFS(栈/递归/LIFO):递归深入,产生时间戳(dd, ff),适合结构分析
  • 边分类:DFS 将有向图的边分为树边、回边、前向边、横跨边——back edge \Leftrightarrow 有环
  • 括号定理:DFS 时间区间要么嵌套要么不相交,是连通性分析的基础
  • 时空复杂度:两者均为 O(V+E)O(V + E) 时间、O(V)O(V) 空间
  • 选择法则:“最短/最近”→ BFS;“完整探索/时间戳/回溯”→ DFS

BFS 和 DFS 是图的两种”呼吸方式”——一种向外扩张,一种向内深入。掌握了它们,你就拥有了探索任意图结构的能力。

下一篇 Art. 2: 连通性 将展示 DFS 的时间戳和 low-link 如何成为分析图连通结构的利器——找出强连通分量、桥和割点。

实现指引

算法函数/方法
BFSNetworkXnx.bfs_edges(G, source), nx.bfs_tree(G, source)
DFSNetworkXnx.dfs_edges(G, source), nx.dfs_tree(G, source)
BFS 最短路径NetworkXnx.shortest_path(G, source, target) (无权图默认 BFS)
BFS 层序NetworkXnx.bfs_layers(G, source)
DFS 前序/后序NetworkXnx.dfs_preorder_nodes(G), nx.dfs_postorder_nodes(G)
BFSscipyscipy.sparse.csgraph.breadth_first_order(graph, i_start)
DFSscipyscipy.sparse.csgraph.depth_first_order(graph, i_start)
BFS/DFSigraphg.bfs(vid), g.dfs(vid)