十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度优先搜索与广度优先搜索:算法核心思想、实现与应用场景全解析

深度优先搜索与广度优先搜索:算法核心思想、实现与应用场景全解析 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(fVisiting 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)关键点visited集合这是图的遍历区别于树必须有的。因为图中可能存在环没有这个集合递归会无限循环下去。递归边界if node in visited: return是递归的终止条件之一。处理顺序print或其它操作发生在递归调用之前这被称为“前序遍历”。你也可以根据需求调整操作发生的位置中序、后序这在树结构中更常见。非递归实现显式栈 有时候递归的深度可能受系统栈大小限制例如图非常深或者我们需要更精细地控制栈的状态这时就需要手动维护一个栈。def dfs_iterative(graph, start): visited set() stack [start] # 显式栈初始化放入起点 while stack: node stack.pop() # 弹出栈顶元素 if node not in visited: print(fVisiting 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(fVisiting node: {node}) # 探索当前节点的所有邻居 for neighbor in graph.get(node, []): if neighbor not in visited: visited.add(neighbor) # **关键**在入队时标记已访问 queue.append(neighbor)与DFS非递归版的细微差别数据结构使用deque而非list。list的pop(0)操作是O(n)的而deque的popleft()是O(1)的对于BFS这种频繁出队的操作性能差异巨大。标记时机这是极易出错的地方在BFS中必须在节点入队时就将其标记为visited。为什么想象一下节点A和节点B都有一个共同的邻居C。当处理A时将C标记为已访问并入队。接着处理B时如果C还没被从队列中取出处理但B又试图将C入队如果没有在入队时标记C就会被重复加入队列。这虽然不会导致错误结果因为出队时会检查visited但会导致队列中存在大量重复节点严重浪费空间在极端情况下可能使空间复杂度从O(n)恶化到O(n^2)。层序遍历信息BFS天然带有“层”的信息。如果需要记录节点所在的层数即距离起点的步数可以在入队时同时存入深度信息。def bfs_with_level(graph, start): visited set([start]) queue deque([(start, 0)]) # (节点, 深度) while queue: node, depth queue.popleft() print(fNode {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(r1, c) dfs(r-1, c) dfs(r, c1) 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)解的性质不一定最短首次找到即最短无权图适用场景检查连通性、拓扑排序、寻找可行解、回溯问题最短路径、层次遍历、扩散问题选择策略求最短路径或最少步数-首选BFS。这是它的核心优势。图非常深但可能很宽-谨慎使用递归DFS可能栈溢出。考虑显式栈或BFS。图非常宽分支因子大-谨慎使用BFS队列可能消耗巨大内存。考虑DFS。需要遍历所有可能解如排列组合-必须用DFS回溯法。BFS无法有效生成所有序列。问题有明确的层次或轮次概念-BFS更直观。只是检查连通性或是否存在路径-两者皆可DFS代码可能更简洁。5.2 常见陷阱与调试技巧陷阱一忘记 visited 集合导致死循环或重复计算这是图遍历中最常见的错误。尤其是在图中存在环的情况下。务必在访问节点后立即标记对于BFS标记时机在入队时。陷阱二DFS递归深度过大Python默认递归深度约1000层。对于深度可能很大的图如一条长链递归DFS会引发RecursionError。解决方案1使用迭代DFS显式栈。解决方案2调整递归深度限制sys.setrecursionlimit(1000000)但这只是权宜之计可能引发栈溢出。陷阱三BFS中错误地使用列表作为队列如前所述使用list的pop(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调试技巧可视化对于小规模图手动画出图然后一步步模拟算法执行在纸上画出栈/队列的变化和visited集合。打印关键信息在循环中打印当前处理的节点、栈/队列的内容、visited集合这是最直接的调试方法。单元测试构造简单的测试用例包括空图、单节点图、带环的图、不连通的图等确保算法在边界情况下也能正常工作。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较大时优势非常明显。实现要点需要两个队列和两个visited字典分别记录从起点和终点访问过的节点及距离。每次迭代选择当前节点数较少的那一边进行扩展以保持平衡。检查新扩展的节点是否出现在另一边的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能找到最短路径在状态空间搜索中的优点。常用于状态空间巨大且深度未知的搜索如棋类游戏。算法流程设置深度限制depth_limit 0。运行深度限制为depth_limit的DFS。即DFS在搜索时如果当前深度超过depth_limit则立即回溯不再深入。如果在当前深度限制内找到目标则返回成功。如果没找到则将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, depth1, 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的队列是“待办事项清单”一个节点一旦被列入清单入队就意味着它即将被处理为了防止它被其他节点重复列入清单必须立刻打上标记。最后关于练习。不要只停留在“看懂了”的层面。找一些经典的题目如迷宫、单词接龙、岛屿数量、二叉树层序遍历等自己动手实现并尝试用两种方法都解一遍。然后分析在特定输入下栈/队列的变化、节点的访问顺序。这个过程能帮你建立起牢固的直觉。当你再遇到复杂的问题时你就能下意识地判断出该用哪种搜索策略作为你解题的基石或者如何将两者结合演化出更高效的算法。
返回列表