☰
拓扑排序与Dijkstra最短路径:图论两大核心算法C++实战解析
2026/10/12 21:31:29 网站建设 项目流程

今天打卡到第六十三天,图论部分正好进入一个承上启下的节点。卡码网KamaCoder这两道题——117. 软件构建和47. 参加科学大会,一个是有向无环图的拓扑排序,一个是带权图的最短路径,可以说是把“图论里最实用的两类问题”一次性摆到了你面前。说它们实用,是因为拓扑排序对应的是软件包依赖、编译顺序、任务调度这些每天都在发生的场景,而Dijkstra(迪杰斯特拉算法)更是导航、网络路由、物流规划背后的常客。这篇博文就把这两道题的完整解法、推导过程和踩坑记录都整理出来,代码用C++写,核心思路讲清楚,让你看完能直接上手复现,也能理解为什么这类题在面试和实际工程里反复出现。

1. 为什么第六十三天要同时刷这两道题

1.1 两道题在训练营中的定位

代码随想录的图论章节安排是有讲究的。前面几天把图的存储方式(邻接矩阵、邻接表)、深度优先搜索、广度优先搜索都过了一遍,第六十三天开始进入“图论应用题”的阶段。拓扑排序本质上是“依赖关系”的线性化,而最短路径则是“代价优化”问题,一个是先决条件的处理,一个是全局最优的选择。

软件构建这道题的题面很直白:有N个文件,编号从0到N-1,文件之间有依赖关系,比如文件A依赖文件B,那就得先构建B再构建A。题目会给你一个依赖关系列表,要求你输出一个合法的构建顺序,如果存在循环依赖就输出空。这不就是现实中make、gradle、maven构建项目时干的事情吗?编译系统拿到依赖图,必须先拓扑排序才能决定谁能先编译。

参加科学大会这道题,题面可以理解为:给定N个地点和M条单向路线,每条路线有一个通行代价,求从起点到终点的最小代价。如果不可达就返回-1。这个就是标准的最短路问题,边上带权,权值可以是时间、距离、金钱消耗中的任意一种。题解标准做法是Dijkstra算法,因为训练营这个阶段还没有引入堆优化和SPFA,所以用朴素O(V^2)版本完全够用。

1.2 两道题背后隐含的经典场景

刷题不能只背模板,得知道模型是从哪来的。拓扑排序对应的现实问题,除了软件构建,还有课程表的先修课程安排(比如选课系统里“要先学高数才能学线代”)、数据库里外键约束的初始化顺序、甚至是包管理器里npm install的依赖解析。只要问题里有“谁必须先于谁完成”的描述,第一反应就应该是拓扑排序。

最短路径对应的场景就更多了。导航App里从A点到B点的最短驾车时间、快递配送的路径规划、网络协议中OSPF(开放最短路径优先)的路由计算,底层跑的算法都离不开Dijkstra思想。区别只是工程上会加堆优化、用邻接表存储、甚至上A*搜索方向加速,但核心的“松弛操作”思想是完全一致的。

所以这两道题放在同一天刷,训练的价值就在于:你能从两个维度理解“图”这个数据结构——依赖维度和代价维度。依赖图关心的是次序,代价值图关心的是权重。

1.3 我对这道题组合的真实感受

说实话,初次刷这两题的时候,我最直观的感受是“拓扑排序比最短路好理解得多”。拓扑排序逻辑起点很朴素:入度为0的节点说明没有前置依赖,可以直接处理,处理完就把它指向的节点的入度减1,再继续找下一个入度为0的节点。反复循环就能得到合法的线性顺序。

而Dijkstra需要转换视角。朴素版本里,你需要从起点出发,每一轮从未访问过的节点里挑一个距离起点最近的节点作为“已确定最短路径”的节点,然后去更新它相邻节点的距离。这个“每次选最近、再扩展更新”的思路,第一次接触确实需要花点时间消化。我建议你刷完题后,自己歪歪扭扭画一个5个点的带权有向图,手动跑一遍整个流程,比看十遍代码都管用。

2. 软件构建与卡码网117题的完整拆解

2.1 看题面之前先把“依赖关系”翻译成图

117题的输入格式是:第一行N表示文件数量,第二行M表示依赖关系数量,接下来M行每行给出s, t,表示文件s依赖文件t,也就是先构建t,再构建s。很多初学者第一件事就栽在理解这个输入上:到底谁指向谁?

我的建议是不要死记“s依赖t”这句话,而是问自己一个问题:我给哪个节点累加入度?拓扑排序里我们常说的“入度”指的是有多少条边指向自己。文件s依赖文件t,意味着t要先构建,那么在依赖图上应该画一条从t指向s的边,因为t是s的前置条件。于是s的入度要加1,因为它多了一个前置依赖。邻接表的存储则是graph[t].push_back(s),表示t处理完之后可以去解锁s。

这个方向一旦搞错,后面全乱套。我自己第一次写的时候就是搞反了,结果构建顺序怎么都不对。记住一句话:依赖别人的人入度加一,被依赖的人出边指向别人。用代码来表达就是:

vector<vector<int>> graph(n); vector<int> inDegree(n, 0); for (int i = 0; i < m; i++) { int s, t; cin >> s >> t; // s依赖t graph[t].push_back(s); inDegree[s]++; }

2.2 为什么用邻接表而不是邻接矩阵

卡码网这道题的N上限并不算大,你用邻接矩阵也能过。但从训练的角度来说,我强烈建议你习惯用邻接表,原因有三个。

第一个原因是复杂度。邻接矩阵存储需要O(V^2)的空间,遍历某个节点的所有邻居需要O(V)的时间。而邻接表存储只需要O(V+E)的空间,遍历邻居也只需要O(出边数量)的时间。图越稀疏,邻接表的优势越明显。

第二个原因是代码的可读性。graph[t][i]和for (int next : graph[t])两种写法,后者一眼就能看出“从t出发能到达哪些点”,逻辑上更贴合“解除依赖”这个动作。

第三个原因是为后续更复杂的图算法做准备。Kruskal最小生成树、堆优化的Dijkstra、SPFA(Shortest Path Faster Algorithm)、拓扑排序的队列实现,这些全部天然适合邻接表。你从一开始就用邻接表写,后面换算法不会觉得别扭。

当然,邻接矩阵也不是一无是处。如果题目需要频繁判断两个点之间是否有边,或者图非常稠密(边数接近V^2),邻接矩阵的O(1)查询优势就会体现出来。所以选择存储方式的核心依据是图的稀疏程度和你要做的操作类型,而不是无脑选某一种。

2.3 拓扑排序的三个核心步骤

拓扑排序的标准实现就是Kahn算法,BFS思路,整个过程可以拆成三步。

第一步,把所有入度为0的节点放入队列。为什么入度为0?因为这些节点没有任何前置依赖,它们就是可以直接开工的任务。初始队列里可能有多个节点,它们之间的相对顺序不影响正确性。

第二步,循环遍历队列。每次取出队首节点,把它存到结果数组中,然后遍历它的所有出边邻居,把邻居的入度减1。如果某个邻居的入度减到了0,说明这个邻居的所有前置依赖都已经处理完了,它现在可以入队了。

第三步,循环结束后,检查结果数组的长度是否等于N。如果等于,说明所有节点都能被处理,不存在循环依赖;如果小于N,说明有环存在,那些永远无法把入度减到0的节点就是环上的节点。

有一个小细节值得注意:这个算法处理的是“每个文件最多构建一次”的场景,所以不需要visited数组。入度减到0本身就是“可以处理”的标记,入队之后节点被取出来,结果就被记录。如果你看到某些写法里还有额外的标记数组,通常是为了应对更复杂的场景(比如去重要求),这道题不需要。

2.4 完整题解代码与执行流程

下面给出C++完整实现,这个是我在卡码网提交通过的版本:

#include <iostream> #include <vector> #include <queue> using namespace std; int main() { int n, m; while (cin >> n >> m) { vector<vector<int>> graph(n); vector<int> inDegree(n, 0); for (int i = 0; i < m; i++) { int s, t; cin >> s >> t; // s依赖t,先构建t graph[t].push_back(s); inDegree[s]++; } queue<int> que; for (int i = 0; i < n; i++) { if (inDegree[i] == 0) { que.push(i); } } vector<int> result; result.reserve(n); while (!que.empty()) { int cur = que.front(); que.pop(); result.push_back(cur); for (int next : graph[cur]) { inDegree[next]--; if (inDegree[next] == 0) { que.push(next); } } } if (result.size() == n) { for (int i = 0; i < result.size(); i++) { if (i > 0) cout << " "; cout << result[i]; } cout << endl; } else { cout << 0 << endl; } } return 0; }

注意最外层有一个while (cin >> n >> m),因为卡码网的题目通常是多组测试用例输入,有的同学忘记写这个循环,只过一组数据就报错了。多组输入的习惯在ACM风格题目中一定要养成,哪怕题目描述里没明说,也建议用这种写法兜底。

执行流程举个例子。假设N=4,M=4,依赖关系是:文件1依赖文件0,文件2依赖文件0,文件3依赖文件1和文件2。翻译成图就是:graph[0] = [1, 2], graph[1] = [3], graph[2] = [3],inDegree = [0, 1, 1, 2]。

初始队列里面只有节点0,因为只有它没有前置依赖。取出0,把1和2的入度各减1,1和2的入度都变成0,都入队。假设先取出1处理,把3的入度从2减到1;再取出2,把3的入度从1减到0,3入队。最后取出3。得到一个合法的构建顺序:0 1 2 3。如果先取出的是2再取出1,结果就是0 2 1 3,两种顺序都正确。

2.5 循环依赖的判定与处理

循环依赖是个非常经典的坑。假设有三个文件,文件A依赖B,文件B依赖C,文件C依赖A,这就构成了一个环。此时所有节点的入度都不是0,初始队列为空,结果数组为空,result.size() != n,直接输出0,代表无法构建。

程序判断“有环”的本质是:环上的节点永远不会把入度减到0,因为环上没有任何一个节点可以“先被处理”。所以最终结果长度一定小于节点总数。这个方法判断有向环是非常可靠的,而且不会多占用额外空间。

在实际工程里,构建系统检测到循环依赖通常会报一个错误,甚至帮你把环上的节点链打印出来。不过在算法题阶段,只需要输出0表示无法完成构建即可。如果你想输出具体的环,需要在图上做DFS检测每个节点的状态,那就是更深一层的问题了。

3. 参加科学大会与Dijkstra最短路径

3.1 什么样的输入暗示我们要用Dijkstra

47题的输入结构是:第一行N个场地、M条单向路线,接下来M行每行给出三个数from, to, weight,表示从from到to有一条路线,通行代价为weight。起点默认为1号场地(有的版本是0号),要求输出从起点到终点N的最小总代价。

如果在审题时捕捉到这几个关键词——单项关系、带权、求最短总代价,基本可以锁定用Dijkstra。带权图的最短路径问题,只有权重为正时才能用Dijkstra;如果权重有负数,就得用Bellman-Ford算法进行多次松弛操作,或者用SPFA,这些在训练营后面的章节会展开。这道题明确说了权值都是正整数,所以用Dijkstra是安全的。

3.2 朴素Dijkstra的核心思想:贪婪的松弛过程

朴素Dijkstra的过程可以用一句话概括:从起点出发,每次从未确定最短距离的节点中,挑一个当前距离值最小的节点,把它标记为“已确定”,然后用这个节点去尝试更新它的所有邻居节点的距离。

这个“每次挑最小”的行为就是贪婪策略。为什么这样做是正确的?因为所有边权都是正数,所以任何经过其他未确定节点绕到当前最小节点的路径,都不可能比当前直接的距离值更小(绕路只会增加总代价)。这就是Dijkstra正确性的根本依据。

如果用生活化的例子来类比:你在大学食堂一楼,想去三楼的某个窗口。已知一楼到二楼有楼梯,二楼到三楼也有楼梯,也可能有一楼直达三楼的电梯。你不知道电梯是否存在、速度多快,于是你先把所有已知的“从一楼能直接到达的地方”记下时间,然后选一个时间最短的地方先去看看,到了那里再重新更新其他地点的最短时间。反复执行这个流程,最终就能得到从一楼到所有地方的最短时间。

朴素Dijkstra在每一轮找最小值时,需要扫描整个未访问节点集合,所以时间复杂度是O(V^2)。在这个数据范围内完全可行。

3.3 朴素版本完整代码与推演

先给出朴素Dijkstra的C++实现:

#include <iostream> #include <vector> #include <climits> using namespace std; int main() { int n, m; while (cin >> n >> m) { vector<vector<int>> graph(n + 1, vector<int>(n + 1, INT_MAX / 2)); for (int i = 0; i < m; i++) { int from, to, weight; cin >> from >> to >> weight; graph[from][to] = weight; // 单向边 } vector<int> minDist(n + 1, INT_MAX / 2); vector<bool> visited(n + 1, false); minDist[1] = 0; for (int round = 0; round < n - 1; round++) { int cur = -1; int curMin = INT_MAX / 2; for (int i = 1; i <= n; i++) { if (!visited[i] && minDist[i] < curMin) { curMin = minDist[i]; cur = i; } } if (cur == -1) break; // 剩下的节点都不可达 visited[cur] = true; for (int next = 1; next <= n; next++) { if (!visited[next] && graph[cur][next] < INT_MAX / 2) { minDist[next] = min(minDist[next], minDist[cur] + graph[cur][next]); } } } if (minDist[n] == INT_MAX / 2) { cout << -1 << endl; } else { cout << minDist[n] << endl; } } return 0; }

做几个细节解释。

先解释为什么初始化矩阵和minDist都用INT_MAX / 2而不是INT_MAX。因为后面要执行minDist[cur] + graph[cur][next],如果这两个值都是INT_MAX,相加时会发生整数溢出,变成负值,代码的错误非常隐蔽。除以2是为了确保两个最大值相加仍小于INT_MAX,同时又足够大,能表示“无穷远”的概念。

再解释为什么要从1到n存储节点而不是0到n-1。这个纯粹是因为卡码网的题目要求起点编号是1,为了和输入对齐,直接用n+1长度的数组最方便,免得下标换算把自己搞晕。每道题你都要先看好输入输出描述的编号规则,不要盲目套模板。

最后解释一下外层循环为什么最多跑n-1轮。因为起点自己的最短距离已经确定了,剩下n-1个节点,每一轮能确定一个节点的最终最短路径。即使有些节点不可达,循环会在cur == -1时提前退出,不会浪费多余轮次。

3.4 堆优化版本应该怎么写

朴素版本在数据量大的时候不够用,这时需要引入堆优化Dijkstra。堆优化版本的核心改动有两处:第一处是用优先队列(最小堆)存储候选节点,按距离值从小到大排序,这样每次取出堆顶就能拿到距离最小的节点,而不是扫描整个数组;第二处是用邻接表存储图,这样遍历邻居的时间会大大降低。

堆优化版本的代码思路如下:起点入队,循环处理队列。每次取出队首节点(距离值最小的节点),如果该节点已经被访问过,直接跳过;否则标记已访问,遍历它的所有邻接边,执行松弛操作。如果某个邻居的最短距离被更新了,就把它重新入队。

这里有一个非常关键的代码细节:优先队列里存的pair<int, int>,第一个int是距离值,第二个int是节点编号。因为默认的priority_queue是最大堆,所以我们要声明为greater模式,或者把距离值取负数放入。我偏爱用greater,代码更直观。

#include <iostream> #include <vector> #include <queue> #include <climits> using namespace std; int main() { int n, m; while (cin >> n >> m) { vector<vector<pair<int, int>>> graph(n + 1); for (int i = 0; i < m; i++) { int from, to, weight; cin >> from >> to >> weight; graph[from].push_back({to, weight}); } vector<int> minDist(n + 1, INT_MAX / 2); vector<bool> visited(n + 1, false); minDist[1] = 0; priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; pq.push({0, 1}); while (!pq.empty()) { auto [d, cur] = pq.top(); pq.pop(); if (visited[cur]) continue; visited[cur] = true; for (auto& edge : graph[cur]) { int next = edge.first; int weight = edge.second; if (!visited[next] && minDist[cur] + weight < minDist[next]) { minDist[next] = minDist[cur] + weight; pq.push({minDist[next], next}); } } } if (minDist[n] == INT_MAX / 2) { cout << -1 << endl; } else { cout << minDist[n] << endl; } } return 0; }

注意看,visited标记的判断非常重要。虽然优先队列保证了取出的节点距离是最小的,但同一个节点可能因为多次更新而进入队列多次。如果不加visited判断,就会重复处理同一个节点,虽然结果仍正确,但效率会下降。在堆优化版本里,一旦一个节点被取出来,就说明它的最短路径已经确定,后续再被更新不可能更小,所以可以直接跳过。

3.5 Dijkstra为什么不能处理负权边

这个问题面试官特别喜欢问,你自己也要想透彻。Dijkstra的每一步都建立在“已经确定最短路径的节点,即使绕路也不可能更短”的基础之上,而这个基础依赖所有边权非负。

如果存在负权边,情况就变了。假设从A到B有一条边权为10的边,从A到C有一条边权为5的边,从C到B有一条边权为-5的边。那么现在从A到B最短的路是先经过C再经过B,总代价是0,比直接走10还要短。但是Dijkstra在第一轮选择时,会先从A到C(代价5),确定C的最短路径为5;然后第二轮取出B(代价10),确定B的最短路径为10。此时你永远不会用C更新B,因为C已经被标记为已访问了。这样算出来的结果就是错的。

所以看到负权边,立刻换用Bellman-Ford。那道题虽然可能让你用Dijkstra就够,但你需要具备判断“什么时候不能用什么算法”的能力。

4. 刷题过程中的常见问题和排查技巧

4.1 拓扑排序结果不唯一的分支处理

有些同学做完拓扑排序后发现自己的输出和标准答案不一样,就以为自己写错了,其实很可能只是合法的不同次序而已。卡码网的判题系统通常会做特殊性判断,只要你的输出是一个合法的拓扑序列就判对。

什么情况下会出现多解?初始入度为0的节点就有多个、或者多个节点的入度同时降到0时,选择处理顺序的不同会直接导致输出顺序不同。这不是bug,而是拓扑排序的天然性质。如果题目要求字典序最小,你需要把普通的队列换成优先队列,每次弹出编号最小的可处理节点。工程上构建系统为了保证构建结果可复现,通常会选用固定的排序规则。

4.2 优先队列的排序规则对比

写到堆优化Dijkstra时,很多新手容易犯一个经典的错误:写成了把{cur, distance}放进优先队列,然后发现结果不对。

pair的默认排序规则是先比较第一个元素再比较第二个元素。所以如果你把节点编号放在第一个位置,优先队列就会按节点编号排序,而不是按距离排序,整个算法就会退化成“按编号顺序一个个访问”,完全失去Dijkstra的贪心特性。

正确做法是把距离放在第一个位置,节点编号放第二个位置。如果你觉得每次写greater<pair<int, int>>比较烦,可以使用using Node = pair<int, int>;来给类型起别名,或者干脆写一个自定义比较器,这会减少很多笔误。

4.3 多组输入导致的状态未清空问题

卡码网这类OJ(在线判题系统)的一大特点就是多组测试数据。如果变量定义在while循环外面,而你在处理完一组数据后没有重置数组,下一组数据就会带着旧的状态运行,结果必然出错。

我在刷题时习惯把核心数据结构全部定义在while循环内部,让每组测试用例都有全新的存储空间。这样虽然看起来有点浪费内存,但对正确性的保证效果是立竿见影的。特别是vector这种容器,每次循环重新构造的开销远远小于排查状态残留问题的时间成本。

4.4 题目细节:输出格式与编号起点

很多卡在边界上的同学不是算法写错了,而是没注意到起点的编号是1而不是0。如果题目要求输出“从1到N的最短路径”,而你的循环从0开始遍历,最终会漏掉起点。建议每次写题前先把输入输出范围抄在草稿纸上,包括编号起点、是否有重边(重边取最小值)、是否有多组测试用例、不可达时输出什么。把这些边缘情况确认好,再开始写算法。

还有一个常见问题:题目有可能存在重边,也就是同一对from和to给了多条不同权重的路线。如果你用邻接矩阵存储,直接执行graph[from][to] = weight会把前面更小的权值覆盖掉吗?不会覆盖吗?要分情况。如果直接赋值,后面大的权值就把前面小的权值覆盖了,那求最短路就会出错。稳妥做法是graph[from][to] = min(graph[from][to], weight),保留最小权值。虽然大部分案例数据不会出这种刁钻的输入,但这个好习惯值得养成。

5. 把算法延伸到真实世界

5.1 软件构建中的拓扑排序与构建号

回到标题里出现的热搜词:有源医疗器械、独立软件、软件组件和固件的构建号。这个组合看起来很专业,但本质上说的就是软件工程里的构建管理问题。

大型医疗设备里往往包含独立软件(比如控制台程序)、软件组件(比如图像处理模块)和固件(比如底层驱动),它们之间有严格的依赖关系。先构建哪个、后构建哪个,必须按拓扑顺序来。而构建号的意义就是给每一次构建打一个唯一的时间戳标识,方便追溯是哪一次构建、哪个依赖版本、哪次提交引入了问题。

算法题的软件构建是抽象模型,真实的软件构建系统要复杂得多:要考虑不同分支的版本差异、缓存机制、增量编译、跨平台差异,但最底层的依赖解析逻辑依然是拓扑排序。你刷的每一道模板题,都可能在某个具体产品的构建流水线里找到影子。这也是我在训练营反复强调“不要只满足于AC”的原因。把题面映射到工程场景,知识才真正变成生产力。

5.2 最短路径在导航与路由中的应用

Dijkstra更不用说。导航软件里的路线规划、地图服务的驾车路线、物流调度系统的路径优化,甚至OSPF路由协议,都基于最短路径算法。你可以想象一个快递员要从配送中心出发去10个驿站送货,求最短路径。直接跑Dijkstra能算出单点到各点的最短距离,而如果要求“访问所有点并回到起点”的最短回路,那就升级成了TSP(旅行商问题),难度完全不同。

实际工程里的地图导航比算法题更复杂,因为要处理实时路况(边的权重随时间变化)、多条候选路径、禁行区域,这些都是在基础最短路算法之上做扩展。所以算法题的模板是地基,地基不牢固,上层建筑再花哨也会塌。

我个人在实际使用中一直有个体会:方案选型不要跟着模板走,要跟着数据规模走。同样是Dijkstra,数据量小的时候朴素版本半小时就能写完调通,数据量大的时候老老实实上堆优化版。如果你在交作业或面试时为了秀技术,明明N只有100还硬上优先队列,反倒会让代码显得冗长。知道每种算法在什么条件下发挥最优性能,比会背十种模板更有价值。

最后再分享一个小技巧。做完117题之后,如果你还有余力,可以自己试着改造一下:拓扑排序加一个result.size() < n的判断后,把环上的节点打印出来;再做一版字典序最小的拓扑排序。做完47题之后,可以自己转型成堆优化版,再尝试处理负权边情况(当然要把Dijkstra换成Bellman-Ford)。把模板题吃透的最好方法,就是给自己加戏,用新的约束条件逼着自己理解本质。这样到训练营后期刷复杂题目时,你会发现很多“新题”不过是旧模型的排列组合。卡码网这两道题都不难,但它们是后续一切图论应用题的基石,值得花时间好好打磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询