想象你站在一座巨大地下迷宫的入口,手里只有一个背包,宝物藏在某条通道尽头。通道交错纵横、岔路密布,你可能绕半天又回到原点。问题是:如何保证把每条该走的通道都探索一遍,还不迷路? 计算机科学给了两种截然不同的答案。第一种叫“一根筋探险家”:选一条通道走到底,走不通就原路退回,换下一个岔路再走到底,直到把所有角落摸清。这在图论里叫深度优先搜索(DFS)——遇到岔路先深入,实在不行才回头。第二种叫“水波搜索队”:你在入口倒下一桶水,水波均匀地朝所有方向扩散,一圈、两圈……水波最先漫过的地方,就是离你最近的出口。这在图论里叫广度优先搜索(BFS)——像投石入湖的涟漪。在计算机的世界里,“迷宫”叫图(Graph),“通道”叫边(Edge),“岔路口”叫顶点(Vertex)。DFS 和 BFS 是所有图算法中最基础的两把钥匙:社交推荐、地图导航、依赖检测,全都从它们出发。这篇文章,我们就跟着探险队和水波,把图遍历的来龙去脉走一遍。
图遍历是最短路、拓扑排序、连通分量等一切图算法的地基。读懂 DFS 与 BFS,你就能理解知识图谱如何推理、搜索引擎如何抓网页、地图 App 如何规划路线——这是 AI 从业者处理关系型数据的必备直觉。
可视化演示
图的深度优先搜索(DFS)和广度优先搜索(BFS)是图算法的基础,分别基于栈和队列实现。本文讲解DFS的递归与... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
DFS递归与:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现
DFS深度优先搜索
探险家背着一捆绳子和一盒粉笔进了迷宫。他的策略很“轴”:在每一个岔路口,用粉笔在走过的入口画个叉,然后永远优先选择没画叉的最深通道往前走。万一前方是死胡同,他就顺着绳子一步步退回上一个岔路口,再试下一条路。粉笔防止他重复走进同一条通道,绳子则代表“回溯”的依据——在代码里,这条绳子就是栈,而粉笔就是 visited 访问标记。
这段“走到底再回头”的过程,就是深度优先搜索(DFS)。给定一个起点顶点,DFS 沿着一条路径尽可能深地访问,直到这条路上没有未访问的邻居,再回溯到最近的分叉点继续。实现上,递归调用天然就是“栈”:函数调用栈帮你保存了每一层的回溯现场;你也可以用显式栈把递归改写成循环,原理完全一致——后进先出,后访问的邻居先被深入。
在无向图中,DFS 的递归式可以写成:访问当前顶点,再递归访问它的每一个未访问邻居。
所以 DFS 的“一根筋”性格来自递归 + 栈:一路深入,无路可退时把绳子收回来。面试和工程中,显式栈版本能避免递归过深导致栈溢出,但核心思想不变:能深入就深入,深入不了就回头。
- 访问当前顶点把顶点 v 标记为已访问,并加入遍历序列。
- 取出邻居集合N(v) 是 v 的所有相邻顶点。
- 筛选未访问邻居只挑那些没有被粉笔标记过的邻居参与递归。
- 逐个递归深入对每个未访问邻居调用 DFS,也就是走向下一段通道。
BFS广度优先搜索
假设探险家换了一种策略:他不再自己走,而是在入口倒下一桶水,水波会同时朝所有方向漫延。第一圈的波纹覆盖所有紧挨着入口的岔路口,第二圈覆盖岔路口的岔路口……波纹扩散得越远,走过的步数越多。这种“一圈圈往外推”的方式,就是广度优先搜索(BFS)。
BFS 的核心是队列:先进先出(FIFO),保证先发现的顶点先被扩展。过程像流水线:从起点出发,把起点所有未访问邻居全部“入队”,然后一个一个出队,每出队一个顶点,又把它的未访问邻居继续入队。这样一来,第 1 层的顶点会在第 2 层之前全部出队处理,天然形成“逐层扫描”。
如果我们用 dist 数组记录“起点到每个顶点的层数”,那么当顶点 v 出队、首次发现邻居 w 时,w 的层数一定比 v 多 1:
BFS 的“水波”性格来自队列 + 逐层扩散:先见先处理,同层全部处理完才进入下一层。正因为如此,在无权图上,BFS 首次访问到某个顶点时,就一定走的是最短步数——水波最先到的,一定是最近的岸。
- 当前顶点出队从队列头部取出顶点 v,此时它是本层中的一个节点。
- 已知层数dist[v] 表示起点到 v 经过的边数。
- 邻居被首次发现w 是 v 的邻居,且此前从未入过队。
- 层数加一w 比 v 晚一层被水波覆盖。
复杂度分析
探险队出发前要估算体力。走完整张图需要多少时间、多少背包空间,取决于你手里拿的是什么样的“地图”。邻接表像一张手绘通道清单:每个岔路口只记下直接相连的通道;邻接矩阵则像一张巨大的全景照片:所有点对之间“有无通道”都清清楚楚。
用邻接表存储时,DFS 和 BFS 的时间开销都很省:每个顶点被访问一次,每条边被检查一次(无向图中每条边会被两条邻接表记录各看一次,但常数倍可忽略),所以总时间是顶点数加边数。空间上,递归栈或队列在最坏情况下可能装下所有顶点。
如果换成邻接矩阵,检查一个顶点的所有邻居必须扫描一整行(V 个格子),所有顶点加起来就是 O(V²)。所以对稀疏图(边少),邻接表完胜;对稠密图,邻接矩阵的 O(V²) 也未必差——毕竟边数本身就有 V² 的量级。选哪种存储,就像选手绘清单还是全景照片,取决于迷宫的密度。
- 顶点总数V 是图中所有岔路口的数量。
- 边总数E 是所有通道的数量。
- 遍历每点每边每个顶点入队/入栈一次,每条边被扫描一次。
- 空间上限栈、队列和标记数组最坏要存下所有顶点。
连通分量
地下迷宫往往不止一个洞窟:有些通道彼此相连成一片,但和另一片之间完全隔绝。在无向图中,这些“互不相通却内部连成一片”的顶点集团,就叫连通分量(Connected Component)。整个图被分成几块,就有几个连通分量。
怎么数清楚迷宫有几座“孤岛”?办法很简单:把所有顶点挨个询问一遍——如果某个顶点还没有被粉笔标记过,就说明它属于一座还没探索过的洞窟,于是从它启动一次 DFS 或 BFS,把这座洞窟里的所有顶点全部标记;计数加一。重复直到所有顶点都被标记过。
这个思想在图像处理里被称作洪水填充(Flood Fill):从某个像素点出发,像倒水一样把颜色相同的连通区域全部染成新颜色——Photoshop 的魔棒工具就是这么识别一块区域的。对 AI 从业者来说,连通分量也是图聚类、社群发现最朴素的雏形:把社交网络分成互不交流的“朋友圈”,本质就是找连通分量。
- 逐个检查顶点遍历所有岔路口 v。
- 判断是否已标记如果 v 从未被访问,说明它所在洞窟还没被探索。
- 启动一次完整遍历从 v 出发做 DFS/BFS,把同属一个洞窟的顶点全部标记。
- 分量计数加一每启动一次,连通分量数加 1。
环检测应用
迷宫里最怕遇到“回环通道”:你走了一圈,发现自己又回到了粉笔标记过的地方。在图中,这就是环(Cycle)。如果一张图里存在环,某些“依赖”关系就无法成立——比如课程 A 依赖 B,B 依赖 C,C 又依赖 A,那谁都没法先修。检测环,是拓扑排序和依赖解析的前提。
DFS 检测环有个优雅的“三色粉笔”技巧:白色表示完全没走过,灰色表示正在走但还没走完(还在当前探索栈中),黑色表示已经彻底探索完毕。当 DFS 在深入过程中碰到一条边,指向一个灰色顶点时,说明你绕了一圈回到了当前路径上的祖先——这就是环。
在 AI 工程中,环检测最常见的舞台是有向无环图(DAG)校验:机器学习流水线、任务调度框架(如 Airflow)都会先用 DFS 三色标记检查依赖图是否含环。一旦发现环,立刻报警,否则任务永远排不出执行顺序。
- 白色未访问顶点还没被任何探险队碰到。
- 灰色在栈中顶点已被访问,但它的邻居还没全部探索完,仍留在递归栈里。
- 黑色结束回溯该顶点的所有邻居都探索完,递归已返回。
- 灰边判环当前顶点 u 有一条边指向灰色顶点 v,说明 u 能绕回自己的祖先。
最短路径应用
水波搜索队最神奇的能力是:它天生知道最短路径。假定所有通道长度一样(无权图),我们从起点倒水,波纹到达某个岔路口的“圈数”,就是从起点到这个岔路口的最少步数。这个性质来自 BFS 的逐层扩散——第 k 层的顶点,最短距离恰好是 k。
算法上,我们用一个 dist 数组记录起点到每个顶点的距离,初始时起点为 0,其他顶点为无穷大。BFS 过程中,每当从队列中取出顶点 v、第一次发现邻居 w 时,就设置 dist[w] = dist[v] + 1。因为 BFS 保证顶点按层数从小到大出队,所以这个“第一次赋值”就是最短距离。
如果想还原整条路径,只需在设置 dist[w] 时顺手记下“前驱” prev[w] = v,最后从终点沿着 prev 一路回溯到起点,就得到完整路线。这在迷宫求解、电梯调度、社交“几度人脉”都是标准操作。注意:一旦通道有了不同长度(带权图),BFS 就不保证最短了,那要请出 Dijkstra 算法——那是后话。
- 真实最短距离δ(s, w) 表示从起点 s 到顶点 w 的最少边数。
- BFS 距离数组dist 数组记录 BFS 过程中各顶点首次被发现时的层数。
- 两者相等BFS 首次发现即走最短路径。
遍历选型对比
现在你背包里有了两种工具:探险家的绳子(DFS)和水波的扩散桶(BFS)。干活前先想清楚任务类型。如果问题是“有没有一条路径通往宝藏”“打印所有可能的路线”“检测有没有回环”,DFS 更顺手——它可以快速深入解空间,配合回溯枚举所有情况。如果问题是“从入口到出口最少几步”,BFS 是唯一正确答案——它逐层扩散,第一次碰到出口时就是最短步数。
工程上还有个常被忽略的差异:空间占用。DFS 的空间和路径深度成正比,在树形分叉多的图上很省;BFS 的空间和“当前层最宽处”成正比,如果图是“扇形展开”,BFS 队列可能瞬间膨胀。另外 DFS 的递归实现代码极短,但递归太深有栈溢出风险,显式栈可以化解;BFS 用队列实现,逻辑直观,没有递归深度问题。
剪枝策略也常让选择改变:DFS 配合可行性剪枝,能在巨大的解空间里快速排除死路(比如八皇后、数独);BFS 配合分支限界,则能在搜索树上按层剪掉次优分支。理解了 DFS 与 BFS 的底层性格,你就掌握了图算法世界的第一对“阴阳”。
- 任务分类先判断问题核心是“求最短/分层”还是“求可行解/探底”。
- 选 BFS 的情形无权最短路、层序遍历、波纹式扩散。
- 选 DFS 的情形路径存在性、深度优先枚举、回溯剪枝搜索。
AI 应用场景
知识图谱中的实体可达性查询
阶段:数据预处理与图结构分析
解决的问题:知识图谱(如 Google Knowledge Graph)中,“A 是否间接关联到 B”是高频查询。传统 SQL 递归查询在深层次关联上性能差、写法复杂。用 DFS 从实体 A 出发深度遍历,遇到 B 即返回,天然适配“可达性”语义。
具体案例:在电商知识图谱中,判断“用户浏览的球鞋品牌”是否与“某环保材料供应商”存在供应链关联。用 DFS 在商品—品牌—材料—供应商的多跳路径中搜索,命中即返回路径,支持反欺诈和合规审查。
社交网络的好友推荐与分层
阶段:特征工程与图算法应用
解决的问题:社交 App 要推荐“你可能认识的人”,本质是找你二度人脉。BFS 从当前用户出发扩散两层,把第一层邻居排除,第二层邻居按共友数排序即可。相比暴力全图计算,BFS 只扩两层,计算量小一个量级。
具体案例:在一个百万级 DAU 的社区产品中,后端用图数据库执行 BFS 层序遍历:第 1 层是直接好友,第 2 层是“好友的好友”,按交集规模生成推荐列表。这个方案比离线全量计算快 3 倍,且天然支持实时更新。
机器学习中的依赖环检测
阶段:模型训练流水线治理
解决的问题:机器学习平台用有向无环图(DAG)描述“数据清洗→特征工程→训练→评估”的依赖关系。若配置人员不小心把依赖写成环,任务会永远无法调度。用 DFS 三色标记法在提交 DAG 时做一次环检测,违规直接报错。
具体案例:某公司的特征平台每天有上千个特征任务依赖。平台在发布前对 DAG 执行 DFS 环检测,一旦发现“特征 A 依赖 B、B 依赖 A”的循环依赖,立即拒绝发布并提示具体环节。上线后调度死锁事件下降了 90%。
Python 演示
from collections import deque def build_graph(edges): graph = {} for u, v in edges: graph.setdefault(u, []).append(v) graph.setdefault(v, []).append(u) return graph def dfs_recursive(graph, start, visited=None, order=None): if visited is None: visited = set() order = [] visited.add(start) order.append(start) for neighbor in graph.get(start, []): if neighbor not in visited: dfs_recursive(graph, neighbor, visited, order) return order def dfs_stack(graph, start): visited = set() stack = [start] order = [] while stack: node = stack.pop() if node in visited: continue visited.add(node) order.append(node) for neighbor in reversed(graph.get(node, [])): if neighbor not in visited: stack.append(neighbor) return order def bfs_queue(graph, start): visited = {start} queue = deque([start]) order = [] while queue: node = queue.popleft() order.append(node) for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) queue.append(neighbor) return order def count_components(graph): visited = set() count = 0 for node in graph: if node not in visited: dfs_recursive(graph, node, visited, []) count += 1 return count def has_cycle(graph): state = {} def dfs(v): state[v] = 1 for u in graph.get(v, []): if u not in state: if dfs(u): return True elif state[u] == 1: return True state[v] = 2 return False for v in graph: if v not in state: if dfs(v): return True return False if __name__ == "__main__": edges = [(0, 1), (0, 2), (1, 3), (1, 4), (2, 5)] graph = build_graph(edges) print("DFS recursive:", dfs_recursive(graph, 0)) print("DFS with stack:", dfs_stack(graph, 0)) print("BFS order:", bfs_queue(graph, 0)) print("components:", count_components(graph)) print("has cycle:", has_cycle(graph))
def build_graph(edges):把边列表转成邻接表,方便按顶点取邻居。visited.add(start)相当于探险家在岔路口用粉笔画叉,防止重复进入。for neighbor in graph.get(start, []):逐个取出当前顶点的邻居通道。if neighbor not in visited:只走没被标记过的通道。node = stack.pop()显式栈后进先出,模拟递归的“回溯现场”。for neighbor in reversed(graph.get(node, [])):逆序入栈,让访问顺序与递归版一致。queue = deque([start])队列初始化,起点先入队。node = queue.popleft()从队头取出顶点,先进先出保证逐层扩散。count += 1每启动一次完整遍历,就多一座“洞窟”(连通分量)。state[v] = 1DFS 环检测中把顶点标记为灰色,表示正在递归栈中。
常见误区
误区“树遍历也不需要 visited,图不就是多几根线吗?我直接递归不就行了?”
正解树天然没有环,从根出发不会走回祖先;但图可能带环,如果不做访问标记,DFS/BFS 会在环里无限循环,就像探险家在回环通道里绕圈永远出不来。visited 标记(粉笔)是图遍历和树遍历的本质区别。
为何常见:初学者先学二叉树遍历,习惯了“自上而下无重复”的思维,迁移到图上时忽略了“回边”的存在。
误区“BFS 能找到最短路径,那地图导航直接用 BFS 不就行了?”
正解BFS 保证最短的前提是无权图(所有边长度相同)。一旦边有权重(比如公路有长有短),BFS 只按“跳数”扩散,可能先到一条“跳数少但总里程长”的路。带权最短路要交给 Dijkstra 或 A* 算法。
为何常见:教材通常用无权图讲 BFS 最短路,“首访即最短”的印象太深刻,忽略了权重这个隐藏前提。
误区“图太大了递归会爆栈,所以 DFS 在大图上根本不能用。”
正解递归只是 DFS 的一种实现形式,函数调用栈帮你保存回溯现场。你完全可以用显式栈(自己管理一个栈数据结构)改写为迭代版本,空间开销可控,还能避免系统栈溢出。正文 Python 演示中的 dfs_stack 就是现成例子。
为何常见:教材常以递归写法为主,很多人没有意识到“递归”与“栈”是一体两面,显式栈改写其实很机械。
误区“复杂度公式不是 O(V+E) 吗?不管邻接表还是邻接矩阵,都一样吧。”
正解O(V+E) 来自邻接表的存储特性——每条边在表里出现一次,扫描所有邻接表恰好 O(V+E)。邻接矩阵检查一个顶点的所有邻居必须扫一整行,V 个顶点就是 O(V²)。即使边很少,矩阵也要扫完全部 V² 个格子。
为何常见:公式记熟了,却没和“存储结构如何决定访问方式”联系起来,于是忽略了常数背后的结构性差异。
误区“我用 visited 标记,访问到已标记的节点不就说明有环吗?”
正解visited 只能防止死循环,不能判环。无向图中“遇到已访问邻居”不一定是环(可能只是回退到父节点);有向图中“遇到黑色已访问节点”也不是环,因为那条边可能是跨子树的横叉边。正确做法是三色标记:只有遇到灰色(仍在当前递归栈中)的顶点,才说明存在环。
为何常见:初学时把“访问过”当成唯一状态,没有意识到 DFS 递归过程中顶点其实有“访问中”和“访问完”两种不同阶段。
面试题
Q1. 深度优先搜索(DFS)遍历图时,底层依赖哪种数据结构来模拟“回溯”过程?
- A.队列 queue
- B.栈 stack← 正确答案
- C.最小堆 heap
- D.哈希表 hash
答案:B。DFS 的“走到底再回头”本质是后进先出——最后进入的节点最先被深入,这正是栈的特性。队列是 BFS 用的,堆和哈希表与回溯无关。就像探险家顺绳子原路退回,绳子就是栈。
Q2. 广度优先搜索(BFS)逐层扩散时,保证“先发现的顶点先被扩展”需要用到哪种结构?
- A.栈 stack
- B.队列 queue← 正确答案
- C.优先队列 priority queue
- D.链表 linked list
答案:B。BFS 要求按发现顺序处理顶点,先进先出的队列天然满足。栈会让后发现的先处理,变成 DFS;优先队列用于 Dijkstra 等带权场景;链表本身不提供先进先出的队列语义。
Q3. 在遍历带环图时,为什么必须设置 visited 访问标记?
- A.为了统计图中的顶点个数
- B.防止重复访问造成死循环← 正确答案
- C.为了让遍历结果按编号排序
- D.标记只是编程习惯,不影响正确性
答案:B。图中存在回边时,若不标记已访问顶点,DFS/BFS 会在环上无限绕圈。visited 相当于探险家的粉笔标记,保证每个岔路口只进一次。选项 A、C 都不是标记的目的,D 明显错误。
Q4. 用邻接表存储图时,从单一起点出发完成一次 DFS 或 BFS 的时间复杂度是?(V 为顶点数,E 为边数)
- A.O(V)
- B.O(E)
- C.O(V + E)← 正确答案
- D.O(V × E)
答案:C。邻接表遍历时每个顶点入栈/入队一次,每条边被扫描一次,总计 O(V+E)。此复杂度只适用于邻接表;若用邻接矩阵则需要 O(V²)。
Q5. 下列哪些场景适合使用 BFS 求解?
- A.无权图的最短路径← 正确答案
- B.社交网络“几度人脉”的层数计算← 正确答案
- C.记忆化回溯搜索所有可行解
- D.递归下降解析表达式
答案:AB。BFS 逐层扩散,天然适合无权图最短路和层数统计。选项 C、D 需要深度探索与回溯,属于 DFS 的典型场景。BFS 不擅长枚举所有解,因为它需要逐层保存大量状态。
Q6. 用三色标记法做环检测时,GRAY(灰色)状态表示什么?
- A.顶点从未被访问
- B.顶点已被访问,但其邻居尚未完全探索完,仍在递归栈中← 正确答案
- C.顶点已完全探索完毕并回溯
- D.顶点已被删除
答案:B。灰色表示 DFS 正在处理该顶点的子树,它仍留在递归栈中。若当前节点发现一条指向灰色节点的边,说明绕回了祖先,存在环。A 是白色,C 是黑色。
Q7. 一个无向图被分为 3 个连通分量,若用“逐点检查+启动遍历”的方法数分量,至少需要启动几次遍历?
- A.1 次
- B.2 次
- C.3 次← 正确答案
- D.取决于边的数量
答案:C。每个连通分量内部互相可达,但分量之间不相连。从任意一个未访问顶点启动遍历,只能覆盖它所在的那一个分量;要覆盖 3 个分量,必须分别在每个分量里启动一次,共 3 次。
Q8. 用显式栈将递归 DFS 改写为迭代版本,下列说法正确的有?
- A.入栈时标记 visited 可避免同一顶点重复入栈← 正确答案
- B.若要保留与原递归一致的访问顺序,通常需要把邻接点逆序压栈← 正确答案
- C.出栈时标记 visited 可能导致某些顶点被多次压入栈中← 正确答案
- D.改成迭代后不再需要回溯思想
答案:ABC。迭代 DFS 仍需维护“回溯现场”,只是从系统调用栈换成了显式栈,D 错。入栈即标记能防止重复入栈;由于栈后进先出,逆序压栈才能恢复递归版的邻居访问顺序;若等到出栈才标记,同一顶点的多条边可能把它重复压入栈中。
Q9. 关于 DFS 与 BFS 的对比,正确的有?
- A.BFS 在无权图上首次到达某顶点时即为最短路径← 正确答案
- B.DFS 适合在解空间中深度探索“找一条可行路径”← 正确答案
- C.BFS 的空间复杂度一定低于 DFS
- D.两者若不加 visited 标记,在带环图上都可能死循环← 正确答案
答案:ABD。BFS 逐层扩散,首访即最短,A 对;DFS 深入到底回溯,适合解空间搜索,B 对;visited 对两者都必需,D 对。C 错:BFS 空间取决于“队列最宽处”,DFS 取决于“路径深度”,没有绝对高低。
Q10. 对 n 个顶点的完全图(每对顶点之间都有边)用邻接矩阵存储,执行一次 BFS 的时间复杂度是?
- A.O(n)
- B.O(n²)← 正确答案
- C.O(n³)
- D.O(n log n)
答案:B。完全图的边数本就是 O(n²),若用邻接表,BFS 为 O(V+E)=O(n+n²)=O(n²);但题中指定邻接矩阵,遍历每个顶点的邻居都必须扫描一整行(n 格),n 个顶点共扫 n² 格。两种角度都指向 O(n²)。容易误选 C,是因为把“每个邻居处理一次”错算成“每个顶点扫描全矩阵”。