资讯详情

资讯详情

有向图简单路径算法详解:DFS与BFS实现及优化

1. 有向图简单路径到底在解决什么问题有向图的简单路径说白了就是在一张有向图里找一条从起点到终点的路径且路径上不能有重复的顶点。这个定义听起来简单但真正动手写代码的时候你会发现里面藏着不少门道。我在带新人做图论练习的时候发现很多人第一反应就是直接上DFS结果要么路径重复了要么死循环出不来要么就是漏掉了某些合法路径。这个问题的核心应用场景其实非常广泛。比如在任务调度系统中你需要判断某个任务的前置依赖链是否形成了环在编译原理的依赖分析里你需要找出模块之间的调用路径甚至在网络路由的模拟中简单路径的概念也被用来避免数据包在环路中反复转发。所以别看它只是一个图论的基础问题真正把它写扎实了对后续理解更复杂的图算法帮助极大。这篇文章主要面向有一定C基础、正在学习图论或者准备算法练习的读者。我会从数据结构的选择开始一步步拆解DFS和BFS两种思路在简单路径问题上的差异然后给出完整的可编译运行的代码实现最后把我自己踩过的坑和调试经验都摊开来聊。你不需要有很深的算法功底只要能看懂基本的C语法和递归就能跟着走下来。有一点需要提前说明有向图的简单路径问题根据具体需求的不同其实分为好几个变种。有的是找一条路径就行有的是要找所有路径有的是找最短的简单路径还有的是判断两点之间是否存在简单路径。这几个变种的实现策略差别挺大我会在正文里分别展开。你如果只是想做一道练习题可以直接看第二节的DFS实现如果你是在实际项目里遇到了路径查找的需求建议把第三节的优化思路也看完。2. 数据结构选型与图的存储方式2.1 邻接矩阵 vs 邻接表到底选哪个在动手写算法之前第一个要做的决定就是怎么存这张图。我见过不少初学者上来就用二维数组结果图稍微大一点就内存爆炸。也见过有人无脑用邻接表结果在需要快速判断两点之间是否有边的时候效率很低。这两种方式各有各的适用场景选错了不会导致程序跑不起来但会让你的代码在特定场景下性能差很多。邻接矩阵的本质是一个二维数组adj[i][j]如果从顶点 i 到顶点 j 有边就设为1或者边的权重否则设为0或者无穷大。它的优势在于查询两点之间是否有边的操作是 O(1) 的非常快。但缺点也很明显如果图有 n 个顶点就需要 n×n 的空间。对于一个有10000个顶点但只有50000条边的稀疏图来说邻接矩阵要开一亿个元素的数组而邻接表只需要存50000条边的信息差距是数量级的。邻接表的思路是给每个顶点维护一个链表或者动态数组里面存所有从该顶点出发能到达的顶点。在C里我通常用vectorvectorint来实现外层vector的下标代表顶点编号内层vector存该顶点的所有邻居。这种方式的空间复杂度是 O(VE)对于稀疏图非常友好。但查询两点之间是否有边就需要遍历某个顶点的邻居列表最坏情况下是 O(V)。那到底怎么选我的经验是这样的如果你的图顶点数不超过1000而且经常需要判断任意两点之间是否有边那就用邻接矩阵写起来简单直接。如果顶点数超过1000或者边的数量远小于顶点数的平方那就果断用邻接表。对于简单路径这个问题来说因为DFS过程中主要操作是“遍历当前顶点的所有邻居”所以邻接表其实是更自然的选择。2.2 有向图存储的特殊注意点有向图和无向图在存储上最大的区别就是无向图的一条边要存两次有向图只存一次。这个看起来是废话但实际写代码的时候很多人会习惯性地按照无向图的方式去加边结果导致算法把有向边当成了双向边来处理找出来的路径在逻辑上是错的。举个例子假设有边 1→2在邻接表里你只需要在adj[1]里加入2不需要在adj[2]里加入1。如果你两边都加了那算法就会认为从2也能直接到1这显然不符合有向图的定义。我在review代码的时候这个问题出现的频率高得离谱尤其是从无向图模板改过来的时候特别容易犯。另外还有一个细节有向图中可能存在自环也就是从顶点 i 到顶点 i 自己的边。在简单路径的定义下自环是不能出现在路径中的因为那会导致顶点重复。所以在DFS的过程中判断邻居是否已经被访问过这一步天然就把自环过滤掉了。但如果你在加边的时候没有做去重处理邻接表里可能会出现重复的边这会导致算法找出重复的路径。建议在加边的时候做一次去重或者在DFS的时候用一个集合来记录已经走过的顶点。2.3 顶点编号与数据映射实际项目中图的顶点往往不是从0开始的连续整数可能是字符串名字、IP地址、任务ID等等。这时候就需要做一个映射层把实际的顶点标识转换成从0开始的连续整数方便用数组或vector来存储。我通常用unordered_mapstring, int来做这个映射同时用一个vectorstring来存反向映射方便最后输出路径的时候把编号还原成可读的名字。这个映射层的代码虽然简单但很容易出bug。最常见的问题是忘记检查某个顶点是否已经在映射表中导致重复分配编号。我的做法是封装一个函数每次需要获取顶点编号的时候都先查map如果不存在就分配一个新的编号并同时更新正反两个映射。这样虽然多写了几行代码但能避免很多低级错误。3. DFS实现简单路径查找的完整思路3.1 递归DFS的核心框架DFS找简单路径的思路其实很直观从起点出发沿着一条边走到底如果走到了终点就记录路径如果走不通就回退到上一个顶点换一条边继续尝试。这个“回退”的过程就是回溯也是DFS能找出所有路径的关键。用递归来实现的话核心框架大概是这样维护一个visited数组记录当前路径上已经访问过的顶点维护一个path数组记录当前路径的顶点序列。每次进入递归函数的时候把当前顶点标记为已访问并加入path如果当前顶点就是终点就把path保存到结果集里否则遍历当前顶点的所有邻居对每个未访问的邻居递归调用。递归返回之后要把当前顶点从path中移除并把visited标记清除这就是回溯。这个框架看起来简单但有一个很容易忽略的点visited数组的清除时机。如果你在递归调用之前标记visited在递归返回之后清除visited那是对的。但如果你在递归调用之前标记却忘记在返回后清除那算法就只能找到一条路径因为其他路径上的顶点都被标记成已访问了。这个bug非常隐蔽因为程序不会崩溃只是结果不对而且对于小图你可能一眼看不出问题。3.2 递归终止条件与路径记录递归的终止条件有两个一是当前顶点等于终点这时候记录路径并返回二是当前顶点的所有邻居都已经访问过了这时候自然返回。但这里有个细节需要注意如果起点和终点是同一个顶点那路径就是只包含这一个顶点的路径长度为0。这种情况在实际问题中是否算作有效路径取决于具体需求。我在做题的时候遇到过要求“路径长度至少为1”的情况也就是起点和终点不能相同这时候就需要在终止条件里加一个判断。路径的记录方式也有讲究。如果你用vectorint来存当前路径每次找到一条完整路径就把它push到结果集里那结果集里存的就是路径的副本后续回溯不会影响已经保存的结果。但如果你图省事直接存了path的引用或者指针那回溯之后结果集里的路径就全乱了。这个坑我踩过不止一次尤其是在赶时间写代码的时候特别容易犯。另外如果图比较大路径数量可能非常多全部存下来会占用大量内存。这时候可以考虑不存所有路径而是只统计路径数量或者在找到第一条路径后就立即返回。这些变种我会在后面的章节里展开。3.3 处理环与重复访问的判断逻辑有向图中存在环是很常见的情况而简单路径的定义要求路径上不能有重复顶点所以环天然就被visited数组挡住了。但这里有一个性能上的考量每次判断邻居是否已访问都需要查一次visited数组这个操作是O(1)的所以整体上不会成为瓶颈。不过有一种特殊情况需要注意如果图中存在大量的环DFS可能会做很多无效的探索。比如从起点出发走进了一个很大的环绕了一圈之后发现走不到终点然后回溯换一条路又走进同一个环。这种情况下算法的时间复杂度会比较高。对于一般规模的图这个问题不明显但如果顶点数上万就需要考虑加一些剪枝策略比如提前判断终点是否从当前顶点可达。还有一个细节是visited数组的类型选择。用bool数组是最常见的但如果你的顶点编号不是连续的或者图是动态变化的用unordered_setint来记录已访问顶点会更灵活。不过set的查询有常数级开销比数组慢一些所以如果顶点编号连续且范围不大还是用vector 或者bool数组更高效。4. BFS在简单路径问题中的适用场景4.1 BFS找最短简单路径BFS和DFS最大的区别在于搜索顺序BFS是一层一层地往外扩展所以它天然就能找到从起点到终点的最短路径这里的最短是指边数最少。如果你只需要找一条最短的简单路径BFS是比DFS更合适的选择因为DFS可能会先找到一条很长的路径而BFS保证第一次到达终点时经过的边数最少。用BFS找最短简单路径的代码框架是维护一个队列初始时把起点入队维护一个parent数组记录每个顶点是从哪个顶点扩展过来的每次从队列取出一个顶点遍历它的所有邻居如果邻居没有被访问过就标记为已访问记录parent然后入队。当终点第一次被访问到的时候就可以通过parent数组从终点回溯到起点得到一条最短路径。这里有一个关键点BFS中找到的路径天然就是简单路径因为每个顶点只会被访问一次所以路径上不可能有重复顶点。这也是BFS相对于DFS的一个优势——不需要额外的visited回溯操作代码更简洁。4.2 BFS无法直接枚举所有路径的原因虽然BFS找最短路径很方便但如果你想枚举所有的简单路径BFS就不太合适了。原因在于BFS的visited数组是全局的一个顶点一旦被访问过就不会再被加入队列。这意味着BFS只能找到一条到达每个顶点的路径无法找到所有可能的路径。如果你非要用BFS来枚举所有路径那就需要把visited数组改成“当前路径上的visited”也就是每个队列元素都要携带自己的visited状态。这样一来队列里存储的数据量会急剧膨胀空间复杂度会变得很高。所以对于枚举所有简单路径这个问题DFS是更自然的选择。我在实际项目中遇到过需要枚举所有路径的场景一开始想用BFS改后来发现代码复杂度比DFS高很多而且性能也不好。最后还是老老实实用了DFS加回溯代码清晰调试也方便。4.3 双向BFS的优化思路如果图比较大而且你只需要找一条最短路径双向BFS是一个值得考虑的优化方案。它的思路是从起点和终点同时开始BFS当两个搜索 frontier 相遇的时候就找到了一条路径。理论上双向BFS的时间复杂度是单向BFS的平方根级别对于大图来说提升非常明显。不过双向BFS的实现比单向BFS复杂不少需要维护两个visited集合、两个parent数组还要处理两个搜索方向相遇时的路径拼接。而且它只适用于找最短路径的场景对于枚举所有路径或者带权图的最短路径就不适用了。我的建议是如果图的规模不大顶点数几千以内单向BFS足够了只有当图特别大且只需要一条最短路径的时候才值得上双向BFS。5. 完整代码实现与逐段解析5.1 图的类封装与基础操作下面这段代码是我平时写图论题目时常用的一个模板把图的存储和基本操作封装成了一个类。你可以直接拿去用也可以根据自己的需求修改。#include iostream #include vector #include unordered_map #include unordered_set #include queue #include algorithm class DirectedGraph { public: DirectedGraph() {} // 添加顶点返回顶点编号 int addVertex(const std::string name) { auto it nameToId_.find(name); if (it ! nameToId_.end()) { return it-second; } int id static_castint(idToName_.size()); nameToId_[name] id; idToName_.push_back(name); adj_.emplace_back(); return id; } // 添加有向边 from - to void addEdge(const std::string from, const std::string to) { int u addVertex(from); int v addVertex(to); // 去重避免重复边导致重复路径 auto neighbors adj_[u]; if (std::find(neighbors.begin(), neighbors.end(), v) neighbors.end()) { neighbors.push_back(v); } } int vertexCount() const { return static_castint(adj_.size()); } const std::vectorint neighbors(int u) const { return adj_[u]; } std::string vertexName(int id) const { return idToName_[id]; } int vertexId(const std::string name) const { auto it nameToId_.find(name); return (it ! nameToId_.end()) ? it-second : -1; } private: std::vectorstd::vectorint adj_; std::unordered_mapstd::string, int nameToId_; std::vectorstd::string idToName_; };这个类做了几件事用unordered_map做名字到编号的映射用vectorvectorint存邻接表加边的时候自动去重。去重这一步看起来不起眼但如果你的输入数据里有重复边不去重的话算法会输出重复的路径结果就不对了。5.2 DFS枚举所有简单路径的代码接下来是DFS枚举所有简单路径的核心代码。我把它写成了一个独立的函数接收图、起点、终点返回所有简单路径。void dfsAllPaths(const DirectedGraph graph, int current, int end, std::vectorbool visited, std::vectorint path, std::vectorstd::vectorint result) { visited[current] true; path.push_back(current); if (current end) { result.push_back(path); } else { for (int next : graph.neighbors(current)) { if (!visited[next]) { dfsAllPaths(graph, next, end, visited, path, result); } } } // 回溯恢复状态 path.pop_back(); visited[current] false; } std::vectorstd::vectorint findAllSimplePaths( const DirectedGraph graph, const std::string startName, const std::string endName) { int start graph.vertexId(startName); int end graph.vertexId(endName); std::vectorstd::vectorint result; if (start -1 || end -1) { return result; } int n graph.vertexCount(); std::vectorbool visited(n, false); std::vectorint path; dfsAllPaths(graph, start, end, visited, path, result); return result; }这段代码的关键在于回溯部分path.pop_back()和visited[current] false这两行必须成对出现而且顺序无所谓但一定不能漏。我见过有人只pop了path但忘了清除visited结果算法只能找到一条路径。也见过有人清除了visited但忘了pop path结果路径越积越长输出完全错误。5.3 BFS找最短简单路径的代码如果你只需要一条最短路径下面这段BFS代码更合适std::vectorint bfsShortestPath(const DirectedGraph graph, const std::string startName, const std::string endName) { int start graph.vertexId(startName); int end graph.vertexId(endName); std::vectorint result; if (start -1 || end -1) { return result; } int n graph.vertexCount(); std::vectorbool visited(n, false); std::vectorint parent(n, -1); std::queueint q; visited[start] true; q.push(start); while (!q.empty()) { int current q.front(); q.pop(); if (current end) { // 回溯构造路径 for (int v end; v ! -1; v parent[v]) { result.push_back(v); } std::reverse(result.begin(), result.end()); return result; } for (int next : graph.neighbors(current)) { if (!visited[next]) { visited[next] true; parent[next] current; q.push(next); } } } return result; // 没有找到路径 }BFS的代码比DFS稍微长一点但逻辑更线性不容易出错。注意parent数组的初始值是-1回溯的时候以-1作为终止条件。另外找到终点后要记得reverse因为回溯出来的路径是从终点到起点的。5.4 测试用例与运行结果验证为了验证代码的正确性我构造了一个简单的有向图来测试。图的结构是这样的A→BA→CB→DC→DD→E。从A到E的简单路径有两条A→B→D→E 和 A→C→D→E。int main() { DirectedGraph graph; graph.addEdge(A, B); graph.addEdge(A, C); graph.addEdge(B, D); graph.addEdge(C, D); graph.addEdge(D, E); auto allPaths findAllSimplePaths(graph, A, E); std::cout Total paths: allPaths.size() std::endl; for (const auto p : allPaths) { for (size_t i 0; i p.size(); i) { if (i 0) std::cout - ; std::cout graph.vertexName(p[i]); } std::cout std::endl; } auto shortest bfsShortestPath(graph, A, E); std::cout Shortest path: ; for (size_t i 0; i shortest.size(); i) { if (i 0) std::cout - ; std::cout graph.vertexName(shortest[i]); } std::cout std::endl; return 0; }运行结果应该是输出两条路径最短路径也是这两条中的一条因为两条路径长度相同BFS会返回先找到的那条。这个测试用例虽然简单但覆盖了分叉、汇合、终点等关键情况足以验证代码的基本正确性。6. 性能优化与常见问题排查6.1 路径数量爆炸时的剪枝策略枚举所有简单路径有一个天然的问题路径数量可能随着图的规模指数级增长。在一个稠密图中简单路径的数量可能是天文数字全部枚举出来既耗时又耗内存。如果你在实际项目中遇到了这个问题有几个剪枝策略可以考虑。第一个策略是提前判断可达性。在DFS之前先用一次BFS或DFS从终点反向搜索标记出所有能到达终点的顶点。然后在正向DFS的时候只探索那些被标记过的顶点。这样可以避免在不可能到达终点的分支上浪费时间。第二个策略是限制路径长度。如果业务需求只关心长度不超过K的路径那在DFS的时候可以传入一个剩余步数参数当步数用完时直接返回。这个剪枝在路径长度有限制的场景下非常有效。第三个策略是限制结果数量。如果只需要找前N条路径那可以在找到N条之后直接终止搜索。实现方式可以用一个计数器每次找到路径就加一达到上限就抛异常或者设置一个全局标志位来终止递归。6.2 递归深度过大导致的栈溢出DFS的递归深度等于路径的最大长度在极端情况下可能达到图的顶点数。如果图的顶点数上万递归深度就可能超过系统栈的默认大小导致栈溢出。这个问题在Windows上尤其明显因为默认栈大小通常只有1MB左右。解决方式有两种一是把递归改成迭代用显式的栈来模拟递归过程二是增大栈的大小在编译选项里设置。第一种方式更通用但代码会复杂一些。第二种方式简单但依赖于编译器和操作系统可移植性差。我个人的建议是如果顶点数不超过5000递归通常没问题如果超过5000最好改成迭代实现。迭代版本的DFS需要手动维护一个栈栈里存的是“当前顶点”和“下一个要探索的邻居索引”实现起来比递归麻烦但不受栈大小限制。6.3 常见问题速查表下面这张表整理了我在实现有向图简单路径算法时遇到过的典型问题以及对应的排查思路和解决方法。问题现象可能原因排查方法解决方案只能找到一条路径visited数组回溯时未清除检查递归返回后是否执行了visited[current]false确保path.pop_back()和visited清除成对出现路径中出现重复顶点visited判断逻辑有误打印每次递归的path和visited状态在加入path之前检查visited确保不重复输出重复路径邻接表中有重复边检查加边时是否去重加边时用find或set去重程序死循环有向图中存在环且visited未生效检查visited是否在递归前设置确保进入递归前标记visited结果路径顺序颠倒BFS回溯时未reverse检查结果构造逻辑回溯后调用std::reverse大图运行超时路径数量爆炸统计路径数量或加计数器加剪枝策略或限制结果数量栈溢出崩溃递归深度过大查看崩溃时的调用栈改迭代实现或增大栈空间这张表里的每一个问题我都实际遇到过尤其是第一个和第二个在初学阶段几乎必然会踩。建议你在调试的时候先在递归函数里加几行打印语句把每次进入和退出时的path和visited状态打出来这样一眼就能看出问题出在哪。6.4 调试技巧与经验分享调试图论算法有一个很实用的技巧把图可视化。虽然这篇文章里不能用图表但你可以在本地用Graphviz或者简单的文本输出来把图的结构打印出来然后手动模拟算法的执行过程。对于小图来说手动模拟一遍比盯着代码看半天有效得多。另一个技巧是写单元测试。图论算法的边界情况特别多比如空图、只有一个顶点的图、起点等于终点、起点或终点不存在、图中有自环、图中有平行边等等。我通常会针对这些情况各写一个测试用例每次修改代码后跑一遍确保没有引入回归问题。还有一个经验是先用小图验证正确性再用大图测试性能。小图用来检查逻辑对不对大图用来检查有没有性能问题。如果小图都跑不对就别急着上大图了先把逻辑理清楚。7. 实际应用中的扩展与变体7.1 带权图中的最短简单路径如果图中的边带有权重那“最短路径”的定义就从“边数最少”变成了“权重之和最小”。这时候BFS就不适用了需要用Dijkstra算法或者Bellman-Ford算法。但需要注意的是Dijkstra算法找到的路径不一定是简单路径因为如果图中存在零权边或者负权边算法可能会绕圈。不过在大多数实际场景中边权都是正数Dijkstra找到的路径天然就是简单路径。如果你需要找带权图中的最短简单路径而且图中存在负权边那就只能用Bellman-Ford或者SPFA而且还需要额外处理负权环的情况。这个问题就比无权图复杂很多了超出了这篇文章的范围但思路是一样的在松弛边的时候记录parent最后回溯出路径。7.2 判断两点间是否存在简单路径有时候你不需要找出具体路径只需要判断两点之间是否存在简单路径。这个问题比找路径简单因为一旦找到一条就可以立即返回。实现上可以用DFS找到一个就返回true也可以用BFS看终点是否被访问到。甚至可以用并查集来判断无向图的情况但有向图不行因为并查集无法处理方向性。在实际项目中判断可达性是一个非常常见的需求。比如在任务调度系统中你需要判断任务A是否依赖于任务B本质上就是判断从B到A是否存在有向路径。这种情况下用BFS或DFS都可以选择哪个取决于你对性能的要求和图的结构特点。7.3 所有路径中的最长路径问题最长简单路径问题在有向图中是一个NP难问题也就是说没有多项式时间的精确算法。对于小图可以用DFS枚举所有路径然后取最长的对于大图只能用近似算法或者启发式搜索。我在实际项目中遇到最长路径的需求时通常会先确认图的规模如果顶点数超过20就会考虑用动态规划或者整数规划来求解而不是暴力枚举。有一个特殊情况是DAG有向无环图中的最长路径这个问题可以用拓扑排序加动态规划在多项式时间内解决。如果你的图恰好是DAG那就不要用暴力枚举了拓扑排序加DP是更优雅的方案。7.4 路径去重与结果排序当你枚举出所有简单路径后有时候需要对结果进行去重或排序。去重通常是因为输入数据中有重复边或者重复顶点导致算法输出了重复路径。排序则是因为业务需求要求按路径长度或者字典序输出。去重的思路很简单把每条路径转换成一个字符串或者哈希值然后用set去重。排序的话可以用自定义比较函数按路径长度排序或者按顶点编号的字典序排序。这些操作虽然简单但在实际项目中很常见值得花几分钟写个辅助函数。8. 我个人的实操体会与建议写了这么多年代码有向图简单路径这个问题我至少实现过几十遍每次都有新的体会。最大的感受是不要一上来就追求最优解先把正确性保证了再考虑优化。我见过太多人为了追求性能写了一堆复杂的剪枝逻辑结果连基本的正确性都没保证调试起来痛苦不堪。另外一个体会是图论算法的调试可视化是关键。你脑子里想的图结构和代码里实际构建的图结构往往有出入。把图打印出来把每次递归的状态打印出来比盯着代码空想有效得多。最后分享一个小技巧如果你在面试或者考试中遇到这个问题先把DFS的框架写出来确保能跑通然后再根据题目要求加优化。不要一上来就想写最优解那样很容易在细节上翻车。先把基础分拿到手再争取加分项这个策略在大多数场景下都是适用的。还有一点关于代码风格的建议图论算法的代码通常比较长变量名一定要起得有意义。u、v、w这种命名在数学推导里没问题但在代码里可读性很差。我通常用current、next、start、end这种有明确含义的名字虽然多打几个字符但调试的时候能省很多时间。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →