news 2026/7/29 4:34:12

深度优先搜索与广度优先搜索:原理、实现与应用场景全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度优先搜索与广度优先搜索:原理、实现与应用场景全解析

1. 从迷宫到算法:两种搜索策略的直观理解

想象一下,你站在一个巨大的迷宫里,手里只有一支粉笔。你的目标是找到出口。现在,你有两种截然不同的探索策略。第一种,你选择一条路走到黑,遇到岔路就随便选一条继续深入,直到走进死胡同,然后退回上一个岔路口,尝试另一条没走过的路。这种“不撞南墙不回头”的策略,就是深度优先搜索(DFS)。它像是一个执着于探索每一条分支尽头的探险家,优先向深处挖掘。第二种策略,你站在起点,先把你目光所及、一步就能到达的所有路口都标记下来;然后,从这些标记的路口中,依次出发,再把从这些路口出发、一步能到达的路口标记下来。你是一层一层、由近及远地探索整个迷宫。这种“稳扎稳打,层层推进”的策略,就是广度优先搜索(BFS)。它像是一位严谨的指挥官,确保搜索完所有近处可能性后,才向更远处进发。

这两种策略,不仅仅是走出迷宫的思路,更是计算机科学中遍历或搜索图与树这类数据结构最基础、最核心的两种算法思想。无论是社交网络中的好友推荐(几度人脉)、编译器对代码语法树的解析、游戏AI寻找通关路径,还是我们刷算法题时遇到的“岛屿数量”、“二叉树层序遍历”、“单词接龙”等问题,DFS和BFS都是解决问题的利器。理解它们的本质差异、适用场景以及实现细节,是每一位开发者内功修炼的必经之路。本文将从原理、实现、应用场景到实战避坑,为你彻底拆解这对“搜索双雄”。

2. 核心原理与数据结构选择:栈与队列的博弈

DFS和BFS最根本的区别,源于它们所使用的辅助数据结构不同,这直接决定了它们的搜索顺序和行为模式。

2.1 深度优先搜索(DFS):栈的“后进先出”哲学

DFS的核心是栈(Stack)。栈是一种“后进先出”(LIFO)的数据结构,想象一摞盘子,你总是把新盘子放在最上面(入栈),也总是从最上面取走盘子(出栈)。

算法过程

  1. 将起始节点放入栈中,并标记为已访问。
  2. 当栈不为空时,重复以下步骤: a. 从栈顶弹出一个节点作为当前节点。 b. 处理当前节点(例如,打印值、判断是否为目标等)。 c. 将当前节点的所有未访问过的邻居节点压入栈中。

为什么是栈?这保证了算法总是优先探索最新发现的路径。从当前节点压入它的邻居后,栈顶就变成了其中一个邻居。下一步就会立刻弹出这个邻居进行探索,从而一路深入下去。只有当一个节点的所有邻居(或者说一条路径的尽头)都探索完毕,算法才会回溯到栈中更早的节点(即上一个岔路口),实现“深度优先”。

递归实现是天然的栈:递归函数的调用本身就是利用系统调用栈来实现的。每次递归调用相当于压栈,返回相当于出栈。因此,DFS用递归写起来通常非常简洁直观,其隐式栈由系统管理。

2.2 广度优先搜索(BFS):队列的“先进先出”逻辑

BFS的核心是队列(Queue)。队列是一种“先进先出”(FIFO)的数据结构,就像排队买票,先来的人先得到服务。

算法过程

  1. 将起始节点放入队列中,并标记为已访问。
  2. 当队列不为空时,重复以下步骤: a. 从队首弹出一个节点作为当前节点。 b. 处理当前节点。 c. 将当前节点的所有未访问过的邻居节点加入队尾。

为什么是队列?这保证了算法总是按“发现顺序”来处理节点。起点先入队,也先出队并被处理。当处理起点时,它的所有邻居被加入队尾。接下来,队列里就是这些第一层的邻居,它们会按照入队的顺序依次出队被处理,并在处理时将它们各自的邻居(第二层)加入队尾。如此往复,节点就像水面的涟漪一样,一层一层地扩散出去,确保了最先找到的路径一定是边数最少的路径(在无权图中即最短路径)。

2.3 关键对比与选择依据

特性深度优先搜索 (DFS)广度优先搜索 (BFS)
核心数据结构栈 (Stack)队列 (Queue)
搜索顺序一条路走到黑,再回溯一层一层,由近及远
空间复杂度O(h),h为图的最大深度。递归深度或栈深度。在树形结构中优势明显O(w),w为图的最大宽度。需要存储一整层的节点。在宽而浅的图中可能消耗大
时间复杂度O(V+E),V为顶点数,E为边数。两者都需要访问所有节点和边。O(V+E),同上。
找到的路径不一定是最短路径(无权图)。保证找到最短路径(无权图)。
常见应用场景拓扑排序、连通分量、检测环、回溯问题(如八皇后、全排列)、图的路径存在性判断。最短路径问题(无权图)、层序遍历、扩散问题(如腐烂的橘子)、最近距离问题。

注意:空间复杂度的差异是选择算法时的重要考量。例如,在一棵非常深但很窄的树(如链表)中,BFS的队列可能始终只存储少量节点,而DFS的递归栈可能非常深,有栈溢出风险。反之,在一棵非常宽(如满二叉树)但很浅的树中,BFS在底层时需要存储海量节点,内存压力大,而DFS的栈深度始终可控。

3. 代码实现与细节剖析

理解了原理,我们通过经典问题来看具体实现。我们以在无向图中搜索特定节点为例,图的表示使用邻接表。

3.1 深度优先搜索(DFS)实现

递归版本(最常用且直观)

def dfs_recursive(graph, node, target, visited): """ :param graph: 邻接表表示的图,dict形式,{node: [neighbor1, neighbor2, ...]} :param node: 当前访问的节点 :param target: 要寻找的目标节点 :param visited: 集合,记录已访问节点 :return: True如果找到目标,否则False """ if node == target: return True visited.add(node) # 标记当前节点已访问 for neighbor in graph.get(node, []): if neighbor not in visited: if dfs_recursive(graph, neighbor, target, visited): return True # 如果子调用找到目标,提前返回 return False # 初始化调用 graph = {'A': ['B', 'C'], 'B': ['A', 'D', 'E'], ...} visited = set() found = dfs_recursive(graph, 'A', 'E', visited)

迭代版本(显式使用栈)

def dfs_iterative(graph, start, target): visited = set() stack = [start] # 用列表模拟栈,append入栈,pop出栈 while stack: node = stack.pop() # 弹出栈顶元素 if node == target: return True if node not in visited: visited.add(node) # 注意:为了与递归顺序一致(假设邻接表是左到右), # 需要将邻居逆序入栈,以保证最左边的邻居最后入栈、最先出栈。 for neighbor in reversed(graph.get(node, [])): if neighbor not in visited: stack.append(neighbor) return False

关键细节

  1. 访问标记(Visited Set):这是绝对必须的,尤其是在无向图或存在环的图中。没有它,DFS会在两个相邻节点间无限循环。visited集合确保了每个节点只被处理一次。
  2. 递归与迭代的选择:递归代码简洁,但存在栈溢出风险(Python默认递归深度约1000层)。对于深度未知或可能很大的图,迭代版本更安全。迭代版本中,手动管理栈的顺序可以灵活控制遍历顺序。
  3. 路径记录:如果我们需要输出具体路径,而不仅仅是判断是否存在,可以在递归参数或栈的元素中附带路径信息。例如,在迭代栈中存储(node, path)元组。

3.2 广度优先搜索(BFS)实现

BFS通常只用迭代实现,因为它天然的层次性用队列表达最清晰。

