深度优先搜索与广度优先搜索:算法核心思想、实现与应用场景全解析
2026/8/28 17:24:33 网站建设 项目流程

1. 从“搜索”说起:为什么DFS和BFS是程序员的必修课

“搜索”这个词,在编程世界里,远不止于你在浏览器里敲几个关键词那么简单。它指的是一种系统性的、遍历性的查找过程,是解决无数实际问题的核心骨架。无论是你玩一个迷宫游戏,需要找到从入口到出口的路径;还是在一个社交网络中,找出你和某个陌生人之间最短的“几度人脉”;甚至是编译器在分析代码依赖、杀毒软件在扫描文件系统,背后都离不开“搜索”算法的支撑。而深度优先搜索(DFS)和广度优先搜索(BFS),就是构建这座大厦最基础、也最强大的两根支柱。我见过太多新手,一上来就啃复杂的动态规划或者机器学习算法,结果在遇到一个简单的路径规划问题时却无从下手,根源往往就是对这两种最基本的搜索策略理解不透。今天,我们就抛开那些华而不实的术语,深入代码和思想的底层,把DFS和BFS掰开揉碎了讲清楚。无论你是正在刷题的学生,还是需要处理树形结构、图数据的工程师,掌握它们,就等于拿到了一把打开算法世界大门的万能钥匙。

2. 核心思想拆解:两种截然不同的“世界观”

DFS和BFS之所以常被放在一起比较,是因为它们解决的是同一类问题:如何系统地探索一个图(或树)结构中的所有节点,并找到满足特定条件的解。但它们探索的“策略”和“哲学”截然不同,这直接决定了它们适用的场景和性能表现。

2.1 深度优先搜索:一条道走到黑的探险家

你可以把DFS想象成一个执着于探索每条分支到底的探险家。当它站在一个岔路口(节点)时,它会随机(或按既定规则)选择一条路(边)走下去,并且会一直深入,直到走到这条路的尽头(遇到死胡同,即没有未访问的邻接节点)。此时,它会“回溯”到上一个岔路口,选择另一条未曾走过的路继续深入。

核心数据结构:栈DFS天然地使用栈(Stack),无论是显式地用编程语言提供的栈数据结构,还是隐式地利用系统的递归调用栈。这种“后进先出”的特性完美契合了“深入”和“回溯”的需求:每次探索一个新节点就将其压入栈(相当于前进),当无路可走时就从栈顶弹出节点(相当于回溯到上一个点)。

思维特点:

  • 纵向优先:优先向纵深发展,试图尽快找到离起点尽可能“远”的节点。
  • 空间效率:在最坏情况下(例如一条线性的链),栈中最多只会保存从根节点到当前节点路径上的所有节点。因此,其空间复杂度通常为O(h),其中h是图的最大深度或树的高度。
  • 解的特性:DFS不保证找到的第一个解就是最短路径。它找到的是一条“可行”路径,但不一定是“最优”路径。

2.2 广度优先搜索:稳扎稳打的推进者

BFS则像一位严谨的指挥官,它要确保占领一个区域后,再向更外围推进。从起点开始,它先访问所有与起点直接相连的邻居节点(第一层),然后再依次访问这些邻居的邻居(第二层),如此层层推进,直到找到目标或遍历完所有节点。

核心数据结构:队列BFS使用队列(Queue)这种“先进先出”的数据结构。它将待访问的节点放入队列尾部,而从队列头部取出节点进行访问。这保证了节点是按照它们被发现的顺序(也就是离起点的距离顺序)来访问的。

思维特点:

  • 横向优先:优先探索同一层(距离起点相同)的所有节点,再进入下一层。
  • 解的最优性:当图中的边没有权重(或权重相等)时,BFS首次找到目标节点的路径,一定是边数最少(即最短)的路径。这是它一个至关重要的性质。
  • 空间消耗:BFS需要存储当前层的所有节点,在最坏情况下(例如一棵完全二叉树),当搜索到最底层时,队列中可能需要存储几乎整层的节点,数量级可达O(n),其中n是节点总数。因此,其空间复杂度通常高于DFS。

