假设有一张社交关系图,A 认识 B,B 认识 C,A 也直接认识 C。现在要从 A 出发找到 C:
- 深度优先(DFS)会沿着一条路一直走到底:A → B → C,找到就停。
- 广度优先(BFS)会先看完 A 的所有直接朋友:B 和 C,一轮就命中。
同一张图,两种遍历给出了不同的路径和顺序。搞清它们的差别,决定了你在写「连通性判断」「无权图最短路」「拓扑排序」时选哪个。
一、先确定图的表示
遍历的效率几乎完全取决于图怎么存。
邻接表:graph[v] 存 v 的所有邻居。稀疏图首选,遍历所有边是 O(E)。
const graph: Record<number, number[]> = {
0: [1, 2],
1: [0, 3],
2: [0, 3],
3: [1, 2, 4],
4: [3],
};
邻接矩阵:matrix[i][j] = 1 表示有边。查两点是否相邻是 O(1),但要扫一整行是 O(V),总遍历成本 O(V²),只适合稠密图。
下面统一用邻接表,两者遍历的「骨架」是一样的。
二、深度优先遍历(DFS)
DFS 的思想是「一条路走到黑,走不动再回头」。递归写法最直观:
function dfs(
graph: Record<number, number[]>,
node: number,
visited: Set<number> = new Set(),
): void {
visited.add(node);
console.log(node); // 访问
for (const next of graph[node] ?? []) {
if (!visited.has(next)) {
dfs(graph, next, visited);
}
}
}
dfs(graph, 0); // 0 1 3 2 4
递归天然用调用栈保存了「回退点」,所以代码很短。但图可能很深,深递归会把 JS 调用栈压爆。这时改成显式栈的迭代版:
function dfsIterative(
graph: Record<number, number[]>,
start: number,
): void {
const visited = new Set<number>();
const stack = [start];
while (stack.length) {
const node = stack.pop()!;
if (visited.has(node)) continue; // 可能被重复压栈,出栈时去重
visited.add(node);
console.log(node);
for (const next of graph[node] ?? []) {
if (!visited.has(next)) stack.push(next);
}
}
}
一个容易踩的坑:递归版和迭代版、甚至迭代版不同的压栈顺序,都会产生不同的访问顺序。遍历顺序本身不是唯一的,别把某个顺序写死进测试用例。要想让迭代版和递归版顺序一致,压栈时要反着压。
三、广度优先遍历(BFS)
BFS 用队列,一层一层向外扩散:
function bfs(
graph: Record<number, number[]>,
start: number,
): Map<number, number> {
const visited = new Set<number>([start]);
const queue: number[] = [start];
const dist = new Map<number, number>([[start, 0]]);
let head = 0; // 用下标代替 shift(),避免 O(n) 出队
while (head < queue.length) {
const node = queue[head++];
for (const next of graph[node] ?? []) {
if (!visited.has(next)) {
visited.add(next); // 入队时就标记,见下方易错点
dist.set(next, dist.get(node)! + 1);
queue.push(next);
}
}
}
return dist;
}
BFS 会按「距离起点的边数」分层访问,所以顺带就能求出无权图的最短路径长度——这正是 DFS 做不到的。
四、两个必须记牢的易错点
1. BFS 要在「入队时」标记 visited,不能等「出队时」。
如果在出队时才标记,同一个节点可能被多个邻居重复入队,队列膨胀,最坏情况从 O(V+E) 劣化。DFS 因为有栈的去重(或天然递归)影响较小,但同样建议访问即标记。
2. 非连通图要遍历所有起点。 从单个点出发只会访问到它所在的连通分量。要覆盖整张图,得对每个未访问节点再启动一次遍历:
function countComponents(
n: number,
graph: Record<number, number[]>,
): number {
const visited = new Set<number>();
let count = 0;
for (let v = 0; v < n; v++) {
if (!visited.has(v)) {
count++;
dfs(graph, v, visited); // 这里换成 bfs 结果一样
}
}
return count;
}
求连通分量时,DFS 和 BFS 完全等价,选哪个都行。
五、复杂度
用邻接表:时间 O(V + E),空间 O(V)(visited + 栈/队列),递归 DFS 还要加上最深路径的栈空间。
用邻接矩阵:时间 O(V²),因为每个节点都要扫一整行。
所以「遍历用哪种」不取决于 DFS/BFS,而取决于图的存储方式。
六、什么时候用哪个
| 问题 | 首选 | 原因 |
|---|---|---|
| 无权图最短路、最少步数 | BFS | 逐层扩散,首次到达即最短 |
| 层次遍历、按距离分层 | BFS | 天然按层处理 |
| 判断二分图 | BFS | 按层染色 |
| 连通分量 / 可达性 | 都行 | 结果一致 |
| 环检测(有向图) | DFS | 用三色标记(白/灰/黑)判断回边 |
| 拓扑排序 | 都行 | DFS 后序反转,或 BFS(Kahn 算法) |
| 桥、割点、强连通分量 | DFS | Tarjan / Kosaraju 都基于 DFS 的访问序 |
| 回溯、枚举路径 | DFS | 沿路径深入与撤销天然匹配 |
一句话选型口诀:要找「最短/最近/分层」用 BFS;要处理「结构/顺序/环/连通性」用 DFS。
七、一个关键区别的直觉
为什么 BFS 能求无权最短路,DFS 不能?
BFS 是「一圈一圈」推进的:第 k 层节点一定在第 k+1 层之前全部处理完,所以第一次到达某节点时,走过的边数必然是全局最少。
DFS 是「一条路走到黑」:它可能在一条很长的路径上先撞到目标,但那条路径未必最短。上例里 DFS 走 A→B→C(2 条边)纯属巧合;如果先走到 A 的另一个朋友再绕回来,路径就会更长。
结语
DFS 和 BFS 的骨架都只有几行:一个栈、一个队列、一个 visited 集合,差别只在容器的存取顺序(LIFO vs FIFO)。
真正决定你选谁的,是问题本身:BFS 解决「距离」问题,DFS 解决「结构」问题。 记住这一点,加上「入队即标记」「非连通图遍历所有起点」这两个易错点,图的遍历基本就不会写错了。