资讯中心

深度优先搜索与广度优先搜索:图遍历核心算法详解与应用场景

📅 2026/8/15 3:02:49
深度优先搜索与广度优先搜索:图遍历核心算法详解与应用场景
1. 项目概述从迷宫到社交网络图的遍历无处不在聊到“图的遍历”很多刚接触数据结构的朋友可能会觉得这又是一个枯燥的理论概念。但如果你玩过走迷宫的游戏或者用过社交软件的“可能认识的人”功能甚至在网上查找从一个网页跳转到另一个网页的所有路径那你其实已经在和图的遍历打交道了。简单来说图就是由一堆“点”称为顶点或节点和连接这些点的“线”称为边组成的一种结构用来描述事物之间的关系。而遍历就是系统地访问图中每一个顶点确保不重不漏就像你要探索一个未知区域的所有角落。这次我们聚焦两种最经典、也最核心的遍历策略深度优先搜索DFS和广度优先搜索BFS。别看名字听起来有点学术它们的核心思想非常直观。DFS就像一个人走迷宫遇到岔路就选一条道走到黑直到碰壁再原路返回尝试下一个岔路这是一种“不撞南墙不回头”的纵向深入策略。而BFS则像一场平静湖面投入石子激起的涟漪从起点开始先访问所有直接相邻的点然后再访问这些相邻点的相邻点一层层向外扩散是一种“稳扎稳打”的横向扩展策略。理解DFS和BFS绝不仅仅是为了应付考试。它们是解决无数实际问题的基石算法。比如DFS常用于查找路径、检测环路、拓扑排序安排任务执行顺序BFS则是求解最短路径在边权相等的情况下、网络爬虫抓取网页、社交网络中的“六度空间”理论验证等场景的首选。无论你是准备技术面试还是希望真正弄懂算法如何解决实际问题吃透这两种遍历方式都至关重要。接下来我们就抛开晦涩的教科书定义用最直白的语言和具体的例子把DFS和BFS的里里外外、前世今生彻底讲明白。2. 核心思想与算法原理深度拆解要真正掌握DFS和BFS不能只停留在“DFS用栈BFS用队列”的机械记忆上。必须深入理解它们背后的“世界观”和“方法论”明白为什么在这种场景下用DFS更合适在另一种场景下BFS又成了不二之选。2.1 深度优先搜索DFS一条道走到黑的探险家DFS的策略精髓在于“深度优先”。想象你正在探索一个多岔路的地下洞穴你的目标是摸清每一个洞室。DFS式的探索者会这么做从入口起点开始随便选择一条通道走下去每到一个新的洞室顶点就标记“已探索”然后继续从当前洞室选择一条未走过的通道深入。这个过程会一直持续直到你走进一个死胡同没有未访问的邻接顶点为止。此时你会原路返回回溯到上一个有未探索通道的洞室然后选择另一条通道继续深入。这个过程天然地适合用递归或者显式栈来实现。递归本身就是函数调用栈的体现完美契合了“深入”和“回溯”的需求。其核心伪代码逻辑可以概括为访问当前顶点v并标记为已访问。对于v的每一个未被访问的邻接顶点w递归地调用DFS(w)。这种策略会导致搜索路径形成一条很长的“链”优先往纵深发展。它的优势在于实现简洁尤其适合寻找一条可行路径、遍历树或图的结构如计算连通分量并且空间复杂度相对较低主要消耗在递归栈上在最坏情况下图退化为一条链为 O(V)V为顶点数。但它的缺点也很明显不一定能找到最短路径。因为它可能一开始就扎进一个很深的分支绕了远路才发现目标。此外在图中存在环路且未妥善处理已访问状态时递归版本的DFS可能导致栈溢出。2.2 广度优先搜索BFS层层递进的侦察兵BFS的策略核心在于“广度优先”。同样以探索洞穴为例BFS式的侦察兵会这样做从入口开始先不急着深入而是把入口的所有直接相连的洞室第一层邻居全部探索一遍并做好标记。等这一层所有洞室都探索完毕后再以这些洞室为新的起点去探索它们的直接邻居第二层注意跳过已经标记过的洞室。如此一层层向外推进直到所有可达的洞室都被探索完毕。这个过程天然地需要用到队列这个数据结构。队列“先进先出”的特性正好保证了“先被发现的顶点先被访问”的层序逻辑。其核心伪代码逻辑如下将起点s放入队列并标记为已访问。当队列非空时取出队首顶点v并访问之。将v的所有未被访问的邻接顶点放入队列并标记为已访问。这种像水面波纹一样扩散的方式确保了当BFS首次访问到某个顶点时它所经过的路径一定是从起点到该顶点的最短路径假设每条边的权重或代价相同。这是BFS一个极其重要的性质。它的空间复杂度在最坏情况下需要存储一整层的顶点对于稀疏图可能接近O(V)对于稠密图或完全图则会更大。2.3 DFS与BFS的对比与选型指南光理解各自原理还不够关键在于知道什么时候该用谁。下面这个表格从多个维度进行了对比特性维度深度优先搜索 (DFS)广度优先搜索 (BFS)核心数据结构栈 (递归调用栈或显式栈)队列遍历顺序纵深优先探索单条路径到底层序优先探索完当前层所有顶点再下一层解的性质找到的路径不一定最短首次找到的路径即为最短路径等权图空间复杂度O(V) (递归深度)O(V) (最坏情况队列大小)经典应用场景路径查找、环路检测、拓扑排序、连通分量最短路径等权、连通分量、层次遍历实现倾向递归实现非常简洁直观通常用迭代队列实现类比走迷宫一条路走到黑涟漪扩散层层推进选型心法当你需要检查可达性、寻找任何一条路径、处理有依赖关系的任务排序拓扑排序或者空间资源紧张时可以优先考虑DFS。而当你问题的核心是**“最短距离”、“最少步数”或者需要按层次处理节点**如社交网络的好友推荐层级时BFS通常是更优甚至唯一的选择。例如解决“3*3迷宫(全0)的dfs的路径是什么意思”这类问题DFS给出的是一条从起点到终点的可行路径但很可能绕远而如果用BFS得到的就是步数最少的唯一最短路径。3. 算法实现细节与代码实战理解了思想我们就要动手实现。这里我会分别用递归、迭代两种方式展示DFS用队列实现BFS并提供详细的代码注释和复杂度分析。我们假设图使用邻接表表示这是最通用和高效的方式之一。3.1 深度优先搜索DFS的两种实现方式首先定义图的基本结构以C为例其他语言思想一致#include iostream #include vector #include list using namespace std; class Graph { int V; // 顶点数 vectorlistint adj; // 邻接表 public: Graph(int V) : V(V), adj(V) {} void addEdge(int v, int w) { adj[v].push_back(w); } // 添加有向边 v-w // DFS 和 BFS 方法将在这里实现 };3.1.1 递归实现最直观递归实现利用了系统调用栈代码极其简洁最能体现DFS“深入”的本质。class Graph { // ... 同上 ... void DFSUtil(int v, vectorbool visited) { // 标记当前节点为已访问并输出 visited[v] true; cout v ; // 递归访问所有未访问的邻接顶点 for (int neighbor : adj[v]) { if (!visited[neighbor]) { DFSUtil(neighbor, visited); } } // 当这个函数返回时意味着从v出发能深入访问的所有顶点都已访问完毕自动回溯到上一层调用者。 } public: void DFS(int start) { vectorbool visited(V, false); // 访问标记数组 DFSUtil(start, visited); } };递归DFS要点visited数组至关重要防止重复访问陷入无限循环尤其是在有环图中。递归调用DFSUtil(neighbor, visited)就是“深入”的过程。函数返回即代表“回溯”无需显式操作。时间复杂度O(V E)每个顶点和每条边都被访问一次。空间复杂度O(V)主要是递归栈的深度和visited数组。3.1.2 迭代实现显式栈迭代实现手动维护一个栈模拟递归过程。这对于避免递归深度过大导致的栈溢出很有帮助。class Graph { // ... 同上 ... public: void DFS_Iterative(int start) { vectorbool visited(V, false); stackint s; s.push(start); while (!s.empty()) { int v s.top(); s.pop(); // 注意弹出的顶点可能已在更早的层级被访问过通过其他路径 if (!visited[v]) { visited[v] true; cout v ; // 将邻接点逆序压栈保证与递归顺序一致可选 // 常规顺序是正序这里为了演示使用逆序以便与递归输出对比 vectorint neighbors(adj[v].begin(), adj[v].end()); for (auto it neighbors.rbegin(); it ! neighbors.rend(); it) { if (!visited[*it]) { s.push(*it); } } } } } };迭代DFS要点核心是用栈s来保存待访问的顶点。弹出栈顶顶点v如果未访问则处理它。将v的未访问邻接顶点压入栈中。这里有一个关键细节由于栈是后进先出为了达到和递归类似的“一条路走到黑”的顺序我们通常将邻接表逆序压栈这样第一个邻接点会最后被弹出从而优先深入最后一个邻接点分支。当然遍历顺序在DFS中并非固定取决于压栈顺序。需要检查if (!visited[v])因为同一个顶点可能通过不同路径被多次压入栈中。3.2 广度优先搜索BFS的队列实现BFS的实现范式非常固定核心就是队列。class Graph { // ... 同上 ... public: void BFS(int start) { vectorbool visited(V, false); queueint q; visited[start] true; q.push(start); while (!q.empty()) { int v q.front(); q.pop(); cout v ; // 访问v的所有未访问邻接点并加入队列 for (int neighbor : adj[v]) { if (!visited[neighbor]) { visited[neighbor] true; // **关键入队时标记已访问** q.push(neighbor); } } } } };BFS实现要点核心数据结构是队列q。一个至关重要的优化在将邻接顶点neighbor加入队列的同时就将其标记为visited。而不是等到从队列中取出时才标记。为什么因为如果等到取出时才标记同一个顶点可能会被多个上层顶点重复加入队列导致队列中存在大量重复节点极大增加时间和空间开销在最坏情况下复杂度会恶化。取出队首顶点v后访问它然后将其所有未访问的邻接点入队。这个过程保证了严格的层次遍历顺序。时间复杂度同样是 O(V E)每个节点入队出队一次每条边被检查一次。空间复杂度O(V)主要是队列和visited数组的开销。实操心得在编写BFS时visited标记的时机是新手最容易出错的地方。务必牢记“入队即标记”的原则。你可以这样理解一旦一个节点被“发现”即成为某个已访问节点的邻居它就立刻被安排了访问次序进入队列为了防止它被再次“发现”和重复安排必须立刻打上标记。4. 经典应用场景与实战案例剖析懂了原理和实现我们来看看它们如何大显身手。通过具体案例你能更深刻地体会两者的差异和适用性。4.1 案例一迷宫最短路径问题BFS的主场问题描述给定一个N*M的二维网格迷宫0表示可通行空地1表示障碍物。从左上角(0,0)出发走到右下角(N-1, M-1)求最短路径步数只能上下左右移动每一步移动算一步。为什么用BFS因为每一步移动代价相同都是1BFS首次到达目标点时经过的层数就是最短步数。解决方案将网格每个格子看作图的一个顶点。如果两个相邻格子都是0则在它们之间连一条无向边代价为1。从起点(0,0)开始进行BFS。在BFS过程中需要记录每个格子是从哪个格子访问过来的前驱或者直接记录到达该格子的步数。当BFS首次访问到终点(N-1, M-1)时当前的步数就是最短路径长度。如果需要输出具体路径可以通过记录的前驱信息反向回溯。代码框架示意int shortestPathBinaryMatrix(vectorvectorint grid) { if (grid[0][0] 1) return -1; int n grid.size(); vectorvectorint dirs {{-1,0},{1,0},{0,-1},{0,1}}; // 上下左右 queuepairint, int q; vectorvectorint dist(n, vectorint(n, -1)); // 记录最短步数-1表示未访问 q.push({0, 0}); dist[0][0] 1; // 起点步数为1 while (!q.empty()) { auto [x, y] q.front(); q.pop(); if (x n-1 y n-1) return dist[x][y]; // 找到终点返回步数 for (auto d : dirs) { int nx x d[0], ny y d[1]; if (nx 0 nx n ny 0 ny n grid[nx][ny]0 dist[nx][ny]-1) { dist[nx][ny] dist[x][y] 1; q.push({nx, ny}); } } } return -1; // 无法到达终点 }4.2 案例二寻找图中所有连通分量DFS/BFS均可问题描述给定一个无向图可能不连通找出它所有的连通分量。连通分量是指图中最大的连通子图即子图中任意两点之间都有路径可达。解决方案这是一个典型的遍历应用。无论DFS还是BFS都能从某个起点出发“扫荡”掉它所在的整个连通分量。我们只需要对图中所有未被访问的顶点依次启动遍历每次启动都意味着发现了一个新的连通分量。DFS实现思路class Graph { // ... 同上 ... void findConnectedComponents() { vectorbool visited(V, false); int componentId 0; for (int v 0; v V; v) { if (!visited[v]) { cout 连通分量 componentId : ; DFSUtil(v, visited); // 复用之前的DFS递归函数 cout endl; } } } };说明外层的for循环确保每个顶点都被检查。当遇到一个未访问的顶点v就以它为起点进行一次完整的DFS或BFS这次遍历访问到的所有顶点就构成了一个连通分量。然后继续循环寻找下一个未访问的起点。4.3 案例三拓扑排序DFS的典型应用问题描述给定一个有向无环图DAG将所有顶点排成一个线性序列使得对于图中的每一条有向边u - v在序列中u都出现在v之前。这种排序称为拓扑排序常用于任务调度、课程安排等有依赖关系的场景。为什么用DFSDFS天然的回溯过程非常适合处理依赖关系。当一个顶点所有的后继依赖它的任务都处理完毕后这个顶点本身才算处理完毕可以加入序列。这个“处理完毕”的时机正好发生在DFS递归函数返回之前。DFS实现拓扑排序Kahn算法基于BFS此处展示DFS版class Graph { // ... 同上 ... bool topologicalSortUtil(int v, vectorbool visited, vectorbool recStack, listint order) { visited[v] true; recStack[v] true; // 标记当前递归栈中的顶点用于检测环 for (int neighbor : adj[v]) { if (!visited[neighbor]) { if (!topologicalSortUtil(neighbor, visited, recStack, order)) return false; // 发现环 } else if (recStack[neighbor]) { // 后向边存在环 return false; } } recStack[v] false; // 从递归栈中移除 order.push_front(v); // **关键在递归返回前将顶点加入顺序表头部** return true; } public: bool topologicalSort(listint order) { vectorbool visited(V, false); vectorbool recStack(V, false); // 递归栈记录 order.clear(); for (int i 0; i V; i) { if (!visited[i]) { if (!topologicalSortUtil(i, visited, recStack, order)) { cout 图中存在环无法进行拓扑排序 endl; return false; } } } return true; } };关键点order.push_front(v)这行代码在递归函数返回前执行。这意味着一个顶点只有在它的所有后代顶点都被“安排好”递归调用完成之后才会被加入到排序序列的前端。最终得到的order列表就是一个从“依赖项”到“被依赖项”的拓扑排序。同时利用recStack检测环的存在因为有环图无法进行拓扑排序。5. 常见陷阱、优化技巧与面试高频考点在实际编码和面试中仅仅写出标准模板是不够的很多细节决定成败。这里我总结了一些容易踩的坑和提升效率的技巧。5.1 通用陷阱与注意事项忘记已访问标记Visited Array这是最致命的错误会导致在存在环的图中陷入无限循环递归栈溢出或程序死循环。无论是DFS还是BFSvisited数组或集合都是必需品。BFS中重复入队如前所述BFS必须在节点入队时就标记为已访问而不是出队时。否则一个节点可能被多个父节点重复加入队列造成巨大的性能浪费和错误结果。递归DFS的栈溢出对于顶点数非常多或者图深度很大的情况例如一条长链递归DFS可能导致调用栈溢出。此时应使用迭代DFS显式栈因为堆内存通常比栈内存大得多。邻接表与邻接矩阵的选择邻接表适合稀疏图边数远小于顶点数的平方空间复杂度O(VE)遍历邻居高效。邻接矩阵适合稠密图或需要快速判断任意两点间是否有边的场景空间复杂度O(V^2)。在遍历算法中使用邻接表是更通用的选择。有向图与无向图的边处理在添加边时如果是无向图调用一次addEdge(v, w)需要同时添加v-w和w-v。很多题目默认网格上下左右移动是无向的但实际编码时我们通过检查四个方向来隐式处理了这种“双向”关系如迷宫案例所示。5.2 性能优化与进阶技巧双向BFSBidirectional BFS当起点和终点都已知且图规模很大时可以从起点和终点同时开始BFS。当两个搜索的“前沿”相遇时路径即被找到。这能极大减少搜索空间从O(b^d)降到O(b^(d/2))其中b是分支因子d是路径深度。常用于字词接龙、社交网络最短关系链等场景。使用数组代替容器类在性能要求极高的竞赛或场景中用原生的int数组、vectorint代替queueint、stackint手动维护头尾指针可以带来常数级别的性能提升。例如用vectorint q(N)和int front0, rear0模拟队列。状态压缩在一些问题中顶点的状态可能需要用一个结构体或元组表示如坐标额外属性。如果状态空间不大可以考虑将状态编码成一个整数位运算这样能加快访问速度也便于使用visited数组或位集bitset进行标记。迭代加深搜索IDS这是DFS和BFS思想的结合。它限定DFS的搜索深度进行深度受限的DFS。如果没找到解就增加深度限制再次进行DFS。这样既能获得BFS找到最短路径的优点在等权图中又能利用DFS空间开销小的优势。常用于解谜题、棋类游戏等状态空间未知或极大的情况。5.3 面试高频考点与解题思路面试中图的遍历很少直接考模板而是融入具体问题。你需要快速识别问题本质并选择正确的遍历策略。“最短路径”关键词如果问题描述中出现“最少步数”、“最短距离”且移动代价均等立刻想到BFS。例如“腐烂的橘子”每个新鲜橘子被腐烂所需的最短时间、“打开转盘锁”从初始状态到目标状态的最少转动次数。“所有可能路径”或“是否存在路径”如果只是问是否存在一条路径或需要列出所有路径DFS更合适。因为BFS在找到一条路径后会停止如果只求一条而DFS可以方便地记录路径并回溯枚举所有可能性。例如“二叉树的所有路径”扩展到“图的所有路径”。“依赖关系”或“顺序安排”看到任务执行有先后顺序、课程有先修要求立刻想到拓扑排序。判断是否有环DFS检测后向边或BFS计算入度和给出一个可行序列是常考点。“连通性”问题岛屿数量、被围绕的区域、朋友圈并查集也可解等本质都是求连通分量。DFS/BFS均可通常写法简洁用于“染色”或标记整个连通区域。复杂状态搜索有时图的顶点不是一个简单的整数而是一个状态如字符串、数组等。你需要将状态抽象成顶点状态间的转换抽象成边然后应用BFS求最短转换次数。例如“单词接龙”中每个单词是一个顶点相差一个字母的单词间有边。面对一道新题我的习惯性思考路径是先判断它是不是一个“图”问题事物和关系。如果是再判断是求最短步数BFS还是找路径/枚举DFS或是处理依赖拓扑排序。这个判断过程往往比编码本身更重要。最后再分享一个调试小技巧在实现遍历算法时尤其是处理二维网格问题可以先将visited数组的标记过程可视化打印出来看看遍历的顺序是否符合预期DFS是一条深线BFS是一圈圈扩散。这能帮你快速定位是边界条件错误还是visited标记时机不对这类逻辑问题。图形化的理解永远比抽象的代码更直观。