注意:很多人初学时会混淆“深度”和“广度”在空间上的含义。一个简单的记忆方法是:DFS的“深”体现在它探索的路径长,但需要的“辅助空间”(栈)小;BFS的“广”体现在它同时探索的范围宽,需要的“辅助空间”(队列)大。

3. 算法实现与细节剖析

理解了思想,我们来看代码。这里我用最经典的“图的遍历”作为场景,假设图用邻接表表示。我会给出递归和非递归两种实现,并解释每一个细节。

3.1 DFS的两种实现方式

递归实现(最直观): 递归是DFS最自然的表达方式,系统调用栈隐式地为我们管理了回溯过程。

def dfs_recursive(graph, node, visited): """ :param graph: 字典,邻接表表示的图。graph[node] = [neighbor1, neighbor2, ...] :param node: 当前访问的节点 :param visited: 集合,记录已访问过的节点,防止重复访问和死循环 """ if node in visited: return # 处理当前节点(例如打印、记录路径等) print(f"Visiting node: {node}") visited.add(node) # 标记为已访问 # 递归访问所有未访问的邻居 for neighbor in graph.get(node, []): if neighbor not in visited: dfs_recursive(graph, neighbor, visited) # 初始化 graph = { 'A': ['B', 'C'], 'B': ['A', 'D', 'E'], 'C': ['A', 'F'], 'D': ['B'], 'E': ['B', 'F'], 'F': ['C', 'E'] } visited = set() dfs_recursive(graph, 'A', visited)

关键点

  1. visited集合:这是图的遍历(区别于树)必须有的。因为图中可能存在环,没有这个集合,递归会无限循环下去。
  2. 递归边界if node in visited: return是递归的终止条件之一。
  3. 处理顺序print或其它操作发生在递归调用之前,这被称为“前序遍历”。你也可以根据需求调整操作发生的位置(中序、后序,这在树结构中更常见)。

非递归实现(显式栈): 有时候,递归的深度可能受系统栈大小限制(例如图非常深),或者我们需要更精细地控制栈的状态,这时就需要手动维护一个栈。

def dfs_iterative(graph, start): visited = set() stack = [start] # 显式栈,初始化放入起点 while stack: node = stack.pop() # 弹出栈顶元素 if node not in visited: print(f"Visiting node: {node}") visited.add(node) # 将邻居逆序入栈,以保证遍历顺序与递归版一致(先访问第一个邻居) # 如果不关心顺序,直接入栈即可 for neighbor in reversed(graph.get(node, [])): if neighbor not in visited: stack.append(neighbor)

实操心得

  • 非递归版本中,stack.pop()弹出的是最后一个压入的元素,这实现了“深度优先”。
  • 使用reversed()是为了模拟递归版本中for neighbor in graph[node]的顺序。因为栈是后进先出,第一个被压入的邻居会最后被弹出。如果我们希望先处理graph[node]列表中的第一个邻居,就需要逆序压栈。
  • 显式栈版本中,我们在节点出栈时才检查是否访问并处理它。这是因为同一个节点可能被多次压入栈(通过不同的父节点),但我们只应处理它一次。

3.2 BFS的标准实现

BFS几乎总是用队列以非递归方式实现,其结构非常规整。

from collections import deque def bfs(graph, start): visited = set([start]) # 起始节点直接标记为已访问 queue = deque([start]) # 使用双端队列作为队列,效率更高 while queue: node = queue.popleft() # 从队列左侧弹出,实现先进先出 print(f"Visiting node: {node}") # 探索当前节点的所有邻居 for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) # **关键**:在入队时标记已访问 queue.append(neighbor)

与DFS非递归版的细微差别

  1. 数据结构:使用deque而非listlistpop(0)操作是O(n)的,而dequepopleft()是O(1)的,对于BFS这种频繁出队的操作,性能差异巨大。
  2. 标记时机:这是极易出错的地方!在BFS中,必须在节点入队时就将其标记为visited。为什么?想象一下,节点A和节点B都有一个共同的邻居C。当处理A时,将C标记为已访问并入队。接着处理B时,如果C还没被从队列中取出处理,但B又试图将C入队,如果没有在入队时标记,C就会被重复加入队列。这虽然不会导致错误结果(因为出队时会检查visited),但会导致队列中存在大量重复节点,严重浪费空间,在极端情况下可能使空间复杂度从O(n)恶化到O(n^2)。
  3. 层序遍历信息:BFS天然带有“层”的信息。如果需要记录节点所在的层数(即距离起点的步数),可以在入队时同时存入深度信息。
