「算法与数据结构」系列 Day07
写在前面
图的遍历只有两种基本套路:广度优先(BFS)和深度优先(DFS)。两者要完成的任务是一样的------从起点出发,不重不漏地访问所有能到达的节点,代码结构也几乎是镜像对称的,一个用队列,一个用栈(或者递归)。但就是这一个数据结构的差异,让两者的适用场景彻底分了家:BFS能保证第一次找到某个节点时,走的就是从起点到它的最短路径;DFS完全不能保证这一点,但它天生适合探索图的结构性问题。这篇就讲清楚这个差异是怎么从"队列还是栈"这一个选择里长出来的。
一、是什么:一个借助队列,一个借助栈
图的遍历要解决的问题很朴素:从一个起点出发,把所有能到达的节点都访问一遍,每个只访问一次。
**BFS(广度优先)**借助队列:起点先入队,每次从队头取出一个节点访问,把它还没访问过的邻居依次加入队尾。这样处理顺序天然是"先处理早入队的"------起点的邻居们先入队,等它们都被处理完,才会轮到它们的邻居(也就是离起点更远一层的节点)入队。整个过程像水波纹一样,一圈一圈往外扩散。
**DFS(深度优先)**借助栈,更常见的写法是用递归(函数调用栈本质上就是一个栈):从起点出发,访问一个还没访问过的邻居就立刻递归扎进去,一路走到不能再往下走(所有邻居都访问过了)才回溯到上一层,换一条路径继续走。
用源码里同一张图来看两者的遍历结果差异:
从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算法的贪心本质。