ARTICLE DETAIL

资讯详情

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

深度优先搜索与广度优先搜索:图遍历的核心思想、代码实现与实战选型

深度优先搜索与广度优先搜索:图遍历的核心思想、代码实现与实战选型 1. 项目概述从迷宫到网络理解图的遍历最近在社区里看到不少朋友在讨论图的遍历特别是DFS深度优先搜索和BFS广度优先搜索。无论是“3*3迷宫(全0)的dfs的路径是什么意思”这样的具体问题还是“连通分量”这类抽象概念都指向一个核心我们如何系统地“走遍”一个图结构并从中获取我们需要的信息。这不仅仅是算法竞赛的考点更是解决无数实际工程问题的基石。从社交网络的好友推荐六度空间理论、网页爬虫的抓取策略、网络路由的路径发现到游戏地图的寻路AI背后都离不开这两种最基础、最强大的图遍历思想。我自己在早期做路径规划项目时也曾对DFS和BFS的选择感到困惑。用DFS代码写起来简单但一不小心就掉进“死胡同”出不来用BFS感觉能稳扎稳打但内存消耗又让人头疼。后来经过大量实战才明白没有最好的算法只有最合适的场景。这篇文章我就结合自己踩过的坑和积累的经验带你彻底吃透图的遍历。我会从最直观的“走迷宫”例子入手拆解DFS和BFS每一步的思考逻辑然后深入到代码实现、性能分析和那些教科书上不会讲的调试技巧。无论你是正在啃《算法导论》的学生还是需要解决实际连通性问题的开发者相信都能从中获得可以直接“抄作业”的干货。2. 核心思想拆解深度与广度的哲学在深入代码之前我们必须像建筑师理解蓝图一样先吃透DFS和BFS的核心设计哲学。这两种算法代表了两种截然不同的探索世界的策略理解了这个你才能在做技术选型时毫不犹豫。2.1 深度优先搜索一条路走到黑DFS的策略可以用一个词概括递归与回溯。它的核心思想是从起点开始选择一条边尽可能深地探索下去直到这条路径的尽头无法继续前进然后“回溯”到上一个分岔路口选择另一条未探索的路径继续深入。为什么是“深度优先”想象你在探索一个巨大的地下洞穴。DFS就像是一个固执的探险家他看到一个洞口就钻进去一直走到洞穴尽头标记好所有岔路然后原路返回到最近的一个未探索的岔路口再钻进去。他优先保证的是对单条路径的完整探索。核心数据结构栈无论是显式使用栈还是利用函数调用栈实现递归DFS都依赖于栈“后进先出”的特性。这完美契合了“走到尽头再回溯”的行为你最后探索的分支正是你需要最先回溯处理的分支。注意很多新手容易混淆递归和迭代实现的DFS。递归实现利用了系统的函数调用栈代码简洁但深度过大时可能导致栈溢出。显式栈的迭代实现更可控但代码稍复杂。理解它们本质相同至关重要。一个生活化类比破解密码锁你有一个3位数的密码锁每位是0-9。DFS的策略是先固定第一位为0然后尝试第二位为0再尝试第三位从0到9。穷尽所有第三位后回溯将第二位改为1再穷尽所有第三位……以此类推。它是在深度上从高位到低位进行穷举。2.2 广度优先搜索层层递进的扩张BFS的策略则相反它追求的是公平与层次。从起点开始先访问所有与起点直接相连的顶点然后再访问这些顶点的邻居即距离起点为2的顶点以此类推像水波一样一圈圈扩散出去。为什么是“广度优先”继续地下洞穴的比喻BFS像是一个指挥有序的勘探队。队长先派第一批队员探索起点直接相连的所有洞口并回报情况。等所有直接洞口探索完毕队长再命令第一批队员的队员即第二批去探索这些新洞口的直接连接洞。它优先保证的是对所有“距离”起点同等近的顶点进行公平访问。核心数据结构队列队列“先进先出”的特性是BFS的天然伴侣。起点先入队访问后将其所有未访问的邻居入队。这样先被访问的顶点其邻居也会先被访问严格保证了按层次遍历的顺序。一个生活化类比社交网络的传播你想知道通过多少层朋友关系可以认识某个名人。BFS的做法是先列出你的所有直接朋友第一层问他们是否认识该名人。如果不认识再让你的朋友们列出他们的朋友第二层你去询问这批人……这个过程一定是按关系亲疏层数由近及远进行的。2.3 核心对比与选型逻辑理解了思想我们就能从原理上对比并指导选型特性维度深度优先搜索广度优先搜索核心思想递归回溯钻探到底层次扩散水波涟漪数据结构栈 (Stack)队列 (Queue)解的空间适用于发现一条路径、拓扑排序、检测环适用于寻找最短路径边权相等时、连通分量内存消耗与深度成正比。路径长时消耗小但递归深可能栈溢出。与宽度成正比。需要存储当前层的所有节点在宽图上消耗大。经典应用迷宫所有路径、排列组合、图的连通性检测、拓扑排序最短步数迷宫、社交网络度数、广播网络、网页爬虫选型心法 当你需要“找到任意一个解”或“遍历所有可能状态”如全排列或者问题空间很深但很窄时优先考虑DFS。当你明确需要“最短路径”或“最少步骤”或者需要按层次处理节点时BFS是唯一选择。例如走迷宫找出口如果只问“能否走出去”DFS和BFS都可以但如果问“最短的出路是哪条”就必须用BFS。3. 从理论到代码邻接表下的实现详解理论说得再透不如一行代码。我们以最常用的邻接表方式存储图适合稀疏图分别用递归、迭代实现DFS用队列实现BFS。这里我假设图是无向且连通或弱连通的顶点从0开始编号。3.1 深度优先搜索的两种实现首先我们需要一个图。这里用一个vectorvectorint来表示邻接表graph[i]存储顶点i的所有邻居。#include iostream #include vector #include stack using namespace std; class Graph { private: vectorvectorint adjList; // 邻接表 int numVertices; public: Graph(int n) : numVertices(n), adjList(n) {} void addEdge(int u, int v) { adjList[u].push_back(v); adjList[v].push_back(u); // 无向图双向添加 } const vectorint getNeighbors(int v) const { return adjList[v]; } int getNumVertices() const { return numVertices; } };3.1.1 递归实现DFS递归实现是最直观也最能体现DFS“深入”本质的方式。void dfsRecursive(int node, vectorbool visited, const Graph graph) { // 1. 访问当前节点 cout node ; visited[node] true; // 2. 递归访问所有未访问的邻居 for (int neighbor : graph.getNeighbors(node)) { if (!visited[neighbor]) { dfsRecursive(neighbor, visited, graph); // 递归深入 } } // 函数结束自动回溯到调用者上一层 } void dfs(const Graph graph, int start) { vectorbool visited(graph.getNumVertices(), false); dfsRecursive(start, visited, graph); }关键点解析visited数组是灵魂。它防止重复访问陷入死循环尤其是在有环的图中。递归调用dfsRecursive(neighbor, ...)就是“选择一条路走下去”。当for循环结束当前函数返回就是“回溯”到上一个节点。实操心得递归DFS的访问顺序取决于邻接表中邻居的存储顺序。如果你需要特定的遍历顺序如按节点编号务必先对邻居列表进行排序。3.1.2 显式栈迭代实现DFS对于深度可能非常大的图为了避免系统栈溢出我们需要用自己维护的栈来模拟递归过程。void dfsIterative(const Graph graph, int start) { vectorbool visited(graph.getNumVertices(), false); stackint s; // 初始化起点入栈 s.push(start); // 注意此时不要标记起点为已访问 while (!s.empty()) { int node s.top(); s.pop(); // **关键检查**弹出栈顶后才检查是否访问过 if (visited[node]) { continue; // 如果已访问跳过 } // 访问该节点 cout node ; visited[node] true; // 将其所有未访问的邻居入栈 // 注意为了模拟递归的顺序可能需要逆序入栈取决于你对顺序的要求 const vectorint neighbors graph.getNeighbors(node); for (auto it neighbors.rbegin(); it ! neighbors.rend(); it) { if (!visited[*it]) { s.push(*it); // 这里不标记 visited标记是在弹出时进行的。 } } } }为什么弹出时才标记visited这是迭代DFS最容易出错的地方。在递归中我们一进入函数就标记visited。在迭代中同一个节点可能被多次压入栈中通过不同的父节点。如果我们在入栈时就标记那么后压入的同一节点就会被忽略这可能错过一些合法的访问路径在某些允许重复访问的问题中。而在弹出时标记保证了每个节点只会被访问一次但允许其被发现多次。对于标准的图遍历每个节点访问一次这种“延迟标记”是正确且通用的写法。如果你想在入栈时标记必须确保每个节点只会被压入栈一次这通常需要额外的数据结构来记录“是否在栈中”实现更复杂。3.2 广度优先搜索的实现BFS的实现范式非常统一几乎总是使用队列。#include queue void bfs(const Graph graph, int start) { vectorbool visited(graph.getNumVertices(), false); queueint q; // 初始化起点入队并标记 q.push(start); visited[start] true; // BFS通常在入队时标记 while (!q.empty()) { int levelSize q.size(); // 记录当前层的节点数可选 // 遍历当前层的所有节点 for (int i 0; i levelSize; i) { int node q.front(); q.pop(); cout node ; // 访问节点 // 将下一层的未访问邻居入队 for (int neighbor : graph.getNeighbors(node)) { if (!visited[neighbor]) { q.push(neighbor); visited[neighbor] true; // **入队时立即标记** } } } // 此处可以输出换行直观显示层次 cout endl; } }BFS的关键细节入队时标记这是BFS与迭代DFS的一个重要区别。因为队列保证每个节点只会被处理一次在入队时标记可以防止同一个节点被多次加入队列减少不必要的重复判断和内存占用。层次信息通过levelSize我们可以轻松地知道当前正在处理的是第几层的节点。这对于求解“最短路径步数”等问题非常有用。在打印时换行能直观看到遍历的层次。最短路径BFS天然地按距离起点的边数层次由近及远访问节点。因此当图中所有边权值相等时BFS第一次访问到某个节点的路径就是从起点到该节点的最短路径。要记录路径只需在访问每个节点时同时记录它的“前驱节点”即可。4. 实战应用场景深度剖析懂了怎么写更要懂什么时候用。下面我们结合几个典型场景看看DFS和BFS如何大显神通。4.1 场景一迷宫问题这是最经典的例子。“3*3迷宫(全0)的dfs的路径是什么意思”假设一个3x3网格0代表可走1代表墙。从(0,0)走到(2,2)求所有路径。DFS解法DFS会探索一条路直到终点或死路然后回溯。它能找出所有可能的路径。记录路径时需要在递归调用前将当前点加入路径向量递归返回后从向量中弹出回溯。这样当到达终点时路径向量里保存的就是一条完整路径。路径含义DFS输出的“路径”序列是它探索过程中依次经过的格子顺序。由于回溯这个序列会很长包含所有走过的岔路。你需要专门用一个数组在到达终点时记录快照才是真正的有效路径。BFS解法BFS按步数层层推进第一次到达终点的路径就是最短路径假设每步移动代价相同。它通常不直接输出所有路径而是输出最短步数和一条最短路径。代码片段示意DFS找所有路径vectorvectorpairint, int allPaths; // 存储所有路径 vectorpairint, int currentPath; void dfsMaze(int x, int y, vectorvectorint maze, vectorvectorbool visited) { if (x 0 || x maze.size() || y 0 || y maze[0].size() || maze[x][y] 1 || visited[x][y]) return; // 进入当前点 currentPath.push_back({x, y}); visited[x][y] true; if (x targetX y targetY) { allPaths.push_back(currentPath); // 找到一条路径 } else { // 四个方向递归探索 dfsMaze(x1, y, maze, visited); dfsMaze(x-1, y, maze, visited); dfsMaze(x, y1, maze, visited); dfsMaze(x, y-1, maze, visited); } // 回溯离开当前点 visited[x][y] false; currentPath.pop_back(); }4.2 场景二连通分量与岛屿问题“bfs 连通分量”是另一个热点。连通分量是指图中一个极大的连通子图。求连通分量数量是经典的“岛屿数量”问题LeetCode 200的图论版本。核心思路遍历所有节点。如果遇到一个未访问的节点就从它开始进行一次完整的DFS或BFS这次遍历所能到达的所有节点构成一个连通分量。计数器加1。然后继续寻找下一个未访问的节点。DFS vs BFS在这个问题上两者完全等价都能正确标记一个连通分量内的所有节点。选择依据通常是图的特点深度大用BFS防栈溢出或个人编码习惯。int countComponents(const Graph graph) { int n graph.getNumVertices(); vectorbool visited(n, false); int count 0; for (int i 0; i n; i) { if (!visited[i]) { count; // 使用DFS或BFS遍历这个连通分量标记所有节点为visited // dfsRecursive(i, visited, graph); bfs(graph, i); // 注意这里的bfs需要适配仅遍历未访问的 } } return count; }4.3 场景三拓扑排序拓扑排序针对有向无环图用于确定任务的执行顺序。DFS是实现拓扑排序非常优雅的方式。DFS解法对每个未访问节点进行DFS。在DFS递归函数返回之前将当前节点压入一个栈。最终将栈中元素依次弹出得到的序列就是拓扑排序的一个逆序或正序取决于压栈顺序。为什么是DFS因为DFS的特性是必须将一个节点的所有后代都访问完毕该节点自身的递归才会结束。这正好符合“一个任务必须在它的所有依赖任务完成后才能进行”的语义。BFS也可以实现拓扑排序Kahn算法通过不断移除入度为0的节点这里不展开。5. 性能优化与避坑指南在实际项目中直接套用模板常常会出问题。下面是我总结的几个关键陷阱和优化技巧。5.1 栈溢出与递归深度这是递归DFS的阿喀琉斯之踵。当图是一条长长的链时递归深度等于节点数很容易触发栈溢出。解决方案改用迭代DFS使用显式栈内存通常分配在堆上容量远大于系统调用栈。限制递归深度如果问题性质允许可以设置一个最大递归深度。使用BFS如果问题可以用BFS解决优先使用BFS其空间复杂度通常与宽度相关更可控。5.2 访问标记的时机与状态如前所述visited标记的时机是易错点。DFS递归一进入函数就标记。DFS迭代通用模板从栈中弹出节点时标记。BFS节点入队时标记。踩坑实录在一次解决“图中两点间所有简单路径”的问题时我使用了迭代DFS并在入栈时标记visited结果漏掉了许多路径。因为从A到B可能有两条路径共享中间节点C如果在第一次经过C时就标记为已访问第二条路径就无法通过C了。正确的做法是使用“路径上的visited”或者回溯时取消标记。5.3 邻接表的遍历顺序邻接表中邻居的顺序会影响DFS/BFS的访问序列。如果问题要求特定顺序如字典序最小路径必须在遍历前对每个节点的邻居列表进行排序。这是一个常见的性能与正确性权衡点。// 在构建图或遍历前排序 for (auto neighbors : adjList) { sort(neighbors.begin(), neighbors.end()); // 升序 }5.4 处理大规模图时的内存与效率当图非常大如社交网络图时数据结构使用vectorvectorint可能内存不连续可以考虑用单一大数组存储所有边配合索引数组CSR格式对缓存更友好。visited数组如果节点ID非常稀疏可以用unordered_set代替vectorbool但查询速度会慢。有时可以用vectorint存储时间戳来判断是否为本轮访问节省清空数组的时间。并行化对于BFS每一层的节点可以独立访问其邻居适合并行化处理。而DFS的深度优先特性使其难以并行。6. 从遍历到算法DFS/BFS的进阶思考掌握了基础的遍历我们可以将其作为基石解决更复杂的问题。6.1 双向BFS当起点和终点都已知且需要找最短路径时双向BFS能大幅减少搜索空间。从起点和终点同时开始BFS当两个搜索 frontier 相遇时路径找到。理论搜索空间从 O(b^d) 降到 O(b^(d/2))其中b是分支因子d是深度。实现要点使用两个队列和两个visited字典或一个字典但记录来源。每次迭代选择当前节点数较少的方向进行扩展检查新扩展的节点是否出现在另一个方向的visited集合中。6.2 带权图的最短路径BFS只能处理边权相等的情况。对于带权图需要Dijkstra算法边权非负或Bellman-Ford算法。但有趣的是Dijkstra算法可以看作是BFS的广义形式——它使用优先队列最小堆代替普通队列每次扩展当前距离起点最近的节点。而BFS可以看作是边权为1时的Dijkstra特例。6.3 回溯法与DFS回溯法本质是一种特殊的DFS用于在解空间树中搜索所有解。它和图的DFS共享“尝试-回溯”的核心思想。区别在于回溯法在搜索树上进行每个节点代表一个部分解而图的DFS是在已有的图结构上遍历。很多组合问题八皇后、数独都是用回溯法DFS思想解决的。图的遍历DFS和BFS远不止是教科书上的两个算法名字。它们是两种强大的问题解决范式。DFS教你如何专注深入穷尽一条线索的所有可能BFS教你如何步步为营以最小的代价覆盖全局。真正掌握它们不在于背诵代码模板而在于理解其背后的思想并在面对具体问题时能清晰地判断“此时我该深度优先还是广度优先”这种判断力需要在大量实践中反复锤炼。下次当你遇到需要遍历状态空间的问题时不妨先停下来画一画想想是“一条路走到黑”更合适还是“广撒网”更高效。
返回列表