def bfs_with_level(graph, start): visited = set([start]) queue = deque([(start, 0)]) # (节点, 深度) while queue: node, depth = queue.popleft() print(f"Node {node} is at depth {depth}") for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) queue.append((neighbor, depth + 1))

4. 经典应用场景与实战分析

理解了怎么实现,我们来看看它们能解决哪些实际问题。选择DFS还是BFS,往往取决于问题的核心需求。

4.1 适合DFS的场景

场景一:查找一条可行路径(不要求最短)例如经典的“迷宫问题”。给定一个二维网格(1代表墙,0代表路),判断从起点能否到达终点。

def has_path_dfs(maze, start, end): directions = [(0,1), (1,0), (0,-1), (-1,0)] # 上下左右 rows, cols = len(maze), len(maze[0]) visited = [[False] * cols for _ in range(rows)] def dfs(x, y): if (x, y) == end: return True if not (0 <= x < rows and 0 <= y < cols) or maze[x][y] == 1 or visited[x][y]: return False visited[x][y] = True # 尝试四个方向 for dx, dy in directions: if dfs(x + dx, y + dy): return True # 重要:这里不需要显式地将visited[x][y]改回False # 因为本题只求“是否存在”一条路径,而不是“所有”路径。 # 如果求所有路径,则需要回溯,即 visited[x][y] = False return False return dfs(start[0], start[1])

为什么用DFS?迷宫可能很大,我们只关心“能否走出去”,而不关心是不是走了最少步数。DFS会随机选一条路猛扎下去,如果迷宫有解且不太复杂,它可能很快就能碰巧找到一条路,在平均情况下表现不错。注意代码中的注释,这是关于“回溯”的一个关键理解点。

场景二:拓扑排序用于安排有依赖关系的任务执行顺序(如课程安排、编译顺序)。只有有向无环图才能进行拓扑排序。DFS是实现拓扑排序的经典方法之一。

def topological_sort_dfs(graph): visited = set() stack = [] # 用于存放拓扑排序的结果(逆序) def dfs(node): visited.add(node) for neighbor in graph.get(node, []): if neighbor not in visited: dfs(neighbor) # 后序位置:所有依赖都处理完后,将当前节点入栈 stack.append(node) for node in graph: if node not in visited: dfs(node) # 栈顶是最后完成的节点,栈底是最先完成的节点。 # 拓扑排序结果是栈的逆序。 return stack[::-1]

原理:对一个节点来说,只有当它的所有后继节点(依赖它的任务)都处理完毕(即递归返回)后,它自身才能被加入结果序列。这正好对应了DFS递归的“后序遍历”过程。最后将结果逆序,就得到了从基础任务到高级任务的执行顺序。

场景三:寻找连通分量/岛屿问题计算一个无向图中有多少个互不相连的“子图”,或者计算二维网格中有多少个“岛屿”。

def num_islands_dfs(grid): if not grid: return 0 rows, cols = len(grid), len(grid[0]) count = 0 def dfs(r, c): if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != '1': return grid[r][c] = '0' # 标记为已访问,相当于visited数组 # 向四个方向扩散 dfs(r+1, c) dfs(r-1, c) dfs(r, c+1) dfs(r, c-1) for r in range(rows): for c in range(cols): if grid[r][c] == '1': # 发现一个新岛屿的起点 count += 1 dfs(r, c) # 用DFS“淹没”整个岛屿 return count

为什么用DFS?我们需要探索一个连通区域的所有部分。DFS可以很自然地从一个起点出发,“一鼓作气”地标记完整个区域,代码简洁直观。BFS也可以做,但DFS的递归写法通常更短。

4.2 适合BFS的场景

场景一:无权图的最短路径这是BFS的“杀手级”应用。例如,在社交网络中查找两个人之间的最少介绍人次数(六度空间理论),或者在一个简单游戏中找到从起点到终点的最少步数。

