拓扑排序
拓扑排序的原理与实现:DAG 的线性排序,Kahn 算法与 DFS 方法。
一句话算法
拓扑排序就是不断删除入度为 0 的点:谁已经没有前置依赖,谁就可以进入答案序列。
问题模型
给定一张有向图
必须排在 前面; - 或者任务
依赖任务 ; - 或者状态
只能在 之后被处理。
拓扑排序要输出一个点序列,使得每条边
“DAG”
DAG 是有向无环图。只有 DAG 才存在拓扑序;如果有向图中存在环,环上的点互相等待,不可能排出合法顺序。
核心直觉
把有向边看成“前置课程”。
一个点的入度表示还有多少个前置任务没完成。入度为 0 的点没有任何前置依赖,可以立刻安排。安排完这个点以后,它指向的后继点少了一个前置依赖,于是后继点的入度减一。
如果整个过程最后还有点没有被安排,说明剩下的点互相依赖,它们组成了有向环。
算法步骤
Kahn 算法的流程:
- 统计每个点的入度
indeg[v]。 - 把所有入度为
0的点放入队列。 - 当队列非空:
- 取出队首点
u,加入拓扑序; - 枚举所有边
u -> v; - 将
indeg[v]--; - 如果
indeg[v] == 0,把v放入队列。
- 取出队首点
- 如果最终拓扑序长度等于
,说明排序成功。 - 如果拓扑序长度小于
,说明图中存在有向环。
字典序最小拓扑序
如果题目要求输出字典序最小的拓扑序,把普通队列换成小根堆即可。
理由很简单:任何时刻所有入度为 0 的点都没有未完成前驱,它们之间可以任选一个先输出。为了让序列字典序最小,每一步都选编号最小的可选点。
算法证明
关键不变量: 队列中的点,恰好是当前还未输出、且所有前驱都已经输出的点。
-
初始化正确
初始入度为
0的点没有任何前驱,显然可以作为拓扑序开头。 -
删除一个点后不变量仍成立
输出点
u后,相当于从图中删除u和它的所有出边。对于每条边u -> v,v少了一个未完成前驱,所以indeg[v]--。当
indeg[v]变成0,表示v的所有前驱都已经被删除,也就是都已经输出,因此v可以进入队列。 -
输出序列合法
对任意边
, v只有在所有前驱输出后才可能入队,所以一定在 前面输出。 -
环检测正确
如果图中有环,环上每个点至少有一个来自环内的前驱。只要环内点都没被删除,它们的入度就不会变成
0,因此无法全部输出。反过来,如果算法结束时还有点没输出,剩余子图中没有入度为
0的点。从任一点不断沿入边往前走,因为点数有限,必然重复某个点,形成有向环。
所以,Kahn 算法能正确输出 DAG 的拓扑序,并能判断有向环是否存在。
复杂度分析
设点数为
- 普通队列版:每个点入队出队一次,每条边被扫描一次,时间复杂度
。 - 空间复杂度
。 - 若使用小根堆求字典序最小拓扑序,时间复杂度为
。
代码实现
下面模板读入 n m 和 m 条有向边 u v,输出一个拓扑序;如果存在环,输出 Cycle。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <bits/stdc++.h>
using namespace std;
struct TopologicalSort {
int n;
vector<vector<int>> graph;
vector<int> indeg;
explicit TopologicalSort(int n) : n(n), graph(n + 1), indeg(n + 1, 0) {}
void add_edge(int u, int v) {
graph[u].push_back(v);
indeg[v]++;
}
vector<int> kahn() {
queue<int> q;
vector<int> deg = indeg;
vector<int> order;
for (int i = 1; i <= n; i++) {
if (deg[i] == 0) q.push(i);
}
while (!q.empty()) {
int u = q.front();
q.pop();
order.push_back(u);
for (int v : graph[u]) {
deg[v]--;
if (deg[v] == 0) q.push(v);
}
}
return order;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
TopologicalSort solver(n);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
solver.add_edge(u, v);
}
vector<int> order = solver.kahn();
if ((int)order.size() != n) {
cout << "Cycle\n";
return 0;
}
for (int i = 0; i < n; i++) {
if (i) cout << ' ';
cout << order[i];
}
cout << '\n';
return 0;
}
测试用例
输入:
5 5
1 3
2 3
3 4
2 5
5 4
输出:
1 2 3 5 4
解释:
3必须在1,2之后;5必须在2之后;4必须在3,5之后。
输出序列满足所有边的先后限制。
有环样例:
3 3
1 2
2 3
3 1
输出:
Cycle
应用分类详解
拓扑排序的本质是处理“有向依赖关系”。只要题目里出现先后限制、依赖顺序、DAG 上转移,就应该想到拓扑排序。
一、任务依赖与课程安排
典型模式: 若干任务之间有前置依赖,要求给出一种合法执行顺序。
识别信号: “必须先完成 A 才能做 B”“课程先修关系”“工程任务排程”。
核心建模: 任务是点,前置关系 A -> B 是边,拓扑序就是合法执行顺序。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 工程任务排序 | luogu-P1113 | 拓扑序上计算每个任务最早完成时间 |
| 课程安排 | 课程先修类题 | 入度为 0 的课程可以先学 |
| 关系排序 | luogu-P1347 | 每加入一条关系后检查是否唯一或是否成环 |
二、DAG 动态规划
典型模式: 图是 DAG,需要按依赖方向做 DP。
识别信号: “有向无环图”“路径计数”“最长路径”“状态只能从前驱转移”。
核心建模: 先求拓扑序,再按拓扑序处理点,保证计算一个点时它的所有前驱已经算完。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 食物链计数 | luogu-P4017 | 按拓扑序累加路径条数 |
| 最长路 DP | luogu-P1113 | dp[v]=max(dp[v],dp[u]+w[v]) |
| 可达状态转移 | DAG 路径统计类题 | 拓扑序保证转移方向无回头依赖 |
三、唯一拓扑序与环检测
典型模式: 不只要一个拓扑序,还要判断顺序是否唯一,或是否存在矛盾。
识别信号: “排名是否确定”“关系是否矛盾”“能否唯一确定顺序”。
核心建模: Kahn 算法过程中,如果某一时刻可选入度 0 点超过一个,拓扑序不唯一;如果最后没有输出所有点,则有环。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 判断排序关系 | luogu-P1347 | 每轮可选点数量判断唯一性 |
| 检测依赖矛盾 | 依赖关系判定类题 | 输出数量小于 |
四、字典序最小/最大拓扑序
典型模式: 合法拓扑序可能有很多个,题目要求最小字典序或最大字典序。
识别信号: “若有多种方案,输出字典序最小”“编号小的优先”。
核心建模: 把队列换成优先队列。最小字典序用小根堆;某些反向建图题可以通过反向拓扑序处理最大字典序。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 车站分级 | luogu-P1983 | 分层/拓扑关系判断等级 |
| 字典序最小 DAG 排序 | 排序输出类题 | 入度为 0 的候选点中每次取最小 |
经典例题
1. luogu-P1113
任务调度与最早完成时间。先按依赖关系建 DAG,再在拓扑序上做 DP:一个任务的最早开始时间由所有前驱任务的完成时间取最大值。
2. luogu-P1347
判断排序关系。每加入一条大小关系后做拓扑排序:若存在环则矛盾,若每一步只有一个可选点则当前顺序唯一。
3. luogu-P4017
最大食物链计数。把食物链方向建成 DAG,从入度为 0 的点开始按拓扑序转移路径数量。
4. luogu-P1983
车站分级。题目给出的停靠关系可以转成等级依赖,依赖图上的最长层数就是最少等级数。
参考
- Kahn 算法
- DAG 动态规划