同样是遍历图,为什么BFS能保证找到最短路径,DFS却不行

「算法与数据结构」系列 Day07

写在前面

图的遍历只有两种基本套路:广度优先(BFS)和深度优先(DFS)。两者要完成的任务是一样的------从起点出发,不重不漏地访问所有能到达的节点,代码结构也几乎是镜像对称的,一个用队列,一个用栈(或者递归)。但就是这一个数据结构的差异,让两者的适用场景彻底分了家:BFS能保证第一次找到某个节点时,走的就是从起点到它的最短路径;DFS完全不能保证这一点,但它天生适合探索图的结构性问题。这篇就讲清楚这个差异是怎么从"队列还是栈"这一个选择里长出来的。


一、是什么:一个借助队列,一个借助栈

图的遍历要解决的问题很朴素:从一个起点出发,把所有能到达的节点都访问一遍,每个只访问一次。

**BFS(广度优先)**借助队列:起点先入队,每次从队头取出一个节点访问,把它还没访问过的邻居依次加入队尾。这样处理顺序天然是"先处理早入队的"------起点的邻居们先入队,等它们都被处理完,才会轮到它们的邻居(也就是离起点更远一层的节点)入队。整个过程像水波纹一样,一圈一圈往外扩散。

**DFS(深度优先)**借助栈,更常见的写法是用递归(函数调用栈本质上就是一个栈):从起点出发,访问一个还没访问过的邻居就立刻递归扎进去,一路走到不能再往下走(所有邻居都访问过了)才回溯到上一层,换一条路径继续走。

用源码里同一张图来看两者的遍历结果差异:

flowchart LR A --- B A --- C B --- D B --- E C --- F

从A出发,BFS的访问顺序是A B C D E F------严格按照"离A的距离"从近到远:先是距离1的B、C,再是距离2的D、E、F。DFS的访问顺序是A B D E C F------从A扎进B,再一路扎进B的邻居D,D没有别的邻居了就回溯,接着访问B的另一个邻居E,B这条支线走完了才回溯到A,换一条支线访问C、F。


二、为什么:队列先进先出决定了"逐层扩散",栈后进先出决定了"一路到底"

两者行为的根本差异,来自队列和栈两种数据结构截然相反的取出顺序:队列先进先出,栈(或递归调用栈)后进先出。

队列先进先出意味着:起点的所有邻居会先于它们自己的邻居被处理------因为它们更早入队。这个性质保证了BFS的访问顺序严格按照"离起点的距离"由近到远推进,绝不会出现"跳过近的先处理远的"这种情况。这正是BFS能保证第一次到达某个节点时走的就是最短路径的根本原因:如果存在一条更短的路径能到达这个节点,那条更短路径上的节点一定入队更早,会被更早处理到,不可能出现"抵达同一个节点,却是绕远路的那次先被发现"的情况。

栈后进先出意味着:一旦发现一个新邻居,会立刻优先处理它,而不是排队等着------这就是"深入"的由来。DFS完全不关心"这是不是离起点最近的路径",只关心"沿着当前这条路径能走多远",所以它可能会先绕一条很长的弯路才抵达某个节点,即使存在一条更短的路径。也正因为如此,DFS不能保证第一次到达某个节点时走的是最短路径。

但DFS的"一路到底再回溯"这个特性,恰好是探索图的结构性问题的天然工具:判断两个节点是否连通、检测图中有没有环、给有向无环图做拓扑排序、回溯法穷举所有可能路径(比如后面Day11要讲的N皇后问题),这些问题关心的是"路径本身的结构"而不是"最短距离",DFS递归回溯的写法反而更直接、更省代码。BFS则专攻"最短距离"相关的问题:无权图的最短路径、社交网络里"最少几度人脉能认识某人"、爬虫按层级抓取网页。


三、怎么用:代码实现 + 常见坑

BFS:入队时就要标记visited,而不是出队时

java 复制代码
public static void bfs(Map<String, List<String>> graph, String start) {
    Set<String> visited = new HashSet<>();
    Queue<String> queue = new LinkedList<>();

    visited.add(start);   // 入队前先标记
    queue.add(start);

    while (!queue.isEmpty()) {
        String node = queue.poll();
        System.out.print(node + " ");

        for (String neighbor : graph.get(node)) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor);   // 入队时立刻标记,不是出队时才标记
                queue.add(neighbor);
            }
        }
    }
}

最容易踩的坑是标记visited的时机 :必须在节点入队的那一刻就标记,而不是等它从队列里取出来、真正被访问时才标记。如果拖到出队才标记,同一个节点完全可能因为被两个不同的已访问节点同时指向,在还没被处理之前就被重复加入队列好几次------不仅浪费空间,在计算最短路径这类需要"第一次到达即最优"的场景里,还可能导致后续逻辑判断出错。