from collections import deque def bfs(graph, start, target): if start == target: return True, [start] # 返回是否找到及路径 visited = set([start]) queue = deque([(start, [start])]) # 队列元素为 (当前节点, 到当前节点的路径) while queue: node, path = queue.popleft() # 从队首弹出 for neighbor in graph.get(node, []): if neighbor == target: return True, path + [neighbor] # 找到目标,返回完整路径 if neighbor not in visited: visited.add(neighbor) # 将新节点和延伸到它的新路径入队 queue.append((neighbor, path + [neighbor])) return False, [] # 未找到 # 使用示例 graph = {'A': ['B', 'C'], 'B': ['A', 'D', 'E'], 'C': ['A', 'F'], 'D': ['B'], 'E': ['B', 'F'], 'F': ['C', 'E']} found, shortest_path = bfs(graph, 'A', 'F') print(f"找到目标: {found}, 最短路径: {shortest_path}") # 输出:找到目标: True, 最短路径: ['A', 'C', 'F']

关键细节

  1. 使用deque作为队列:Python的collections.deque在两端进行添加和删除操作的时间复杂度是O(1),而用listpop(0)是O(n),在数据量大时性能差异巨大。这是BFS实现中的一个经典性能坑
  2. 层次遍历与距离记录:BFS天然适合层序遍历。如果需要知道每个节点距离起点的层数(最短距离),可以在入队时记录。一种常见技巧是不在队列里存路径,而是存(node, distance),或者在每一轮循环开始时记录当前队列长度,一次性处理完一整层节点,层数加一。
  3. 路径重建:上述代码在队列中存储了完整路径,简单但空间开销大(每个路径都是一份拷贝)。更优的方法是使用一个parent字典,记录每个节点是从哪个节点访问过来的。找到目标后,从目标反向回溯到起点,即可重建最短路径。这种方法空间效率更高。
    parent = {start: None} # ... 在BFS循环中 ... if neighbor not in visited: visited.add(neighbor) parent[neighbor] = node # 记录父节点 queue.append(neighbor) # 找到目标后重建路径 path = [] node = target while node is not None: path.append(node) node = parent[node] path.reverse() return True, path

4. 经典应用场景实战解析

理论结合实战才能融会贯通。我们看几个LeetCode上的经典问题,感受DFS和BFS如何大显身手。

4.1 DFS实战:二叉树的所有路径(LeetCode 257)

问题:给定一个二叉树,返回所有从根节点到叶子节点的路径。

分析:这是一个典型的遍历所有可能路径的问题,并且需要记录路径上的节点。DFS(特别是递归DFS)非常适合,因为递归可以自然地携带当前路径状态,并在到达叶子节点时完成一条路径的记录。

递归DFS解法

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right class Solution: def binaryTreePaths(self, root: Optional[TreeNode]) -> List[str]: def dfs(node, path): if not node: return # 将当前节点加入路径 path.append(str(node.val)) # 如果是叶子节点,记录一条完整路径 if not node.left and not node.right: result.append("->".join(path)) else: # 递归探索左右子树 dfs(node.left, path) dfs(node.right, path) # 回溯:在返回上一层递归前,将当前节点从路径中移除 path.pop() result = [] if root: dfs(root, []) return result

要点:这里的path.append()path.pop()体现了DFS的回溯思想。在递归调用前后,我们修改共享的path列表,调用结束后必须恢复原状,以确保返回到父节点时,path状态是正确的。这是解决许多组合、排列、路径问题的通用模板。

4.2 BFS实战:二叉树的层序遍历(LeetCode 102)

问题:给你二叉树的根节点root,返回其节点值的层序遍历。(即逐层地,从左到右访问所有节点)。

分析:题目明确要求“层序”,这正是BFS的拿手好戏。我们需要在BFS的过程中区分出每一层。

BFS层序遍历解法

class Solution: def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]: 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)层序遍历的关键技巧。在开始处理每一层之前,先获取当前队列的长度,这个长度就是当前层节点的数量。随后用这个固定数量的循环,保证只处理当前层的节点,并在循环中将下一层节点入队。循环结束后,队列里就只剩下下一层的节点了。如此往复,完美实现了分层。

4.3 BFS进阶:最短单词路径(LeetCode 127)

问题:给定两个单词(beginWord和endWord)和一个字典wordList,找到从beginWord到endWord的最短转换序列的长度。每次转换只能改变一个字母,且转换过程中的中间单词必须是字典中的单词。

分析:这是一个典型的无权图最短路径问题。每个单词是一个节点,如果两个单词之间只差一个字母,则它们之间有一条边。我们需要从起点单词找到终点单词的最短路径。BFS是首选。

