1. 从一张图说起:DFS 和 BFS 到底在“搜索”什么
我第一次意识到自己真正理解了 DFS 和 BFS,不是在看算法课本的时候,而是有天晚上帮朋友调一个“社交关系推荐”的小项目。需求很简单:给我一个用户 ID,找出他所有“朋友的朋友”里他还不认识的人。我第一反应就是遍历图,但到底用深度优先还是广度优先,我在草稿纸上画了十分钟才理清楚。这两个算法看起来简单,实则每一步选择都藏在细节里。
先做个最小化的定义:DFS 是 Depth First Search,深度优先搜索;BFS 是 Breadth First Search,广度优先搜索。它们解决的是同一个基础问题——在一个图结构里,从一个起点出发,按照某种规则访问所有能到达的节点。图是什么?不用说得太抽象,你只需要把它想成“一堆节点和连接它们的线”。社交网络里每个人是一个节点,好友关系是线;地图里每个路口是节点,道路是线;程序里每个状态是节点,状态转移是线。搜索算法就是给你一个起点,让你把这张网“走”一遍。
但同样是走,两种算法的性格完全不同。DFS 是“一条道走到黑”的执拗型选手:选了一个邻居就一路深入,直到走不动了才回头,再换一条路继续。BFS 则是“层层推进”的稳健型选手:先把离起点最近的一层全部访问完,再进入下一层,绝不跳级。这种性格差异,决定了它们在不同问题中的命运。
为了彻底看清它们的区别,我构造一个非常小但足够说明问题的图:
A - B - C | | D - E - F邻接表表示如下(按字母序排列邻居):
graph = { 'A': ['B', 'D'], 'B': ['A', 'C'], 'C': ['B', 'F'], 'D': ['A', 'E'], 'E': ['D', 'F'], 'F': ['C', 'E'] }从 A 出发,DFS 按照“先走字母序更小的邻居”这个规则,访问顺序是:
A -> B -> C -> F -> E -> D你看这个过程:A 先到 B,B 到 C,C 到 F,F 到 E,E 到 D,然后整条路走到头了,才一层层回溯。整个过程像一根绳子从起点向外无限延伸,先探最深的那一段。
BFS 从 A 出发,访问顺序是:
A -> B -> D -> C -> E -> FA 先看自己直接相连的 B 和 D,然后才轮到看“B 的朋友”和“D 的朋友”,也就是 C 和 E,最后才到 F。每一层都整整齐齐,像波纹一样扩散。
这一步如果只看伪代码,可能觉得区别不大,但真正落实到代码实现、复杂度分析、常见 bug 和场景选择,差距就大了。下面我按两条线分别拆开讲,每一段都会给出能直接跑起来的代码和踩坑记录。
2. DFS 的三种写法:递归、显式栈和迭代加深
2.1 递归实现:最直观,但暗藏风险
DFS 用递归写,几乎是零思考成本。因为函数调用的系统调用栈天然就是“后进先出”的结构,正好符合 DFS 的“沿一条路深入再回溯”语义。
def dfs_recursive(graph, node, visited=None): if visited is None: visited = set() visited.add(node) print(node, end=' ') # 这里代表“访问”动作 for neighbor in graph[node]: if neighbor not in visited: dfs_recursive(graph, neighbor, visited) return visited跑一下:
dfs_recursive(graph, 'A') # 输出: A B C F E D递归版本的可读性极好,逻辑和“深度优先”的定义一一对应。但有一个工程上绕不开的问题:递归深度。Python 默认递归深度大约在 1000 层左右,如果图特别深——比如一条链表状的图有 5000 个节点——直接跑会抛出 RecursionError。这时候要么调大递归上限,要么换显式栈。
注意:递归版本的 DFS,对于深度很大的图,不只是性能问题,还有函数调用栈溢出的风险。写生产级代码前,先评估图的最大深度。
2.2 显式栈实现:可控性更强,但顺序有讲究
显式栈的 DFS,字面上就是用自己维护的栈替换系统调用栈。代码长了一点点,但胜在稳,不依赖递归深度。
def dfs_iterative(graph, start): visited = set([start]) stack = [start] while stack: node = stack.pop() print(node, end=' ') for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) stack.append(neighbor)有一个细节必须说清楚:显式栈版本的访问顺序,和递归版本可能不一样。以上面的图为例:
dfs_iterative(graph, 'A') # 输出: A D E F C B为什么?因为递归版本是“边访问边递归”,处理邻居 B 时会把 B 的整条链走完再回来;而显式栈版本是先把所有未访问邻居压栈,然后弹出的顺序取决于栈的 LIFO 行为。在这个写法里,邻居 D 是最后压栈的,但它最先被弹出,所以 D 反而先被访问。
这个差异本身不是错误,关键看你关心的是什么。如果只关心“所有节点都被访问到”,两种写法等价;如果你需要特定的节点访问顺序,必须理解清楚压栈与弹栈的先后关系。通常有一个推荐做法:想让显式栈的访问顺序对齐递归版,就“逆序压栈”——把邻居列表反转后再压进去:
def dfs_iterative_same_order(graph, start): visited = set([start]) stack = [start] while stack: node = stack.pop() print(node, end=' ') # 逆序遍历邻居,保证字母序小的先被处理 for neighbor in reversed(graph[node]): if neighbor not in visited: visited.add(neighbor) stack.append(neighbor)我实际写代码时,如果没有特殊顺序要求,我更倾向显式栈版本,因为它不依赖递归深度,调试时也更容易在循环里加日志。
2.3 迭代加深 DFS:深度优先但不无限深入
还有一个容易被忽略的变体,叫迭代加深搜索(Iterative Deepening DFS,IDDFS)。它的思路很巧妙:先限制 DFS 只能走 depth=1,看看能否找到目标;找不到再把深限提高到 2、3…… 直到找到目标。这个东西看起来重复劳动很多,但由于每层节点数量往往是指数增长,多出来的开销其实可控,而且它保留了 DFS 的低空间占用,同时能保证找到最优解。
它的适用场景:搜索空间非常大但目标深度未知,比如棋盘类游戏、状态空间搜索。不过对于初学者,掌握递归版和显式栈版就够了,迭代加深可以作为进阶了解。
3. BFS 的分层机制:为什么它天生适合最短路径
3.1 用队列实现,出队和入队的时机很关键
BFS 的核心数据结构是队列,先进先出。它的思想是把“当前层”的所有邻居全部入队,然后依次处理,再进入下一层。队列就像一条传送带,保证先发现的节点先被处理。
from collections import deque def bfs(graph, start): visited = set([start]) queue = deque([start]) while queue: node = queue.popleft() print(node, end=' ') for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) queue.append(neighbor)跑一下:
bfs(graph, 'A') # 输出: A B D C E F和 DFS 最大的不同在于:BFS 的访问顺序天然就是“按距离分层”的。A 是第一层,B 和 D 是第二层,C 和 E 是第三层,F 是第四层。这种“先近后远”的性质,让 BFS 成为求解无权图最短路径的首选——因为第一次访问到目标节点时的层数,一定是从起点到目标的最少边数。
3.2 带步数的分层 BFS:模板直接抄
很多教材讲了 BFS 原理,但真正到做题或写工程时会发现,光知道“按层访问”还不够,你得知道“当前在第几层”。一个非常实用的模板是:在外层用for _ in range(len(queue))来区分层。
def bfs_shortest_path(graph, start, target): visited = set([start]) queue = deque([start]) distance = 0 while queue: for _ in range(len(queue)): node = queue.popleft() if node == target: return distance for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) queue.append(neighbor) distance += 1 return -1这个for _ in range(len(queue))是很多人容易漏掉的细节。有了它,distance才能和“层”对齐;没有它,distance累加的次数就完全错乱了。这个方法在网格类最短路径问题中特别有用,比如二维矩阵迷宫寻路:
def shortest_path_in_grid(grid, start, end): """grid: 0 可走, 1 障碍; start/end 为 (row, col)""" from collections import deque rows, cols = len(grid), len(grid[0]) visited = set([start]) queue = deque([(start[0], start[1], 0)]) dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)] while queue: r, c, dist = queue.popleft() if (r, c) == end: return dist for dr, dc in dirs: nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] != 1 and (nr, nc) not in visited: visited.add((nr, nc)) queue.append((nr, nc, dist + 1)) return -1这里我直接在队列里存了步数,省去分层循环。两种写法本质相同,选哪种看你习惯。但要注意:无论哪种写法,visited 的标记必须在入队时完成,这个问题我在后面的“常见坑”里集中说。
3.3 双向 BFS:从两端同时出发,搜索量指数级下降
如果搜索空间特别大,单纯单向 BFS 可能很慢。一个实用优化是双向 BFS:从起点和目标同时扩散,每次扩展节点数更少的那一端,直到两端相遇。对于无权图,双向 BFS 能显著减少访问节点数,因为它把“半径 r 的球体”变成“两个半径 r/2 的球体”,在指数增长的空间里收益非常明显。
伪代码如下:
def bi_bfs(graph, start, target): if start == target: return 0 front, back = {start}, {target} visited_front, visited_back = {start}, {target} depth = 0 while front and back: depth += 1 # 优化:从较小的集合扩展 if len(front) > len(back): front, back = back, front visited_front, visited_back = visited_back, visited_front new_front = set() for node in front: for neighbor in graph[node]: if neighbor in visited_back: return depth if neighbor not in visited_front: visited_front.add(neighbor) new_front.add(neighbor) front = new_front return -1这个模板我建议存下来。LeetCode 上的很多“单词接龙”类问题,用双向 BFS 比单向快出一个量级。不过注意:双向 BFS 的前提是你知道明确的目标节点,如果目标是“找所有可达节点”这种开放性问题,就无从双向了。
4. 选 DFS 还是 BFS:一张对照表看清应用边界
4.1 时空复杂度:没想象中那么“一样”
很多人背过结论:DFS 和 BFS 时间都是 O(V+E),V 是顶点数,E 是边数。这个说法的前提是“每条边最多被检查一次”。但如果为了找某个目标就提前终止,实际性能会因图结构差别巨大。
空间复杂度才是真正的分水岭:
| 算法 | 最坏空间复杂度 | 说明 |
|---|---|---|
| DFS(递归) | O(h),h 是递归深度 | 极端情况是链表图,h=O(V) |
| DFS(显式栈) | O(h) | 栈中最多同时存储一条路径上的节点 |
| BFS | O(w),w 是最大层宽度 | 理想情况是根节点层宽,极端情况是 O(V) |
举个例子:一棵深度为 1000、每层只有 2 个节点的二叉树。DFS 的栈深度大约是 1000,空间占用很稳定;BFS 在某一层可能同时有 2^10 个节点,但如果树是“宽而浅”的,BFS 反而省空间。没有绝对的优劣,只有图结构和目标问题的匹配度。
4.2 场景对照:什么时候无脑选 BFS
我把常见问题分成两类,直接看表:
| 问题类型 | 推荐算法 | 原因 |
|---|---|---|
| 无权图最短路径、最少步数 | BFS | 第一次到达目标即最优 |
| 找所有可达节点/连通分量 | DFS 或 BFS 均可 | 只要求覆盖,不要求顺序 |
| 判断图中是否有环 | DFS | 递归回溯时天然能发现“回边” |
| 拓扑排序 | DFS 或 BFS 均可 | DFS 后序反转,BFS 做 Kahn 算法 |
| 全排列、组合、子集 | DFS(回溯) | 需要枚举所有状态路径 |
| 迷宫是否有解(只要结果) | DFS | 更早深入探索,空间省 |
| 迷宫最短路径 | BFS | 层数即步数 |
| 树/图的层次遍历 | BFS | 天然按层输出 |
| 搜索大量可行解中的第一个 | DFS | 尝试路径,可能更快命中 |
一个常见的面试题变体是“岛屿数量”:给定二维网格,1 代表陆地,0 代表水,求有多少块连通的陆地。DFS 和 BFS 都能做,而且代码骨架几乎一样。区别在于 DFS 写起来更像“递归蔓延”,BFS 则是“队列扩散”。这题考的不是算法难度,而是你能不能选对遍历框架,并在 visited 标记上不犯错。
4.3 实践中的直觉:怎么“秒选”算法
我给自己的判断逻辑是这样一套短链:
- 这题的目标是“是否存在一条路径”还是“最短路径”?后者直接选 BFS。
- 是否需要回溯枚举所有可能性?比如全排列、八皇后这类,选 DFS。
- 图的规模是“深”还是“宽”?递归深度容易爆,就选 BFS 或者显式栈 DFS。
- 是否要按层输出结果?比如按层级遍历二叉树,选 BFS。
- 是否需要在递归过程中记录一条完整路径?DFS 天然维护路径栈,更好操作。
这套逻辑在大部分算法题里都能快速落地。真实工程里,比如地图导航的最短路线,你会选 BFS 或其进阶版 A*;但如果是爬虫抓取整个网站链接结构,你可能更需要 DFS 来控制爬取深度。没有“万金油”,只有“合不合适”。
5. 手写搜索最容易踩的坑:visited 时机、递归深度与调试方法
5.1 为什么必须在“入队/入栈时”标记 visited
这是新手最容易踩的坑,没有之一。BFS 代码如果写成“出队时标记 visited”,会让很多节点被重复入队,严重时甚至死循环。看下面这段有问题的 BFS:
def bfs_wrong(graph, start): visited = set() queue = deque([start]) while queue: node = queue.popleft() if node in visited: # 出队时才检查 continue visited.add(node) for neighbor in graph[node]: if neighbor not in visited: # 这里检查无效,因为邻居可能已在队列里但未被处理 queue.append(neighbor)问题在哪?假设图是A - B - C形成的三角形,从 A 出发。A 先出队,B、C 入队;此时队列是 [B, C]。B 出队后,它看到邻居 A(已访问)和 C(C 已经在队列里但不在 visited 中),于是把 C 又入队一次。队列变成 [C, C]。C 第一次出队时访问完,C 第二次出队时发现 C 已在 visited 就跳过了。单独看这个例子勉强能跑,只是多了一次无用入队。但如果图里有环结构,这种“出队才标记”的写法会让队列指数膨胀,甚至永远处理不完。
正确做法很明确:在把节点放入队列/栈的那一刻就标记为已访问。这一步不仅是为了避免重复,也是保证算法在环状图上能终止的关键。
5.2 递归深度导致崩溃:一行代码解决,但别依赖
Python 里默认递归深度大约是 1000。如果你用递归 DFS 处理一个 2000 层深的图,会直接抛 RecursionError。最简单的解法是加一行:
import sys sys.setrecursionlimit(1000000)这会调高限制。但我必须泼一盆冷水:调高限制不等于没有代价。Python 的递归调用本身有函数调用开销,而且太深的递归会让 C 栈面临真实风险(在某些环境会直接段错误)。我的建议是:
- 算法题里可以调,为了过测试没问题;
- 生产代码里,凡是你预计深度可能超过几百的,直接用显式栈版本,别赌系统栈空间。
5.3 调试技巧:把访问顺序打出来,一切一目了然
每次写完 DFS 或 BFS,先别急着跑大数据,用一个小图把访问顺序打印出来,对比自己手推的顺序。这能帮你快速定位三类问题:
- 顺序不符合预期:检查邻居遍历顺序、压栈是否逆序。
- 节点被重复访问:检查 visited 标记时机是否在入队/入栈前。
- 该访问的节点漏掉了:检查边界条件,尤其是网格类问题的行列越界判断。
网格问题还有一个特殊调试点:方向数组别写错。dirs = [(1,0), (-1,0), (0,1), (0,-1)]对应上下左右,写成一个(0,0)就会原地踏步。我见过有人把(0, -1)写成(0, 1),导致整个搜索方向歪掉,排查了半天才发现是方向定义问题。
6. 从算法题到真实工程:DFS/BFS 的实际用武之地
6.1 算法题里的高频场景:直接套模板
刷 LeetCode 的朋友可以重点关注下面几类题,它们都是 DFS/BFS 的“标准形态”:
- 二叉树遍历:前序/中序/后序对 DFS,层序对 BFS。
- 网格类问题:岛屿数量、腐烂的橘子、01 矩阵,本质都是图搜索,DFS/BFS 二选一。
- 图论基础:判断二分图、课程表拓扑排序、找桥找环。
- 状态空间搜索:单词接龙、最少转机次数,这些基本都是 BFS 的天下。
我特别想提一下“腐烂的橘子”这类多源 BFS 题。它的思路是:把所有初始腐烂的橘子同时作为起点,一起放入队列,然后统一扩散。这其实就是把“单源 BFS”扩展成“多源 BFS”,代码几乎一样,只是初始队列里放多个节点。这种题对理解“层”和“步数”非常有帮助。
6.2 工程实践:爬虫、社交推荐、地图导航
跳出刷题,DFS/BFS 在真实项目里到处都是。我举几个自己经历过或见过的场景:
爬虫设计。爬取一个网站的所有子页面时,BFS 是默认选择:先抓首页的链接,再抓这些链接指向的页面里的链接,一层一层来。这样能避免爬虫陷入某个深不见底的目录结构里出不来。但如果目标是“精准抓取某一类深层页面”,DFS 反而更合适,因为它会优先沿着一条路径深入。
社交平台的“你可能认识的人”。我的那位朋友做的功能,核心就是一个 BFS:从当前用户出发,把“朋友”作为第 1 层,“朋友的朋友”作为第 2 层,排除自己已经认识的人,把第 2 层按共同好友数排序返回。这就是典型的 BFS 分层+排序。
地图导航。虽然真实导航不会用纯 BFS,但 BFS 是所有启发式搜索的地基。像 A* 算法,本质上就是“带有启发函数的 BFS”,它依然用队列的思想,只是在选择下一个扩展节点时,会优先选“距离目标更近”的方向。理解好基础 BFS,再看 A* 会轻松很多。
6.3 更进一步:记忆化搜索和回溯法
如果你已经熟练掌握了 DFS 和 BFS,下一步我建议接触三个进阶方向:
- 回溯法:DFS 的一种应用,在全排列、组合、N 皇后问题中,DFS 枚举所有路径,并在不合条件时“剪枝”返回。
- 记忆化搜索:把 DFS 递归过程中计算出的子问题结果存起来,避免重复计算,本质是“DFS + 缓存”。
- A* 搜索:BFS 的启发式升级,在工程中用于路径规划。
这三个方向都以基础 DFS/BFS 为骨架,只是针对不同问题加了策略。把今天这篇里的模板写熟练,再往这几个方向走,会顺畅很多。
最后分享一个我自己的实操习惯:不管多简单的搜图题,我都坚持先画图、再写邻接表、然后手推一遍访问顺序,最后才写代码。这个流程看起来是“多此一举”,但它能帮你把抽象的算法变成具体的直觉。等你刷多了就会发现,DFS 和 BFS 不是两种孤立的知识点,而是你分析一切图结构问题的底层语言。