C++图搜索算法精讲:BFS、DFS与双向BFS实战指南

发布时间:2026/7/23 1:23:09

C++图搜索算法精讲:BFS、DFS与双向BFS实战指南 1. 项目概述为什么图搜索是算法工程师的必修课如果你正在学习数据结构与算法或者准备技术面试那么“图搜索”这个概念你一定绕不过去。它不仅是LeetCode上的高频考点更是解决无数实际工程问题的核心工具。从社交网络的好友推荐、地图软件的最短路径规划到编译器依赖分析、网络爬虫的页面抓取策略背后都离不开图搜索算法的身影。今天我们不谈那些高深莫测的理论就从一个一线开发者的视角用C这把“瑞士军刀”把图搜索领域最经典、最实用的三个算法——广度优先搜索BFS、深度优先搜索DFS和它们的进阶版“双向BFS”给你掰开了、揉碎了讲清楚。我见过太多初学者对着算法书上的伪代码和复杂的数学符号一头雾水。也见过一些有经验的开发者能写出BFS的代码却说不清队列里到底存的是什么更不明白在什么场景下该用BFS而不是DFS。这篇内容就是来解决这些问题的。我会假设你已经有基本的C语法基础比如会用vector、queue这些STL容器然后带你从零开始一步步实现这三个算法。更重要的是我会分享在实际编码中如何根据问题的“味道”来选择合适的算法如何设计数据结构来高效地表示图以及调试这些算法时那些教科书上不会写的“坑”。我们的目标很明确不只是让你看懂代码而是让你真正理解算法背后的思想并能自信地在面试或项目中运用它们。无论你是正在刷题的学生还是想巩固基础的工程师这篇文章都将是一份值得你反复查阅的实战指南。让我们暂时忘掉那些抽象的定义直接进入代码的世界看看这三个“剑客”究竟是如何工作的。2. 基础准备如何用C优雅地表示一张图在动手写搜索算法之前我们得先解决一个更根本的问题在C里怎么表示“图”这个数据结构这就像打仗前得先有张地图一样重要。图主要由两部分构成顶点Vertex或Node和边Edge。顶点的表示通常很简单用从0开始的连续整数编号就行这样我们可以直接用数组或向量来索引。难点在于边的表示它决定了我们后续搜索的效率。主流的表示方法有两种邻接矩阵和邻接表。邻接矩阵是一个二维数组比如vectorvectorintmatrix[i][j]的值表示顶点i到顶点j的边信息例如1表示连通0表示不连通或者存储权重。它的优点是查询任意两个顶点是否相邻非常快是O(1)的时间复杂度。但缺点也极其明显当图的顶点很多比如上万个而边相对稀疏时这个矩阵将浪费巨大的内存空间空间复杂度O(V²)。想象一下一个社交网络有一万个用户但平均每个用户只关注了100个人那么矩阵里将有上亿个元素其中绝大部分都是0这显然是无法接受的。因此在绝大多数涉及搜索的算法题和实际场景中我们更倾向于使用邻接表。邻接表的本质是一个数组数组的每个元素是一个链表或动态数组这个链表里存储了该顶点的所有邻居顶点。在C中我们可以用vectorvectorint adjList来完美实现。adjList[i]这个向量里就存放了所有与顶点i直接相连的顶点编号。#include iostream #include vector using namespace std; class Graph { private: int V; // 顶点数 vectorvectorint adj; // 邻接表 public: // 构造函数初始化顶点数和空的邻接表 Graph(int vertices) : V(vertices), adj(vertices) {} // 添加一条从顶点u到顶点v的边无向图 void addEdge(int u, int v) { adj[u].push_back(v); adj[v].push_back(u); // 如果是无向图需要添加两次 } // 获取顶点v的所有邻居 const vectorint getNeighbors(int v) const { return adj[v]; } // 获取图的顶点数 int getNumVertices() const { return V; } };为什么选择vectorvectorint而不是listint*这样的指针数组原因在于缓存友好性和易用性。vector的数据在内存中是连续存储的遍历adj[i]里所有邻居时CPU缓存命中率会更高速度更快。同时vector的动态扩容特性也让我们省去了手动管理内存的麻烦。当然如果图的规模固定且已知使用定长数组如vectorint adj[MAX_V]在性能上可能略有优势但灵活性稍差。对于算法竞赛和面试vectorvectorint是通用且推荐的选择。这里有一个非常重要的实操心得在初始化Graph对象时务必在构造函数里用adj(vertices)来预分配好外层向量的大小。如果你写成vectorvectorint adj;然后在addEdge里才去resize或者直接对adj[u]进行push_back当u超过当前adj大小时程序就会发生未定义行为通常是段错误。这是新手常踩的一个坑。另一个注意事项是关于有向图和无向图。上面的addEdge函数默认实现的是无向图即添加一条边(u, v)等价于添加了两条有向边u-v和v-u。如果你处理的是有向图比如表示任务依赖关系那么只需要执行adj[u].push_back(v)这一句即可。在解题时一定要先看清题目对图的定义这是方向性错误一旦错了整个搜索结果就全乱了。3. 广度优先搜索BFS层层递进的搜索策略现在我们有了图的表示可以请出第一位“剑客”广度优先搜索BFS。你可以把BFS想象成一场“涟漪式”的探索。假设你站在一个池塘起点边扔下一颗石子水波会一圈一圈地向外均匀扩散。BFS就是这样它从起点开始先访问所有距离为1的邻居第一圈再访问所有距离为2的邻居第二圈以此类推。这种特性使得BFS天然适合求解最短路径问题在边权为1的图中。BFS的核心数据结构是队列Queue。队列“先进先出”的特性完美契合了“先访问的顶点其未访问的邻居也优先被访问”这一逻辑。算法流程可以概括为以下几步将起点放入队列并标记为已访问。当队列不为空时取出队首顶点u。遍历u的所有未访问邻居v将v标记为已访问并入队。重复步骤2-3直到队列为空或找到目标。下面是一个标准的BFS模板代码它计算从起点s到所有其他顶点的最短距离边数#include queue #include vector using namespace std; vectorint bfs(const Graph graph, int start) { int V graph.getNumVertices(); vectorint distance(V, -1); // 存储最短距离-1表示不可达 vectorbool visited(V, false); // 访问标记数组 queueint q; // 初始化起点 distance[start] 0; visited[start] true; q.push(start); while (!q.empty()) { int u q.front(); q.pop(); // 遍历u的所有邻居 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { visited[v] true; distance[v] distance[u] 1; // 距离递增 q.push(v); } } } return distance; }这段代码有几个关键点需要深入理解distance数组的妙用它同时承担了记录距离和判断是否首次访问的双重职责通过初始值-1。当distance[v] -1时说明v尚未被访问。这是一种常见且高效的空间优化技巧省去了单独的visited数组。但为了逻辑更清晰示例中我仍然保留了visited数组。访问标记的时机一定要在顶点入队时就将其标记为已访问visited[v] true而不是在出队时。为什么想象一下顶点A和B有一个共同的邻居C。A先将C入队但未标记接着B又看到了未访问的C会再次将C入队。这样队列中就会出现两个C导致重复访问和计算错误甚至可能使队列无限增长。这是BFS实现中最经典的错误之一。队列里存的是什么队列里存储的不仅仅是顶点编号更隐含着“搜索前沿”的状态。每个出队的顶点都代表着搜索边界向外推进了一步。让我们看一个具体的应用场景LeetCode 752. 打开转盘锁。你有一个四个圆形拨轮的转盘锁每次只能将一个拨轮向上或向下转动一格同时有一些“死亡数字”组合不能触碰。问从“0000”转到目标数字target最少需要转动多少次这本质上就是一个BFS求最短路径的问题。每个状态如“0000”是一个顶点转动一次得到的新状态就是它的邻居顶点最多8个因为每个拨轮有两个方向。deadends列表里的状态就是不能被访问的顶点。用上述BFS模板稍作修改判断是否为目标、跳过死亡数字就能高效解决。注意在类似转盘锁这种状态空间搜索问题中状态顶点通常不是简单的整数而是字符串、数组或自定义结构。这时我们需要用unordered_set或unordered_map来替代visited数组以实现O(1)时间复杂度的查找。同时生成邻居状态的函数也会比简单的graph.getNeighbors更复杂。4. 深度优先搜索DFS一条路走到黑的探索精神与BFS的“广撒网”不同深度优先搜索DFS的策略是“一条道走到黑”。它从起点开始沿着一条路径一直深入下去直到这条路径走到尽头没有未访问的邻居然后回溯到上一个分岔点选择另一条未探索的路径继续深入。这种特性使得DFS非常适合处理需要遍历所有可能情况的问题比如图的连通分量检测、拓扑排序、寻找环路、回溯算法等。DFS的实现有两种经典方式递归和显式栈迭代。递归写法依靠函数调用栈代码简洁直观是表达DFS逻辑最自然的方式。#include vector using namespace std; void dfsRecursive(const Graph graph, int u, vectorbool visited) { // 访问顶点u这里可以是任何操作比如打印、记录路径等 // cout u ; visited[u] true; // 递归地访问所有未访问的邻居 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { dfsRecursive(graph, v, visited); } } } // 封装函数从起点开始DFS遍历整个连通分量 void dfs(const Graph graph, int start) { vectorbool visited(graph.getNumVertices(), false); dfsRecursive(graph, start, visited); }递归DFS虽然简洁但在图非常大或者深度很深时有栈溢出的风险。这时我们可以使用显式的栈Stack来模拟递归过程也就是迭代版DFS#include stack #include vector using namespace std; void dfsIterative(const Graph graph, int start) { int V graph.getNumVertices(); vectorbool visited(V, false); stackint s; s.push(start); // 注意迭代法中我们选择在入栈时标记还是出栈时标记 // 为了和BFS对比以及避免同一顶点多次入栈通常在入栈时标记。 visited[start] true; while (!s.empty()) { int u s.top(); s.pop(); // 对u进行处理例如输出 // cout u ; // 将u的未访问邻居入栈 // 注意栈是后进先出为了保持和递归类似的遍历顺序比如都优先遍历第一个邻居 // 有时需要将邻居逆序入栈。但这对许多问题如仅判断连通性不影响结果。 for (int v : graph.getNeighbors(u)) { if (!visited[v]) { visited[v] true; // 入栈前标记 s.push(v); } } } }递归与迭代的选择递归DFS逻辑清晰适合深度不大或问题本身适合递归分解如回溯的场景。迭代DFS更安全不会栈溢出并且有时可以通过调整入栈顺序来控制遍历行为。在面试中如果面试官没有特别要求使用递归通常更快捷但如果他提到“图可能很深”那么主动提出可以用迭代栈实现会是一个加分项。DFS的核心应用寻找连通分量。在无向图中一个连通分量是最大的、任意两点间有路径相连的顶点子集。利用DFS可以轻松找出所有连通分量因为一次DFS遍历所能到达的所有顶点就构成一个连通分量。vectorvectorint findConnectedComponents(const Graph graph) { int V graph.getNumVertices(); vectorbool visited(V, false); vectorvectorint components; for (int i 0; i V; i) { if (!visited[i]) { vectorint component; // 需要一个能收集遍历结果的DFS函数 dfsForComponent(graph, i, visited, component); components.push_back(component); } } return components; } // 需要实现一个将遍历节点加入component的DFS函数这里有一个重要的注意事项对于有向图DFS遍历的结果顺序有特殊意义。如果我们在递归DFS返回时将顶点压入一个列表那么这个列表的逆序就是该图的一个拓扑排序如果图是有向无环图的话。这是解决任务调度、依赖解析类问题的关键。DFS的“坑”在处理大规模图时递归DFS最怕的就是深度过大导致栈溢出。我曾经在解决一个棋盘类搜索问题时递归深度达到了几千层直接导致了程序崩溃。解决方案就是改用迭代栈或者尝试用BFS如果问题允许。另一个常见错误是在回溯算法中忘记“恢复状态”。DFS在探索一条路径时可能会修改一些全局或共享的状态比如当前路径列表当这条路径探索完毕回溯时必须将这些状态恢复原样否则会影响其他路径的探索。这不是图DFS独有的但在涉及状态修改的DFS中至关重要。5. 双向BFS当起点和终点都明确时的搜索加速器BFS和DFS是基础但在一些特定场景下我们可以做得更聪明。想象一下你要在一个巨大的社交网络中寻找两个用户之间的最短关联路径。从其中一个人开始BFS可能需要探索非常庞大的圈子才能碰到另一个人。但如果你同时从两个人开始分别向外进行BFS探索那么当两个搜索的“前沿”相遇时路径就找到了。这就是双向BFS的核心思想。双向BFS能大幅提升搜索效率尤其是在搜索空间呈指数级增长时比如单词接龙、滑块拼图等问题。从起点和终点同时开始的搜索会将搜索的“半径”减半。理论上在最理想的情况下如果分支因子是b最短路径长度是L那么单向BFS需要探索大约 b^L 个节点而双向BFS只需要探索大约 2 * b^(L/2) 个节点。当b和L较大时这个优化是指数级的。实现双向BFS我们需要维护两个队列queueA,queueB和两个访问记录visitedA,visitedB。visited记录不仅标记是否访问过通常还会记录该顶点是从哪一端搜索过来的以及距离起点的步数。#include queue #include vector #include unordered_map using namespace std; int bidirectionalBFS(const Graph graph, int start, int target) { if (start target) return 0; // 使用哈希表来记录访问状态和距离方便快速查找相遇点 unordered_mapint, int visitedA, visitedB; // key: 顶点, value: 距离起/终点的步数 queueint qA, qB; // 初始化 visitedA[start] 0; visitedB[target] 0; qA.push(start); qB.push(target); while (!qA.empty() !qB.empty()) { // 每次选择节点数较少的一端进行扩展这是一种优化平衡两端的搜索进度 int distance -1; // 扩展A端 distance expandQueue(graph, qA, visitedA, visitedB); if (distance ! -1) return distance; // 扩展B端 distance expandQueue(graph, qB, visitedB, visitedA); if (distance ! -1) return distance; } return -1; // 未连通 } int expandQueue(const Graph graph, queueint q, unordered_mapint, int visitedThis, unordered_mapint, int visitedOther) { int size q.size(); for (int i 0; i size; i) { int u q.front(); q.pop(); int currentDist visitedThis[u]; for (int v : graph.getNeighbors(u)) { if (visitedThis.find(v) ! visitedThis.end()) { continue; // 已在本侧被访问过 } // 关键检查如果这个节点已经在另一侧被访问过说明相遇了 if (visitedOther.find(v) ! visitedOther.end()) { int otherDist visitedOther[v]; return currentDist 1 otherDist; // 总距离 A端距离 当前边 B端距离 } // 否则标记并加入本侧队列 visitedThis[v] currentDist 1; q.push(v); } } return -1; // 本轮扩展未相遇 }双向BFS的实现要点与技巧相遇判断这是核心。当从一端扩展到一个新节点v时不仅检查它是否在本端的visited中更要检查它是否在另一端的visited中。如果在则路径连通总长度是两端距离之和加1连接v的那条边。轮流扩展与优化代码中每次只扩展一层通过for (int i 0; i size; i)循环控制然后切换另一端。更优的策略是每次选择当前节点数更少的那一端进行扩展这可以更快地让两端搜索范围接近从而尽早相遇。上面的expandQueue函数被设计成可以处理任意一端。数据结构选择由于顶点可能不是连续整数或者为了快速查找我们使用unordered_map来替代vector作为visited记录。visitedThis[u]的值记录了从本侧起点到u的距离。终止条件任一队列为空时如果还未相遇说明起点和终点不连通。一个经典的应用场景是“单词接龙”问题LeetCode 127。给定一个起始单词、一个结束单词和一个单词列表每次只能改变一个字母找出从起始词到结束词的最短转换序列长度。单词列表可以构成一个图每个单词是节点相差一个字母的单词之间有边。单词列表通常很大使用单向BFS可能会超时而双向BFS则可以显著加速。注意双向BFS并非万能。它要求起点和终点都明确已知。在那些只知起点、终点未知如寻找任意一个解的问题中双向BFS就无法应用。同时实现双向BFS的代码复杂度高于单向BFS在状态空间不大时优势可能不明显甚至因为额外的哈希表操作而更慢。所以选用前要先判断问题是否适合。6. 三大算法对比与实战选型指南学完了三位“剑客”的招式是时候来一场“华山论剑”看看它们各自的优劣和适用场景了。选择哪种算法往往取决于问题的具体“味道”。1. BFS (广度优先搜索)核心特征使用队列按层遍历。时间复杂度O(V E)其中V是顶点数E是边数。每个顶点和每条边都被访问一次。空间复杂度O(V)在最坏情况下如星型图队列需要存储所有顶点。适用场景无权图的最短路径这是BFS的“杀手锏”。因为它按层遍历第一次访问到某个节点时的路径一定是边数最少的路径。层级遍历或扩散问题如社交网络中的N度好友、腐烂的橘子LeetCode 994、岛屿数量也可以用DFS等。判断二分图通过交替染色和BFS遍历可以高效判断。不适用场景需要遍历所有路径或状态的问题如排列组合BFS的空间消耗可能过大。2. DFS (深度优先搜索)核心特征使用栈递归或显式一条路走到底再回溯。时间复杂度O(V E)同样访问所有顶点和边。空间复杂度O(H)其中H是图的最大深度。递归DFS取决于调用栈深度迭代DFS取决于显式栈的大小。在树或链状图上空间复杂度可能远小于BFS。适用场景遍历所有路径/方案如回溯算法、排列组合、求所有连通分量。拓扑排序对有向无环图进行排序。检测环路在图中寻找环。解决“可达性”问题判断两点是否连通不关心最短路径时。不适用场景求解最短路径除非遍历所有路径后比较但效率极低。在深度可能极大的图中递归DFS有栈溢出风险。3. 双向BFS核心特征从起点和终点同时开始BFS相遇时停止。时间复杂度最坏情况仍是O(VE)但平均情况尤其是解在中间层时远快于单向BFS。空间复杂度O(b^(d/2))其中b是分支因子d是最短路径长度。通常优于单向BFS的O(b^d)。适用场景起点和终点明确的最短路径问题且搜索空间巨大。如单词接龙、滑块拼图8-puzzle等。不适用场景终点未知或图本身很小双向BFS的优化效果不明显反而增加实现复杂度。为了更直观我们可以用一个表格来总结特性BFSDFS双向BFS数据结构队列 (Queue)栈 (Stack/递归)两个队列遍历顺序层级遍历深度优先双向层级遍历解的性质最优解最短路径不一定最优最先找到的最优解最短路径空间开销较大O(V)较小O(H)中等通常小于单向BFS经典应用最短路径、扩散问题连通性、拓扑排序、回溯已知起终点的最短路径实战选型心法 当你拿到一个问题时可以问自己以下几个问题问题目标是什么找最短路径 - 优先考虑BFS或双向BFS。遍历所有可能 - DFS。图有多大深度可能有多深图巨大且深度可能很深 - 谨慎使用递归DFS考虑迭代DFS或BFS。起点终点明确且路径可能很长 - 强烈考虑双向BFS。需要记录路径吗如果需要输出具体路径无论是BFS还是DFS都需要在访问节点时记录其“前驱节点”从哪个节点来的最后从终点反向回溯即可。这是一个通用的技巧。有特殊约束吗比如“每次移动代价不同”加权图那么普通的BFS就不适用了需要升级为Dijkstra算法或A*算法。这超出了本文范围但它是图搜索算法家族中的重要成员。记住没有最好的算法只有最适合当前场景的算法。很多时候在面试中面试官期待你不仅能写出代码更能清晰地说出为什么选择这个算法以及它的时间和空间复杂度是多少。这才是真正理解了算法思想的表现。7. 常见问题排查与性能优化技巧即便理解了算法原理在亲手实现时依然会遇到各种稀奇古怪的问题。下面我整理了一些在实现图搜索算法时最常见的“坑”和对应的排查技巧以及一些提升性能的实战心得。问题1程序陷入死循环或栈溢出。可能原因这是最经典的问题几乎百分之百是因为访问标记visited设置错误。对于BFS没有在节点入队时立即标记为已访问导致同一个节点被多次加入队列。对于递归DFS图中有环但没有visited数组或者递归函数没有终止条件比如在遍历邻居时没有判断visited导致无限递归。排查与解决首先确保你的visited数组或集合被正确初始化。对于BFS在q.push(v)之后紧跟着visited[v] true。对于DFS在递归函数入口或迭代栈的入栈操作后立即标记当前节点。可以在循环或递归开始时打印当前节点和visited状态这是最直接的调试方法。问题2BFS结果不是最短路径。可能原因使用了DFS或者BFS实现有误比如错误地使用了栈。图的边有权重而普通BFS只适用于边权为1或相等的无权图最短路径。如果边权不同需要使用Dijkstra算法。distance数组更新逻辑错误。距离应该是父节点距离1如果你错误地用了其他值或者重复更新了更长的距离就会出错。排查与解决再次确认你实现的是BFS队列。检查distance[v] distance[u] 1这行代码是否在发现未访问邻居v时执行。对于有权图立刻停止使用BFS转用更合适的算法。问题3DFS递归深度太大导致“段错误”或“栈溢出”。可能原因图深度极深比如一条长链递归调用层次太多耗尽了系统为程序分配的调用栈空间。排查与解决改用迭代DFS使用显式的stackint来模拟递归过程系统的堆空间通常比栈空间大得多。尝试BFS如果问题不要求必须DFS换用BFS可能直接避免深度问题。调整系统栈大小不推荐在某些编译环境或操作系统中可以设置但这不是通用的解决方案且不利于代码移植。问题4双向BFS没有正确相遇或者计算的距离不对。可能原因相遇点判断逻辑错误检查expandQueue函数中发现v在visitedOther中存在时计算总距离的公式是否正确。必须是distA 1 distB。两端距离记录错误确保visitedA和visitedB中记录的距离是从各自起点出发的步数。在扩展时新节点的距离是当前节点距离1。初始状态处理不当起点和终点相同的情况需要单独处理直接返回0。排查与解决在扩展队列时打印出当前扩展的节点、距离以及两个visited映射的内容可以非常清晰地看到搜索是如何推进以及在哪里相遇的。用一个非常小的图比如3个节点的链手动模拟算法过程是最有效的调试方法。性能优化技巧数据结构的选择visited标记如果顶点编号是连续的整数优先使用vectorbool或vectorint其访问速度远快于unordered_set。如果顶点是字符串或其他复杂类型则必须使用unordered_set。队列/栈使用STL的queue和stack即可它们默认由deque实现性能足够好。在极端性能要求下可以用vector模拟队列维护头尾指针但代码复杂度会增加。提前终止无论是BFS还是DFS一旦找到目标解立即return或break避免无谓的后续搜索。双向BFS的扩展优化如前所述每次选择节点数更少的那一端进行扩展可以更快相遇。这需要你维护两个队列的大小并做比较。状态压缩在一些搜索问题中如棋盘状态顶点可能是一个复杂结构。直接将其作为unordered_set的key可能效率很低。如果可能将其压缩为一个整数比如位运算或一个字符串可以大幅提升哈希和比较的速度。避免重复计算在生成邻居状态时可能会有重复或无效状态。在入队/入栈前进行有效性判断比如是否越界、是否满足条件比生成所有邻居再过滤效率更高。调试算法就像破案需要耐心和逻辑。最笨但最有效的方法就是“打印大法”。把关键变量当前节点、队列内容、visited数组在每一步都打印出来跟着程序的逻辑走一遍绝大多数错误都会无所遁形。

相关新闻