首页 / 资讯中心 / 文章详情

C++链表存储图的拓扑排序:队列、栈、DFS三实现对比解析

C++链表存储图的拓扑排序:队列、栈、DFS三实现对比解析 ★ FEATURED ARTICLE
最近整理课程设计源码时重新把“链表实现图的存储及拓扑排序”这道题过了一遍。这题目放在 C 数据结构里很经典也很有代表性它要求用链表把图存下来再用 C 自带的栈、队列和递归分别实现拓扑排序。表面看是三个排序版本实际上是把邻接表建图、入度统计、Kahn 算法、DFS 完成时间这些知识点串成了一整条线。这篇文章把这套完整思路、可运行代码和调试时踩过的坑都整理出来适合正在做数据结构课设或者想彻底搞懂拓扑排序原理的人参考。1. 从题目拆解开始链表存图与拓扑排序到底在考察什么1.1 四个核心考点拿到这种题目先别急着写代码。它其实同时考了四件事图的邻接表存储结构、有向图入度的计算、基于入度剥离的 Kahn 算法以及 DFS 与拓扑序之间的内在关系。课程设计喜欢用这个题目是因为它不靠单一知识点而是让你把一个图从“存”到“排”完整地走一遍。题目里“基于 C 自带的栈、队列及递归实现”这句话是个信号栈和队列不是让你自己造而是直接用标准库容器。递归也不是用来代替栈或队列而是另走一条 DFS 后序反转的路线。换句话说你已经有了三个可以对比的实现方案接下来需要理解每个方案为什么能得出合法拓扑序。1.2 拓扑排序的前提有向无环图拓扑排序处理的是有向图核心任务是把所有顶点排成一个线性序列使得对图中每条有向边 u→v顶点 u 在序列中都出现在 v 之前。这个概念用课程先修关系类比最好懂A 是 B 的先修课那么 A 必须排在 B 前面。但如果图里有环比如 A→B、B→C、C→A那么无论如何排序都会出现“先修课排在后面”的矛盾。所以拓扑排序只能在有向无环图DAG上进行代码里还必须带环检测。这也是后面三种实现都要返回 bool 值的原因排出来了返回 true发现环返回 false。1.3 输出不唯一别被网上代码带偏一个容易误解的地方拓扑排序的结果并不唯一。因为同一时刻可能有多个顶点入度为 0先处理哪个后处理哪个取决于你用的容器和边的存储顺序。队列版、栈版、递归版跑同一个图出来的序列可能不同但只要每条边的前驱都在后继前面就是正确结果。很多同学第一次对照输出发现不一样就以为自己写错了其实恰恰是理解了“不唯一性”的最好时机。2. 邻接表结构设计顶点表和边表的 C 表达2.1 为什么用链表而不是邻接矩阵邻接矩阵存图简单直接但空间是 O(V²)顶点一多就浪费。常规课程设计题目顶点数量一般控制在几十个以内矩阵还凑合但题目点名要用链表本质是希望你实现真正的邻接表——用一个顶点表数组每个顶点挂一条单链表链表里存的是它能直接到达的邻接点。这种结构的好处是空间和边数挂钩稀疏图下优势明显。遍历某个顶点的所有出边时沿着它的链表走下去就行不需要扫一整行矩阵。2.2 结构体定义与类的封装边表节点和顶点表节点这样定义#include iostream #include vector #include queue #include stack using namespace std; class Graph { private: // 边表节点存储邻接顶点编号 struct EdgeNode { int adjVertex; // 邻接顶点编号 EdgeNode* next; // 指向下一条边 EdgeNode(int v) : adjVertex(v), next(nullptr) {} }; // 顶点表节点存储顶点数据以及该顶点的第一条出边 struct VertexNode { char data; // 顶点数据这里用字符 A、B、C... EdgeNode* firstEdge; // 出边链表的头指针 VertexNode() : data(0), firstEdge(nullptr) {} }; vectorVertexNode vertices; // 顶点表 int vertexNum; int edgeNum; public: Graph(int n, int m) : vertexNum(n), edgeNum(m) { vertices.resize(n); for (int i 0; i n; i) { vertices[i].data A i; } } // 插入一条有向边 u - v void addEdge(int u, int v) { EdgeNode* node new EdgeNode(v); node-next vertices[u].firstEdge; vertices[u].firstEdge node; } };顶点表用 vector 包一层是因为顶点数在构造时已知直接 resize 初始化更方便。每个顶点的出边用单链表串联链表节点带一个 int 类型的邻接顶点编号和 next 指针。这里用的是头插法也就是新边插到链表头部时间复杂度 O(1)后面会详细说为什么选头插而不是尾插。2.3 头插法的代价与意图addEdge 里最关键的一行是node-next vertices[u].firstEdge; vertices[u].firstEdge node;这两行把新边节点放到链表头部。头插法建边非常快不需要遍历链表找尾部但代价是如果你按输入顺序依次插入 0→1、0→2最终顶点 0 的链表顺序会变成 2、1和输入顺序相反。有人第一次调试时看到邻接点输出反了以为建图出错其实结构没错只是遍历方向变了。拓扑排序只关心每个顶点的邻接集合不关心它们的具体排列顺序所以头插法完全够用。当然如果题目的输出测试用例对邻接表顺序有明确要求那就得改成尾插或者先全部插完再反转否则不需要纠结。2.4 析构函数链表释放不能漏链表存图最大的隐藏问题就是内存释放。每个 addEdge 都 new 了一个节点如果只写建图逻辑不写析构程序运行结束会泄漏大量内存。课程设计代码没跑几组数据可能感觉不出来但工程上这是大忌。~Graph() { for (int i 0; i vertexNum; i) { EdgeNode* p vertices[i].firstEdge; while (p) { EdgeNode* tmp p; p p-next; delete tmp; } vertices[i].firstEdge nullptr; } }释放时逐个顶点处理它挂着的边表先保存 next 再 delete 当前节点。这个问题放在踩坑章节还会再提一次因为它真的很容易被忽略。3. 队列版本Kahn 算法如何一层层剥掉依赖3.1 先算出入度表数清楚每个顶点被谁依赖Kahn 算法的核心是“入度”。一个顶点的入度等于有多少条有向边指向它也就是它在图里被多少顶点依赖。入度为 0 的顶点没有前置依赖可以最先输出。计算入度时需要遍历全部顶点的出边链表vectorint computeInDegree() { vectorint inDeg(vertexNum, 0); for (int i 0; i vertexNum; i) { EdgeNode* p vertices[i].firstEdge; while (p) { inDeg[p-adjVertex]; p p-next; } } return inDeg; }逻辑很简单对每个顶点 i沿着它的出边链表往前走每遇到一条边指向 v就把 v 的入度加 1。最终 inDeg 数组里存的就是所有顶点的入度。这里有个容易搞错的地方入度数组统计的是“指向该顶点的边数”而不是“该顶点指向别人的边数”别把方向搞反。3.2 Kahn 算法的完整队列实现队列版本的流程是这样的先找到所有入度为 0 的顶点放进队列然后不断从队头取出顶点加入结果序列对每个被取出的顶点把它所有出边指向的邻接点入度减 1一旦某个邻接点入度减到 0说明它的所有前置依赖都已经处理完把它放入队列。重复直到队列为空。bool topologicalSortQueue(vectorchar order) { vectorint inDeg computeInDegree(); queueint q; // 入度为 0 的顶点入队 for (int i 0; i vertexNum; i) { if (inDeg[i] 0) { q.push(i); } } while (!q.empty()) { int cur q.front(); q.pop(); order.push_back(vertices[cur].data); EdgeNode* p vertices[cur].firstEdge; while (p) { int next p-adjVertex; inDeg[next]--; if (inDeg[next] 0) { q.push(next); } p p-next; } } return order.size() (size_t)vertexNum; }返回值判断的是结果序列长度是否等于顶点总数。如果图中有环环上的每个顶点入度永远不可能减到 0队列会提前空。这时候 order 里少了一部分顶点返回 false调用方就知道图不是 DAG。3.3 为什么队列版输出看起来像“一次遍历一层”Kahn 算法用队列时有个很自然的特点初始入度为 0 的顶点先全部入队然后每出队一个顶点才可能让新的顶点入度为 0。这种现象就像在处理依赖图时一层一层往外剥先处理没有先修课的课程再处理只依赖于已处理课程的课程。这种层次感很适合理解拓扑排序的直觉。但要注意队列先进先出保证了类层次上的稳定推进但同一层内顶点的先后顺序取决于它们入队的具体顺序而这一顺序又取决于边表的排列顺序。所以队列版输出不唯一但它给出的序列一定满足所有先后依赖关系。3.4 复杂度分析为什么 Kahn 算法这么快整个算法对每条边最多访问一次每个顶点最多入队出队一次所以时间复杂度为 O(VE)。空间上除了顶点表和边表外额外用一个入度数组和一个队列都是 O(V) 级别。这个复杂度在实际中表现非常优秀处理几万条边的图也毫无压力。课程设计阶段你甚至可以把这个复杂度分析写进报告展示你是否真正理解了算法而不是只会贴代码。4. 栈版本换一个容器拓扑序就有新的形态4.1 栈版本的代码差异栈版本和队列版本的 Kahn 算法几乎一模一样唯一区别是把 queue 换成 stack。入度为 0 的顶点压栈每次从栈顶取一个顶点处理出边的邻接点入度减到 0 时压栈。bool topologicalSortStack(vectorchar order) { vectorint inDeg computeInDegree(); stackint s; for (int i 0; i vertexNum; i) { if (inDeg[i] 0) { s.push(i); } } while (!s.empty()) { int cur s.top(); s.pop(); order.push_back(vertices[cur].data); EdgeNode* p vertices[cur].firstEdge; while (p) { int next p-adjVertex; inDeg[next]--; if (inDeg[next] 0) { s.push(next); } p p-next; } } return order.size() (size_t)vertexNum; }从代码量来看栈版本就是队列版本的一次“换容器”操作。但正是因为容器改成后进先出顶点的处理顺序会发生变化。你可以把它理解为队列版按“谁先满足条件谁先走”的公平策略推进栈版则倾向于“新满足条件的顶点优先处理”因此更容易产生深度优先式的拓扑序。4.2 用同一张图对比队列版和栈版用一个简单的 DAG 来验证差异图的结构是A(0) → B(1)A(0) → C(2)B(1) → D(3)C(2) → D(3)D(3) → E(4)由于建图时采用头插法顶点 A 的出边链表顺序是 C、B。这个细节不会影响拓扑排序的正确性但会直接影响输出的顶点次序。队列版输出A → C → B → D → E 栈版输出A → B → C → D → E两个序列都满足 A 在 B、C 前B、C 都在 D 前D 在 E 前所以它们都是合法拓扑序。区别只在于队列版先把 C 处理掉再处理 B栈版处理完 A 后栈顶是 B于是先走 B 这条分支。4.3 为什么两个结果都合法拓扑序的不唯一性很多人第一次看到这里会疑惑同一个图两个输出怎么不一样关键在于 Kahn 算法的逻辑是“每次从当前入度为 0 的候选集合里挑一个顶点处理”。这个候选集合可能同时包含多个顶点挑哪个都合法。队列决定了先进入集合的先被处理栈决定了后进入集合的反而先被处理。所以拓扑排序的结果并不是一个固定序列它只保证对所有边 u→vu 一定排在 v 前面。从这个角度说两种输出没有“谁对谁错”的区别只有处理策略不同。理解这一点后再看递归版本就会轻松很多。5. 递归版本DFS 后序输出拓扑序的本质5.1 递归版本不是 Kahn 算法而是 DFS 的巧妙反转队列版和栈版本质上都是 Kahn 算法只是容器不同。递归版本则完全不同它不再依赖入度数组而是对图做深度优先搜索利用“完成时间”来构造拓扑序。核心结论是在一个 DAG 中DFS 完成时间的逆序就是一个合法的拓扑序列。换句话说递归函数对某个顶点的所有邻接点访问完毕、即将返回之前把这个顶点标记为“已完成”所有顶点完成后把“已完成”的顺序反过来输出就是拓扑序。为什么因为如果在 DFS 过程中u 有一条边指向 v那么 v 一定比 u 更早完成要么 v 在 u 的递归调用过程中被处理完要么 v 早就完成了。所以完成顺序里 v 在 u 前面反过来之后 u 就出现在 v 前面恰好满足 u→v 的依赖约束。5.2 三色标记法同时完成判环递归实现里要处理一个关键问题如何判断图中是否有环。这里使用经典的三色标记法0 表示未访问1 表示访问中即当前顶点还在递归栈里2 表示访问完毕如果 DFS 过程中遇到一个状态为 1 的顶点说明从当前顶点出发能找到一条回到“正在访问中”顶点的路径这就形成了环。状态为 2 的顶点则说明它已经被完整处理过直接跳过。bool topologicalSortDFS(vectorchar order) { vectorint state(vertexNum, 0); // 0 未访问1 访问中2 已完成 stackchar result; // 用栈保存完成顺序 for (int i 0; i vertexNum; i) { if (state[i] 0) { if (!dfsTopo(i, state, result)) { return false; } } } while (!result.empty()) { order.push_back(result.top()); result.pop(); } return true; } bool dfsTopo(int v, vectorint state, stackchar result) { state[v] 1; // 标记访问中 EdgeNode* p vertices[v].firstEdge; while (p) { int next p-adjVertex; if (state[next] 1) { return false; // 发现环 } if (state[next] 0) { if (!dfsTopo(next, state, result)) { return false; } } p p-next; } state[v] 2; // 标记完成 result.push(vertices[v].data); // 压入完成顺序 return true; }注意这里的结果容器用的是 stack 正好利用了栈后进先出的特性。每次 DFS 完成一个顶点就压栈最后从栈顶弹出时完成时间最晚的顶点会最先被输出完成时间最早的顶点最后输出正好实现了完成时间逆序。5.3 递归深度什么时候会栈溢出递归版本的最大风险是栈溢出。如果图退化成一条长链比如顶点依次连接从第一个顶点开始 DFS 的递归深度会等于顶点总数。普通程序默认栈空间有限顶点数量达到几万甚至更多时可能出现运行时崩溃。课程设计的测试数据一般不会这么大所以这里可以放心用递归。但如果想扩展成健壮版本可以考虑把递归改成显式栈迭代。显式栈实现的思路是模拟递归过程维护一个成员栈保存访问状态同时再用另一个栈保存完成序列。这样既保留 DFS 的拓扑序逻辑又避免系统栈深度限制。写报告时提一句这个优化方向通常能拿到额外加分。5.4 递归版本的访问顺序和输出示例用前面那个 A、B、C、D、E 的测试图递归版本最终输出为 A → B → C → D → E。它与栈版输出刚好相同但形成原因完全不同栈版是 Kahn 算法通过入度剥离得到的深优节奏递归版是 DFS 完成时间反转。如果你把递归版代码里的顶点访问顺序改成先遍历 B 分支再遍历 C 分支输出又会不一样这再次印证了拓扑序不唯一的性质。6. 三个版本横向对比与测试验证6.1 三个实现方案的对比表对比维度队列版栈版递归版核心思想Kahn 算法Kahn 算法DFS 完成时间反转容器queuestack系统递归栈 stack 结果栈是否依赖入度是是否判环方式输出顶点数 总顶点数输出顶点数 总顶点数状态为 1 时检测回边输出特征层次化推进深度优先倾向完成时间逆序时间复杂度O(VE)O(VE)O(VE)空间复杂度O(V)O(V)O(V)递归深度受系统栈限制三个版本时间复杂度和空间复杂度都一样区别主要在理解角度和输出形态。队列版最容易写对递归版最考验对 DFS 的理解。如果你的课设要求写报告把这张表的内容展开讲会显得很有深度。6.2 主函数设计封装成可复用的测试框架主函数里可以同时调用三个方法把结果分别打印出来确认都满足拓扑序。完整测试框架如下int main() { int n, m; cin n m; Graph g(n, m); for (int i 0; i m; i) { int u, v; cin u v; g.addEdge(u, v); // 添加有向边 u - v } vectorchar orderQueue; vectorchar orderStack; vectorchar orderDFS; cout 队列拓扑排序: ; if (g.topologicalSortQueue(orderQueue)) { for (char c : orderQueue) cout c ; cout endl; } else { cout 图中存在环 endl; } cout 栈拓扑排序: ; if (g.topologicalSortStack(orderStack)) { for (char c : orderStack) cout c ; cout endl; } else { cout 图中存在环 endl; } cout 递归拓扑排序: ; if (g.topologicalSortDFS(orderDFS)) { for (char c : orderDFS) cout c ; cout endl; } else { cout 图中存在环 endl; } return 0; }输入格式约定为第一行两个整数 n、m分别表示顶点数和边数之后 m 行每行两个整数 u、v表示一条从 u 指向 v 的有向边。顶点编号从 0 开始默认数据用大写字母 A 开始表示如果顶点超过 26 个可以把 VertexNode 里的 data 类型改成 int 或者干脆去掉 data只保留编号。6.3 有环图的测试结果测试一张有环图3 3 0 1 1 2 2 0三个版本的输出都会是“图中存在环”。队列版和栈版是因为结果序列长度只有 2小于 3递归版则是在访问到状态为 1 的顶点时直接返回 false。这里的差异是Kahn 算法需要跑完整张图才能确定有没有环而 DFS 版本一旦发现回边就能提前终止处理速度可能更快。6.4 自环和重边的边界情况自环就是 u→u 的边。这种边在 Kahn 算法里会让 u 的入度增加 1但 u 若入度为 0 进入队列处理时把 u 的入度减 1 又回到 0看起来好像能输出实际上还需要小心自环意味着存在 u→u也就是 u 必须排在 u 前面这显然是死循环。正确的处理方式是通过环检测把它拦截下来。递归版遇到自环时dfs 内部会发现 next 的 state 为 1直接返回 false处理很干净。重边则是 u→v 出现多次。Kahn 算法按边数统计入度重边会让 v 的入度增加多次后续处理时每访问一次 u 的邻接表就减一次只要所有边都遍历到结果依然正确。但要注意入度数组必须按实际边数加减不能因为觉得“同一个顶点指向同一个顶点”就合并成一条否则会因为入度提前减到 0 而输出错误序列。7. 踩坑总结数据结构课程设计常见的翻车点7.1 头插法导致邻接点顺序颠倒打印调试误判这是我在调试时遇到的第一个坑。输入顺序是 0→1、0→2可打印顶点 0 的邻接链表时输出却是 2、1。一开始以为 addEdge 写错了后来才反应过来是头插法的正常结果。建议在建图后单独写一个打印邻接表的函数先把图的结构确认无误再继续写拓扑排序省得后面排错了还要回头怀疑建图逻辑。7.2 入度要在函数内重新计算不能复用同一份计算入度的函数 computeInDegree 每次调用都会生成一份新的入度数组。Kahn 算法处理过程中会不断修改这个数组如果反复调用同一个排序函数而不重算上次运行留下的入度 0 状态会污染下一次排序。正确做法是每次排序函数内部都调用 computeInDegree 获取全新数组。递归版因为没有修改入度数组所以不存在这个问题。7.3 递归判环时状态数组和入度数组千万别混用递归版用 state 数组做三色标记状态为 1 表示访问中。有人写错版本时会在 dfsTopo 里同时维护入度数组试图模仿 Kahn 算法的减入度逻辑结果状态和入度两套逻辑纠缠在一起一旦遇到分叉图就返回错误结果。记住递归版的核心是 DFS 状态不是入度。只要某个顶点有一条边连到还没完成的顶点那就是环这和入度没有直接关系。7.4 链表节点的内存释放问题这个题目结束后最少有 m 个 EdgeNode 节点是 new 出来的。主函数直接结束不释放程序也能跑出正确结果但这不是好习惯。课程设计答辩时老师如果抽查析构函数看到裸指针没有 delete 会扣分。给 Graph 类补上析构函数逐个顶点遍历边表释放节点代码放心自己也安心。7.5 一个实用的小技巧输出时带上入度信息辅助调试把顶点编号、顶点数据和当前入度一起打印能更快定位问题。尤其是 Kahn 算法调了很久不对时大多数情况不是算法逻辑错而是入度统计错了。比如边是 u→v却在统计时写成了 inDeg[u]这样初始入度为零的顶点会变成非零整个排序过程全乱。打印入度表是最直观的排查手段。7.6 扩展方向从手动输入到可视化如果学有余力可以给这个项目加一个简单的扩展用文本文件存储图数据或是在输出时按拓扑序把顶点排成一行并用箭头连接。更进一步可以对拓扑序做“逆拓扑序”输出即把每条有向边 v→u 视为依赖关系用同一个框架处理反向图。这些都建立在理解了拓扑排序本质的基础上加进去之后这个课程设计就不再只是交差而是真正变成了一个可扩展的小工具。我自己的体会是链表存图和拓扑排序的组合题目最值得花时间的地方不是背代码而是把三个实现放在一起对比搞清楚它们各自的取舍。当你看到同一个图从队列里出来一个序列、从栈里出来另一个序列、从递归里又出来一个序列而每个都合法时拓扑排序的“不唯一性”就刻在脑子里了。这个理解比单纯跑通代码重要得多。
阅读完成 · 觉得有帮助?
咨询建站