← 目录 / 算法文档 · 模块十五 图论基础 / 15.6 拓扑排序

15.6 拓扑排序

不关心"距离"或"权值之和",只关心一堆有先后依赖关系的任务,应该按什么顺序执行才不会违反任何一条依赖。

本页目录
① 什么是拓扑排序

给定一张有向无环图(Directed Acyclic Graph,简称 DAG),把所有顶点排成一个线性顺序,使得对于图中的每一条有向边 u → vu 在排列中都出现在 v前面——这样的排列就叫拓扑排序

最经典的场景是"选课依赖":某些课程要求先修完另一些课程才能选(比如"数据结构"要求先学完"程序设计基础")。把每门课当成一个顶点,"先修关系"画成有向边,拓扑排序算出来的顺序,就是一种不违反任何先修要求的合法选课顺序。同样的模型也能用于任务调度、编译时的模块依赖顺序等场景。

📌
拓扑排序的结果通常不唯一:只要满足"每条边都是前面指向后面",多种不同的排列都可能是合法的拓扑序。本节讲的方法能找出其中一种合法顺序,而不是唯一确定的答案。
② 核心思想:Kahn 算法——不断移除入度为 0 的点

每个顶点的入度是指有多少条边指向它——对应到选课场景,就是"这门课还有几门先修课没有安排"。入度为 0 的顶点,意味着它没有任何未完成的前置依赖,可以立刻安排。Kahn 算法用一个队列反复做这件事:

步骤做什么
① 统计入度遍历所有边,统计每个顶点的入度
② 初始化队列把所有入度为 0 的顶点放入队列
③ 反复处理每次弹出队首顶点 u,加入拓扑序结果;把 u 指向的每个顶点 v 的入度减 1,如果减到 0 就把 v 入队
④ 结束队列为空时算法结束,此时拓扑序结果里的顶点顺序,就是一种合法的拓扑排序
💡
"移除入度为 0 的点"和"把它指向的点入度减 1",为什么合理?入度为 0 的点已经没有任何依赖,可以第一批处理;处理完它之后,它对"它指向的点"的依赖就已经满足了一部分,所以要把那些点的入度减 1——如果某个点的所有依赖都已经处理完(入度减到 0),它也就可以被安排了。这个过程和 14.1 节 BFS 的队列操作在写法上非常相似,只是这里维护的是"入度"而不是"距离"。
③ 图解:选课依赖问题的完整执行过程

假设有 4 门课 A B C DAB 都是 C 的先修课,C 又是 D 的先修课(对应边:A→CB→CC→D):

依赖关系图
A
C
D
B
初始入度:A=0,B=0(都没有先修课),C=2(依赖 A 和 B),D=1(依赖 C)。
Kahn 算法执行过程
队列(处理前)弹出更新入度拓扑序结果
[A, B]AC 的入度 2→1(还不能入队)A
[B]BC 的入度 1→0,C 入队A, B
[C]CD 的入度 1→0,D 入队A, B, C
[D]D(D 没有指向任何点)A, B, C, D
最终拓扑序是 A, B, C, D——检查一下三条边:A→C(A 在 C 前面 ✓),B→C(B 在 C 前面 ✓),C→D(C 在 D 前面 ✓),全部满足。如果一开始队列里 B 排在 A 前面,得到的结果会是 B, A, C, D——同样合法,这正是 ① 提到的"拓扑序通常不唯一"。
④ 完整代码
C++ · Kahn 算法(BFS 实现拓扑排序)
1vector<int> adj[MAXN]; // adj[u]:u 指向的所有点
2int inDeg[MAXN]; // 每个点的入度
3
4vector<int> TopoSort(int n) // 返回拓扑序;若长度小于 n,说明图中有环(见 ⑥)
5{
6 queue<int> q;
7 for (int i = 1; i <= n; i++)
8 if (inDeg[i] == 0) q.push(i); // ★ 入度为 0 的点先入队
9
10 vector<int> order;
11 while (!q.empty())
12 {
13 int u = q.front(); q.pop();
14 order.push_back(u);
15 for (int v : adj[u])
16 {
17 if (--inDeg[v] == 0) q.push(v); // 依赖减少 1,减到 0 就可以安排了
18 }
19 }
20 return order;
21}
💡
和 14.1 节 BFS 几乎是同一套骨架:都是"初始化队列 → 弹出、处理、把满足条件的新节点入队"这套流程。区别在于:BFS 是"距离"一层层扩展,拓扑排序是"入度"一点点减少到 0;BFS 关心的是"最短距离",拓扑排序关心的是"先后顺序"。
⑤ 复杂度分析

统计入度需要遍历所有边,O(E);每个顶点入队、出队各一次,O(V);每条边在"更新入度"时被处理一次,O(E)。总时间复杂度 O(V+E),和 BFS 是同一量级。

⑥ 常见陷阱
图中存在环时,拓扑排序不存在:如果图里有一个环(比如 X→Y→Z→X),环上的每个点入度都至少是 1,永远不会被减到 0,也就永远不会入队——这些点会被"晾在一边",最终 order 的长度会小于 n。判断"图中有没有环",只需要检查 order.size() == n 是否成立:不成立就说明存在环,根本不存在合法的拓扑排序(对应"选课依赖"的场景,就是几门课互相要求对方先修,谁也没法先上)。
把"拓扑排序不唯一"误认为程序写错了:只要每条边都满足"前面指向后面",不同的实现(甚至同一份代码在不同编译器下 queue 的实现细节差异)都可能给出不同但同样合法的顺序。判断对错应该去检查"每条边是否都满足方向要求",而不是去比较是否和某个特定顺序完全一致。
初始化入度数组时忘记清零,或者统计入度时漏掉某些边:入度是通过遍历所有边统计出来的(对每条边 u→vinDeg[v]++),如果建图时漏加了某条边,或者多组数据之间忘记把 inDeg 数组重新清零,统计出来的入度会不准确,进而导致初始队列里该有的点没入队、或者不该有的点提前入队。
🏆
模块小结:15.1~15.6 节把图论基础走了一遍:图的两种存储方式 → 任意两点最短路(Floyd)→ 单源最短路(Dijkstra)→ 最小生成树的两种做法(Kruskal、Prim)→ 有向无环图的执行顺序(拓扑排序)。这几种算法看似解决的问题各不相同,但反复出现同样的几个身影——并查集、优先队列、贪心、BFS 式的队列操作——图论问题很大程度上是在用前面模块学过的这些基础工具,去解决不同形状的具体问题。