BFS最短路径解法

from collections import deque from typing import List class Solution: def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int: wordSet = set(wordList) # 转为集合,O(1)查找 if endWord not in wordSet: return 0 queue = deque([(beginWord, 1)]) # 队列存储(单词, 当前路径长度) visited = set([beginWord]) while queue: current_word, level = queue.popleft() if current_word == endWord: return level # 生成当前单词所有可能的下一个单词 word_chars = list(current_word) for i in range(len(word_chars)): original_char = word_chars[i] for c in 'abcdefghijklmnopqrstuvwxyz': if c == original_char: continue word_chars[i] = c next_word = ''.join(word_chars) # 如果新单词在字典中且未被访问过 if next_word in wordSet and next_word not in visited: visited.add(next_word) queue.append((next_word, level + 1)) word_chars[i] = original_char # 恢复原字符,准备修改下一个位置 return 0

要点与优化

  1. 图的隐式构建:我们没有显式地构建出整个图的邻接表,因为那可能非常庞大(O(N^2))。而是在BFS过程中,对于每个出队的单词,动态生成所有可能的下一个单词(改变一个字母),并检查是否在字典中。这是一种“用时构建”的策略,大大节省了预处理时间和空间。
  2. 访问标记:同样至关重要,防止重复访问和死循环。
  3. 双向BFS优化:这是一个高级技巧。同时从beginWord和endWord开始进行BFS。当两个BFS相遇时,路径找到。这可以显著减少搜索空间,尤其是在答案路径较长时。其核心思想是每次从节点数较少的那一端进行扩展,代码复杂度会提高,但性能提升明显。

5. 常见陷阱、性能优化与心得

在实际编码和面试中,关于DFS和BFS的坑点不少,这里总结几个高频问题。

5.1 递归深度与栈溢出

这是DFS递归写法最直接的风险。Python默认递归深度限制在1000左右,对于深度很大的树或图(比如一条长链),递归DFS会抛出RecursionError

解决方案

  1. 改用迭代DFS:使用显式的栈(list)来模拟递归过程,不受系统递归深度限制。
  2. 调整递归深度:对于确定深度可控的场景,可以用sys.setrecursionlimit(limit)提高限制,但这只是权宜之计,并且有风险。
  3. 尾递归优化:遗憾的是,Python并不支持尾递归优化。在支持的语言中,将递归写成尾递归形式可以避免栈溢出。

实操心得:在解决算法题时,如果问题规模未知,或者题目给出的测试用例可能包含极端深度的数据,优先考虑迭代DFS或BFS会更安全。尤其是在处理链表、不平衡二叉树等结构时。

5.2 忘记访问标记与重复访问

无论是DFS还是BFS,在遍历(而非树)时,visited集合(或数组)是必不可少的。树是一种特殊的无环连通图,从根开始遍历不会走回头路,所以有时可以省略。但图可能存在环,没有访问标记就会陷入无限循环。

易错场景

  • 克隆图(LeetCode 133)
  • 课程表(检测环,LeetCode 207)
  • 岛屿数量(LeetCode 200)—— 虽然题目是网格,但本质是遍历一个隐式图,也需要标记已访问的单元格(通常通过修改原矩阵为‘0’或使用独立visited)。

解决方案:在编写遍历代码时,养成条件反射:处理节点前,先判断是否已访问;处理完毕后,立即标记为已访问。对于BFS,标记的时机是在入队时(如上文代码所示),这样可以避免同一个节点被多次加入队列。

5.3 BFS队列的选择与性能

前文提到,使用Python的list并通过pop(0)实现队列是低效的,因为pop(0)操作是O(n)的。

错误示范

queue = [start] while queue: node = queue.pop(0) # 性能瓶颈! # ... 处理node ...

正确做法:始终使用collections.deque

from collections import deque queue = deque([start]) while queue: node = queue.popleft() # O(1)操作 # ... 处理node ...

5.4 空间复杂度考量与双向BFS

BFS的空间复杂度在最坏情况下是O(N),即需要存储一整层的节点。对于一种极端情况——完全二叉树,最后一层的节点数约等于总节点数的一半,此时BFS的空间消耗是很大的。而DFS的空间复杂度是O(h),对于平衡二叉树只有O(logN)。