DFS:递归版简洁,但要留意栈溢出风险

java 复制代码
public static void dfs(Map<String, List<String>> graph, String node, Set<String> visited) {
    visited.add(node);
    System.out.print(node + " ");

    for (String neighbor : graph.get(node)) {
        if (!visited.contains(neighbor)) {
            dfs(graph, neighbor, visited);   // 递归访问邻居,一路深入
        }
    }
}

递归写法简洁直观,但递归深度跟着图的最长路径走 ,如果图是一条很长的链(比如几万个节点首尾相连),递归深度可能突破JVM默认的栈大小限制,抛出StackOverflowError。工程上处理规模不确定的图,通常会改成显式用Deque模拟栈的迭代版本,避免依赖调用栈深度。

另外,DFS和BFS的访问顺序都依赖于邻接表里邻居的排列顺序------如果graph.get("A")返回的是["C", "B"]而不是["B", "C"],DFS会先扎进C那条支线,最终访问顺序完全不同。这对DFS的影响比BFS更明显:BFS只是同一层内的访问顺序会变,DFS却可能因为先选了哪条支线,整个遍历路径的形状都不一样。

遍历方式 底层数据结构 时间复杂度 空间复杂度 能否保证最短路径(无权图) 更适合解决的问题
BFS 队列(先进先出) O(V+E) O(V) 能 最短路径、层级关系
DFS 栈/递归(后进先出) O(V+E) O(V) 不能 连通性判断、找环、拓扑排序、穷举路径

四、面试追问

Q1:为什么BFS能保证第一次到达某个节点时走的就是最短路径,DFS不行?

因为队列先进先出,起点的所有邻居会先于它们自己的邻居被处理,访问顺序严格按照离起点的距离由近到远推进------如果存在一条更短的路径能到达某个节点,这条路径上的节点必然更早入队、更早被处理,不可能出现绕远路的那次先被发现的情况。DFS用栈(或递归)后进先出,只关心沿当前路径能走多远,完全不管这是不是离起点最近的路径,所以没有这个保证。

Q2:如果用队列去实现"深度优先"式的遍历,或者用栈去实现"广度优先"式的遍历,会发生什么?

会呈现出跟原本相反的行为------决定BFS还是DFS特性的根本不是"叫队列还是叫栈"这个名字,而是"先进先出还是后进先出"这个存取顺序本身。如果拿一个栈来存储待访问节点、每次取"最后加入"的那个继续访问,得到的就是深度优先式的效果;反过来拿队列、每次取"最早加入"的那个,得到的就是广度优先式的效果。

Q3:BFS里为什么必须在节点入队时就标记visited,而不是等它出队时再标记?

因为一个节点可能被多个不同的已访问节点同时指向。如果标记推迟到出队才做,这个节点在真正被处理之前,可能已经因为被别的节点重复发现而多次入队,不仅浪费队列空间,在需要"第一次到达即为最优解"的场景(比如记录最短路径长度)里,还可能因为重复处理导致逻辑错误。

Q4:递归实现的DFS在什么情况下容易出问题?

当图的最长路径很深时(比如图退化成一条很长的链),递归调用的层数会跟着变深,可能超出JVM默认的调用栈大小限制,抛出StackOverflowError。这跟树的深度优先遍历面临的风险是同一类问题,处理规模不确定的图时,工程上通常会改用显式Deque模拟栈的迭代版本来规避这个风险。


下一篇预告

Day08 最短路径:Dijkstra算法的贪心本质。

相关推荐
无限码力2 小时前
2026华为OD机试真题 新系统 9月27日【坚果巧克力】
算法·华为od·华为od机考·华为od机试真题·华为od机试·华为od机考真题题解·华为社招笔试真题
samforce2 小时前
叹息之墙——人类认知 AI,终将遇到的一堵同构之墙
人工智能·深度学习·算法·哲学·认知科学
l1t3 小时前
编译测试兼容gzip并行压缩解压工具pigz
c++·算法
代码不停3 小时前
BFS算法解决FloodFill问题
算法·bfs·宽度优先
纪念 2293 小时前
C++算法(一)
开发语言·c++·算法
All for pursuit.3 小时前
【二叉树-10】114.二叉树展开为链表
数据结构·c++·算法·leetcode·链表
叠层归一研究院3 小时前
区分预算与通道诱导:Ω–L叠层体系的观测赋值与定量验证
人工智能·算法
BYSJMG4 小时前
计算机毕业设计选题推荐:基于大数据的快递物流运营数据分析与可视化,Spark与K-Means
大数据·算法·数据分析·spark·课程设计
阳明山水4 小时前
因果一致性正则化实战解析
人工智能·深度学习·算法·机器学习·架构