def shortest_path_bfs(graph, start, end): if start == end: return [start] visited = {start} queue = deque([(start, [start])]) # 队列元素:(当前节点, 到达该节点的路径) while queue: node, path = queue.popleft() for neighbor in graph.get(node, []): if neighbor == end: return path + [neighbor] # 找到目标,返回完整路径 if neighbor not in visited: visited.add(neighbor) queue.append((neighbor, path + [neighbor])) return None # 没有路径

核心优势:BFS按层扩展,当它第一次遇到目标节点时,所经过的路径层数一定是最少的。上面的代码还记录了完整路径,这在很多场景下非常有用。

场景二:层次遍历或按距离处理例如,二叉树按层打印节点,或者网络爬虫中按距离种子网址的“跳数”来分批抓取网页(避免对单一站点造成过大压力)。

def level_order_traversal(root): if not root: return [] result = [] queue = deque([root]) while queue: level_size = len(queue) current_level = [] for _ in range(level_size): # 处理当前层的所有节点 node = queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) return result

技巧level_size = len(queue)这行代码是关键。它在进入每一层循环时,固定了当前层的节点数量。这样,内层for循环就只会处理当前层的节点,处理完毕后,队列中剩下的就全是下一层的节点了。这是实现严格按层处理的经典模式。

场景三:扩散类问题(如腐烂的橘子、墙与门)在一个网格中,某个点状态的变化会以固定的速度(每步一格)向四周扩散。求所有点都被影响到所需的最短时间,或者某个点被影响到的时间。

def orangesRotting(grid): rows, cols = len(grid), len(grid[0]) queue = deque() fresh_count = 0 # 初始化:将所有腐烂橘子加入队列,并统计新鲜橘子数量 for r in range(rows): for c in range(cols): if grid[r][c] == 2: queue.append((r, c, 0)) # (行,列,时间) elif grid[r][c] == 1: fresh_count += 1 if fresh_count == 0: return 0 directions = [(0,1),(1,0),(0,-1),(-1,0)] max_time = 0 while queue: r, c, time = queue.popleft() for dr, dc in directions: nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1: grid[nr][nc] = 2 # 感染新鲜橘子 fresh_count -= 1 queue.append((nr, nc, time + 1)) max_time = max(max_time, time + 1) return max_time if fresh_count == 0 else -1

为什么用BFS?因为腐烂的传播是同时、均匀地向四周进行的,每一分钟传播一格。BFS的层序遍历特性,其“层数”天然对应了“时间”。从所有初始腐烂源(第0分钟)开始进行BFS,某个节点第一次被访问到的时间,就是它被腐烂的时间。这是DFS无法高效完成的。

5. 性能对比、选择策略与常见陷阱

在实际编码中,选择DFS还是BFS,或者如何优化它们,需要综合考虑问题性质、数据规模和约束条件。

5.1 时空复杂度与选择策略

特性深度优先搜索 (DFS)广度优先搜索 (BFS)
数据结构栈 (递归/显式)队列
时间复杂度O(|V| + |E|)O(|V| + |E|)
空间复杂度O(h)O(w)
解的性质不一定最短首次找到即最短(无权图)
适用场景检查连通性、拓扑排序、寻找可行解、回溯问题最短路径、层次遍历、扩散问题

选择策略

  1. 求最短路径或最少步数?->首选BFS。这是它的核心优势。
  2. 图非常深,但可能很宽?->谨慎使用递归DFS,可能栈溢出。考虑显式栈或BFS。
  3. 图非常宽(分支因子大)?->谨慎使用BFS,队列可能消耗巨大内存。考虑DFS。
  4. 需要遍历所有可能解(如排列组合)?->必须用DFS(回溯法)。BFS无法有效生成所有序列。
  5. 问题有明确的层次或轮次概念?->BFS更直观。
  6. 只是检查连通性或是否存在路径?->两者皆可,DFS代码可能更简洁。

5.2 常见陷阱与调试技巧

陷阱一:忘记 visited 集合,导致死循环或重复计算这是图遍历中最常见的错误。尤其是在图中存在环的情况下。务必在访问节点后立即标记,对于BFS,标记时机在入队时。