因此,当问题明确要求找最短路径,但图非常宽时,需要警惕BFS的内存消耗。此时可以考虑双向BFS。双向BFS从起点和终点同时开始搜索,当两个搜索方向相遇时停止。理想情况下,它能将搜索空间从指数级减少到平方根级别,大幅节省时间和空间。实现双向BFS的关键是维护两个队列和两个访问集合,并每次选择当前节点数较少的方向进行扩展。

5.5 何时用DFS?何时用BFS?

这是一个永恒的选择题。我的经验法则是:

  • 优先考虑BFS当

    1. 问题明确要求“最短路径”、“最少步骤”、“最近距离”。
    2. 需要进行“层序”或“按距离排序”的处理。
    3. 图的深度可能非常大(如无限状态空间),但目标可能在较浅层,BFS能更快找到。
  • 优先考虑DFS当

    1. 需要遍历所有可能的情况或路径(如排列、组合、子集问题)。
    2. 问题与图的“连通性”、“环检测”、“拓扑排序”相关。
    3. 图的宽度可能非常大(如状态爆炸),但深度有限,DFS的空间开销更可控。
    4. 需要模拟“回溯”的过程(如棋盘类、迷宫类问题)。

很多时候,一个问题既可以用DFS也可以用BFS解决,只是侧重点不同。例如“岛屿数量”,DFS(沉没思想)的代码通常更简洁;而BFS也可以做,代码稍长但逻辑清晰。选择哪种,有时也取决于个人的编码习惯和对问题细节的把握。

最后,无论是DFS还是BFS,核心都是对状态空间的系统化探索。理解它们,就像是掌握了在信息迷宫中导航的两种基本罗盘。多练习,多思考,在遇到新问题时,你就能迅速判断该拿起哪一个罗盘,并熟练地使用它找到答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/29 4:33:54

亚马逊卖家如何用AI技能优化75字符标题,提升转化率

最近在亚马逊运营圈里,75字符的标题限制让不少卖家头疼不已。如何在有限的字数内精准传达产品卖点,同时吸引买家点击,成为提升销量的关键挑战。不少大卖店铺却悄悄实现了销量翻倍,背后的秘密武器正是"AI Skill"的合理配…

作者头像 李华
网站建设 2026/7/29 4:32:48

C++实战:Windows窗口管理与进程交互技术解析

1. 项目概述与核心需求解析最近在技术社区和一些开发者交流群里,经常看到有朋友在讨论一个挺有意思的话题:如何用C解除ClassIn教室的专注模式。这个话题之所以能引起讨论,一方面是因为ClassIn作为一款广泛使用的在线教育软件,其“…

作者头像 李华
网站建设 2026/7/29 4:32:31

基于Micro:bit与ESP32的3D打印教育机器人:从设计到编程全解析

1. 项目概述:为什么我们需要一个“价廉物美”的教育机器人?如果你接触过创客教育或者STEM项目,一定对Micro:bit这块小巧的开发板不陌生。它设计初衷就是为了让编程和硬件交互变得像搭积木一样简单,特别适合中小学生入门。但不知道…

作者头像 李华
网站建设 2026/7/29 4:30:23

基于Romeo控制器的互动搞笑垃圾桶:从硬件选型到状态机编程

1. 项目缘起:从“无聊”到“有趣”的硬件改造不知道你有没有过这样的经历:办公室里或者家里的垃圾桶,就是一个默默无闻的容器,扔垃圾这个动作枯燥又乏味。几年前,我在一个创客空间里第一次看到有人把Arduino和舵机塞进…

作者头像 李华
网站建设 2026/7/29 4:30:20

2026 年最佳笔记本电脑坞站推荐:多类型适配,满足不同需求!

2026 年最佳笔记本电脑坞站推荐:多类型适配,满足不同需求!暑假结束后返校的学生,或是希望提升工作效率的职场人士,一款出色的坞站可能会带来巨大的改变。这类设备,也被称为 Thunderbolt 坞站,能…

作者头像 李华