一、什么是拓扑排序
拓扑排序 是对有向无环图(DAG, Directed Acyclic Graph) 中的节点进行线性排序的一种算法。排序结果满足:对于图中的每一条有向边 u → v u \to v u→v,节点 u u u 在序列中都出现在节点 v v v 之前。
直观理解
以课程先修关系为例:
- 课程 u u u 是课程 v v v 的先修课 ⇒ \Rightarrow ⇒ 图中存在一条边 u → v u \to v u→v
- 拓扑排序就是找到一个合理的上课顺序,使得每门课程的所有先修课都在它之前被修完
存在性判定
拓扑排序并非对所有有向图都存在:
- 有向无环图(DAG):一定存在至少一个拓扑排序
- 存在环的有向图 :不存在拓扑排序(如样例2中的 1 → 2 → 3 → 1 1 \to 2 \to 3 \to 1 1→2→3→1 形成循环依赖,无法消除)
因此,拓扑排序算法通常也兼具判环的功能。
二、核心思想:Kahn 算法
Kahn 算法(基于入度的 BFS),其核心思想非常直观:
2.1 关键概念:入度(In-degree)
- 入度:指向某个节点的边的数量,表示有多少门课程是该课程的先修课
- 出度:从某个节点指出的边的数量
在课程先修问题中:
in[v] == 0表示课程 v v v 没有先修课(或所有先修课都已修完),可以立即学习- 学完课程 u u u 后,所有以 u u u 为先修课的课程 v v v 的入度减 1 1 1(相当于"解锁"了后续课程)
2.2 算法流程
1. 初始化:将所有入度为 0 的节点加入队列
2. 循环:当队列非空时
a. 取出一个节点 x,加入答案序列
b. 遍历 x 的所有出边 x → y
c. 将 y 的入度减 1
d. 如果 y 的入度变为 0,将 y 加入队列
3. 检查:如果答案序列长度等于 n,则成功;否则图中存在环
2.3 如何能得到字典序最小?
使用优先队列(小根堆) (priority_queue<ll, vector<ll>, greater<ll>>)而非普通队列。
原因 :当多个节点的入度同时变为 0 0 0 时,我们总是优先选择编号最小的那个节点。由于堆的每次弹出都是当前可选节点中的最小值,这保证了在每一步局部最优的选择下,最终得到全局字典序最小的拓扑序列。
三、代码详解
以下基于模板,逐段解析拓扑排序的完整实现。
3.1 图的存储与入度数组
cpp
vector<ll> G[maxn]; // G[u] 存储从 u 出发的所有边,即 u 的所有后继课程
ll in[maxn]; // in[v] 存储节点 v 的入度
ll n, m; // n: 课程数, m: 先修关系数
| 变量 | 含义 |
|---|---|
G[u] |
邻接表,存储从课程 u u u 直接指向的所有后续课程 |
in[v] |
课程 v v v 当前的入度(未完成的先修课数量) |
3.2 建图
cpp
void sol()
{
cin >> n >> m;
rep(i, 1, m)
{
ll u, v;
cin >> u >> v; // u 是 v 的先修课
G[u].push_back(v); // 建立有向边 u → v
in[v]++; // v 的入度加 1
}
topsort();
}
建图过程示例(样例1):
输入:
5 4
1 3 → G[1] = {3}, in[3] = 1
2 3 → G[2] = {3}, in[3] = 2
3 4 → G[3] = {4}, in[4] = 1
2 5 → G[2] = {3,5}, in[5] = 1
最终入度:
in[1] = 0, in[2] = 0, in[3] = 2, in[4] = 1, in[5] = 1
3.3 拓扑排序
cpp
void topsort()
{
// 小根堆:保证每次取出的都是当前可修课程中编号最小的
priority_queue<ll, vector<ll>, greater<ll>> q;
// 第一步:将所有入度为 0 的课程加入堆
rep(i, 1, n) if (in[i] == 0) q.push(i);
vector<ll> ans; // 存储拓扑排序结果
while (q.size())
{
ll x = q.top(); // 取出当前编号最小的可修课程
q.pop();
ans.push_back(x); // 加入答案序列
// 遍历所有以 x 为先修课的课程 y
for (auto y : G[x])
{
in[y]--; // 修完 x,相当于 y 的一个先修条件被满足
if (in[y] == 0) // y 的所有先修课都已修完
q.push(y); // y 变为可修,加入候选堆
}
}
// 判环:如果成功修完 n 门课程,则无环;否则存在环
if (ans.size() == n)
{
for (auto x : ans) cout << x << ' ';
cout << endl;
}
else
cout << "Impossible" << endl;
}
执行流程可视化(样例1):
初始入度: [0, 0, 2, 1, 1] (对应课程 1~5)
第1轮: 堆中有 {1, 2},弹出最小值 1
修完 1,解锁 G[1] = {3}
in[3] 从 2 减到 1(仍 > 0,不入堆)
ans = [1]
第2轮: 堆中有 {2},弹出 2
修完 2,解锁 G[2] = {3, 5}
in[3] 从 1 减到 0,入堆
in[5] 从 1 减到 0,入堆
ans = [1, 2]
第3轮: 堆中有 {3, 5},弹出最小值 3
修完 3,解锁 G[3] = {4}
in[4] 从 1 减到 0,入堆
ans = [1, 2, 3]
第4轮: 堆中有 {4, 5},弹出最小值 4
修完 4,G[4] 为空
ans = [1, 2, 3, 4]
第5轮: 堆中有 {5},弹出 5
ans = [1, 2, 3, 4, 5]
结果: 1 2 3 4 5 ✓
样例2的执行(含环检测):
4 4
1 2 → in[2] = 1
2 3 → in[3] = 1
3 1 → in[1] = 1
3 4 → in[4] = 1
初始: 所有节点入度均 ≥ 1,堆为空!
while 循环不执行,ans 为空
ans.size() = 0 ≠ 4,输出 "Impossible"
四、完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
#define rep(i,a,n) for(ll i=a;i<=n;++i)
#define per(i,n,a) for(ll i=n;i>=a;--i)
typedef long long ll;
const int maxn = 2e5 + 10;
const int mod = 1e9 + 7;
vector<ll> G[maxn]; // 邻接表存图
ll in[maxn]; // 入度数组
ll n, m;
void topsort()
{
// 小根堆:维护当前所有入度为0的节点,保证字典序最小
priority_queue<ll, vector<ll>, greater<ll>> q;
// 初始化:将所有无先修课(入度为0)的课程入堆
rep(i, 1, n) if (in[i] == 0) q.push(i);
vector<ll> ans; // 拓扑排序结果
while (q.size())
{
ll x = q.top();
q.pop();
ans.push_back(x);
// 遍历 x 的所有后继,相当于"解锁"后续课程
for (auto y : G[x])
{
in[y]--;
if (in[y] == 0) // 所有先修课已完成
q.push(y);
}
}
// 判环:无法修完全部课程,说明存在循环依赖
if (ans.size() == n)
{
for (auto x : ans) cout << x << ' ';
cout << endl;
}
else
{
cout << "Impossible" << endl;
}
}
void sol()
{
cin >> n >> m;
rep(i, 1, m)
{
ll u, v;
cin >> u >> v;
G[u].push_back(v); // u → v
in[v]++; // v 的入度加 1
}
topsort();
}
int main()
{
sol();
return 0;
}
五、复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 建图 | O ( n + m ) O(n + m) O(n+m) | 初始化数组 + 读入 m m m 条边 |
| 拓扑排序 | O ( n log n + m ) O(n \log n + m) O(nlogn+m) | 每个节点入堆/出堆一次(堆操作 O ( log n ) O(\log n) O(logn)),每条边遍历一次 |
| 空间复杂度 | O ( n + m ) O(n + m) O(n+m) | 邻接表存储 + 入度数组 + 堆 |
如果使用普通队列 (不要求字典序最小),时间复杂度可优化到 O ( n + m ) O(n + m) O(n+m),因为避免了堆的 log n \log n logn 开销。
六、DFS 实现(补充)
除了 Kahn 算法,拓扑排序还可以用 DFS + 栈 实现:
cpp
vector<ll> G[maxn];
bool vis[maxn], instk[maxn]; // vis: 是否访问过, instk: 是否在递归栈中
vector<ll> ans;
bool dfs(ll u)
{
vis[u] = instk[u] = true;
for (auto v : G[u])
{
if (instk[v]) return false; // 发现回边,存在环
if (!vis[v] && !dfs(v)) return false;
}
instk[u] = false;
ans.push_back(u); // 后序遍历加入
return true;
}
bool topsort_dfs()
{
rep(i, 1, n) if (!vis[i]) if (!dfs(i)) return false;
reverse(ans.begin(), ans.end()); // 逆后序即为拓扑序
return true;
}
两种方法对比:
| 特性 | Kahn 算法(BFS) | DFS 算法 |
|---|---|---|
| 核心思想 | 从入度为 0 的节点开始,逐层剥离 | 后序遍历,逆序输出 |
| 判环方式 | 最终检查序列长度是否等于 n n n | 利用递归栈检测回边 |
| 字典序控制 | 用优先队列直接控制 | 需额外排序,较麻烦 |
| 适用场景 | 需要字典序、动态维护入度 | 需要输出逆拓扑序、结合强连通分量 |
七、常见问题与注意事项
7.1 重边与自环
- 重边 (多条相同的 u → v u \to v u→v):需要在建图时一并记录,
in[v]需要增加相应的次数。如果题目保证输入两两不同(如本题),则无需特殊处理。 - 自环 ( u → u u \to u u→u):该节点入度至少为 1,永远无法入度为 0,算法会正确判定为"Impossible"。
7.2 不唯一解的处理
拓扑排序的结果通常不唯一 。例如样例1中,如果不用小根堆而用普通队列,可能得到 2 1 3 4 5 等合法序列。
7.3 与动态规划的关系
拓扑排序是 DAG 上动态规划的前置步骤。很多问题需要先进行拓扑排序,确定 DP 的状态转移顺序,确保计算某个节点时,其所有前驱状态都已经计算完毕。
八、总结
拓扑排序是处理有向图依赖关系的基础算法,广泛应用于课程选修、任务调度、编译顺序、Makefile 依赖解析等场景。