陷阱二:DFS递归深度过大Python默认递归深度约1000层。对于深度可能很大的图(如一条长链),递归DFS会引发RecursionError

  • 解决方案1:使用迭代DFS(显式栈)。
  • 解决方案2:调整递归深度限制sys.setrecursionlimit(1000000),但这只是权宜之计,可能引发栈溢出。

陷阱三:BFS中错误地使用列表作为队列如前所述,使用listpop(0)是O(n)操作。对于大规模BFS,这会成为性能瓶颈。始终使用collections.deque

陷阱四:路径记录的内存消耗在BFS记录路径时(如queue.append((neighbor, path + [neighbor]))),每次都会复制整个路径列表,如果路径很长,内存消耗是O(n^2)。对于只求路径长度的问题,可以只记录前驱节点,最后再反向重建路径。

def shortest_path_length_bfs(graph, start, end): from collections import deque if start == end: return 0 visited = {start} queue = deque([(start, 0)]) # (节点, 距离) while queue: node, dist = queue.popleft() for neighbor in graph[node]: if neighbor == end: return dist + 1 if neighbor not in visited: visited.add(neighbor) queue.append((neighbor, dist + 1)) return -1

调试技巧

  1. 可视化:对于小规模图,手动画出图,然后一步步模拟算法执行,在纸上画出栈/队列的变化和visited集合。
  2. 打印关键信息:在循环中打印当前处理的节点、栈/队列的内容、visited集合,这是最直接的调试方法。
  3. 单元测试:构造简单的测试用例,包括空图、单节点图、带环的图、不连通的图等,确保算法在边界情况下也能正常工作。

6. 从基础到进阶:双向BFS与迭代加深DFS

当你熟练掌握了基础DFS和BFS后,可以了解一些优化变种,它们在解决特定问题时效率更高。

6.1 双向BFS

在已知起点和终点,且图规模很大的情况下,传统的单向BFS可能会探索大量不必要的节点。双向BFS从起点和终点同时开始BFS,当两个搜索 frontier(边界)相遇时,就找到了最短路径。

为什么更快?假设分支因子为b,最短路径长度为L。单向BFS需要探索的节点数量级约为 O(b^L)。而双向BFS从两头出发,理想情况下,每边只需要探索到深度L/2,总探索节点数约为 O(2 * b^{L/2}),当b和L较大时,优势非常明显。

实现要点

  1. 需要两个队列和两个visited字典(分别记录从起点和终点访问过的节点及距离)。
  2. 每次迭代选择当前节点数较少的那一边进行扩展,以保持平衡。
  3. 检查新扩展的节点是否出现在另一边的visited字典中,如果出现,则路径连通。
def bidirectional_bfs(graph, start, end): if start == end: return [start] # 前向和后向搜索的队列及已访问字典(节点:距离) queue_front, queue_back = deque([start]), deque([end]) visited_front, visited_back = {start: 0}, {end: 0} # 记录前驱节点用于重建路径 parent_front, parent_back = {start: None}, {end: None} def expand(queue, visited, other_visited, parent): """扩展一层""" level_size = len(queue) for _ in range(level_size): node = queue.popleft() current_dist = visited[node] for neighbor in graph.get(node, []): if neighbor not in visited: visited[neighbor] = current_dist + 1 parent[neighbor] = node queue.append(neighbor) # 相遇检查 if neighbor in other_visited: return neighbor # 返回相遇点 return None while queue_front and queue_back: # 选择较小的一边进行扩展 if len(queue_front) <= len(queue_back): meet_node = expand(queue_front, visited_front, visited_back, parent_front) else: meet_node = expand(queue_back, visited_back, visited_front, parent_back) if meet_node: # 重建路径:从相遇点分别向起点和终点回溯 path = [] # 从相遇点回溯到起点 node = meet_node while node is not None: path.append(node) node = parent_front.get(node) # 注意:相遇点可能在front的parent中,也可能在back的parent中 # 这里需要根据实际情况判断,简化起见,我们假设meet_node是在front扩展时发现的 # 更健壮的实现需要判断meet_node的来源 path = path[::-1] # 反转,得到从起点到相遇点的路径 # 从相遇点的下一个节点回溯到终点(跳过相遇点本身) node = parent_back[meet_node] while node is not None: path.append(node) node = parent_back[node] return path return None # 没有连通路径

注意:双向BFS的实现比单向BFS复杂不少,主要难点在于路径重建和相遇点的处理。在实际面试或竞赛中,除非明确要求或图非常大,否则实现单向BFS更稳妥。但理解其思想非常重要。

6.2 迭代加深搜索

迭代加深搜索本质上是一种DFS,但它通过逐渐增加深度限制来运行,结合了DFS空间效率高和BFS能找到最短路径(在状态空间搜索中)的优点。常用于状态空间巨大且深度未知的搜索,如棋类游戏。

算法流程

  1. 设置深度限制depth_limit = 0
  2. 运行深度限制为depth_limit的DFS。即DFS在搜索时,如果当前深度超过depth_limit则立即回溯,不再深入。
  3. 如果在当前深度限制内找到目标,则返回成功。
  4. 如果没找到,则将depth_limit加1,回到步骤2。

优势

  • 空间复杂度:和DFS一样,是O(d),d是深度。
  • 能找到最短路径:因为它是按深度一层层增加的,第一次找到目标时,深度一定是最小的。
  • 避免DFS陷入过深的无用分支:对于无限深的状态空间或非常深的分支,普通的DFS可能一头扎进去出不来,而IDS会因为深度限制而及时回溯。

缺点

  • 时间开销:浅层的节点会被重复访问多次。例如,根节点在第1、2、3...次迭代中都会被访问。理论上时间开销比BFS大,但在很多实际问题中,分支因子大,深层节点数指数级增长,重复访问浅层节点的开销相对可以接受。
def iterative_deepening_dfs(graph, start, end, max_depth): def depth_limited_dfs(node, depth, limit, visited): if depth > limit: return None if node == end: return [node] visited.add(node) for neighbor in graph.get(node, []): if neighbor not in visited: result = depth_limited_dfs(neighbor, depth+1, limit, visited) if result is not None: return [node] + result visited.remove(node) # 回溯 return None for depth_limit in range(max_depth + 1): visited = set() result = depth_limited_dfs(start, 0, depth_limit, visited) if result is not None: return result return None

使用场景:当状态空间树非常庞大,且你怀疑解可能在较浅的层次,但又不想承受BFS的巨大内存开销时,IDS是一个很好的折中选择。例如,在解魔方、华容道等 puzzles 时常用。

7. 总结与个人心得

DFS和BFS是算法领域的“原子操作”,它们的价值远不止于解决几道算法题。在我多年的开发经历中,这两种思想无处不在:前端的DOM树遍历、后端的依赖解析、数据库的索引查询优化、网络爬虫的抓取策略、甚至是一些业务流程的状态流转,其底层逻辑都或多或少能看到DFS或BFS的影子。

我个人最深刻的体会是,理解它们的关键不在于背诵代码模板,而在于吃透其背后的“数据结构决定行为”这一核心。栈的“后进先出”天然导向深度探索,队列的“先进先出”天然导向广度探索。当你遇到一个新问题时,先问自己:这个问题需要的是“钻探”还是“铺开”?答案往往就藏在问题描述里。

另一个常被忽视的点是**“标记已访问”的时机**。在DFS中,我们通常在递归调用前或处理节点时标记;在BFS中,必须在入队时标记。这个细微差别是很多Bug的根源。我自己的记忆方法是:BFS的队列是“待办事项清单”,一个节点一旦被列入清单(入队),就意味着它即将被处理,为了防止它被其他节点重复列入清单,必须立刻打上标记。

最后,关于练习。不要只停留在“看懂了”的层面。找一些经典的题目(如迷宫、单词接龙、岛屿数量、二叉树层序遍历等),自己动手实现,并尝试用两种方法都解一遍。然后分析在特定输入下,栈/队列的变化、节点的访问顺序。这个过程能帮你建立起牢固的直觉。当你再遇到复杂的问题时,你就能下意识地判断出,该用哪种搜索策略作为你解题的基石,或者如何将两者结合,演化出更高效的算法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询