拓扑排序:从“先修课”说起
想象你是大一新生,看着培养方案里错综复杂的课程依赖关系:
- 学《数据结构》前得先修《C语言》
- 学《算法设计》前得先修《数据结构》和《离散数学》
- 《离散数学》没有先修要求
- 《C语言》也没有先修要求
你面临一个问题:如何排出一个合理的上课顺序,使得每门课的先修课都在它之前上?
这就是拓扑排序要解决的核心问题——将有向无环图(DAG)中所有顶点排成一个线性序列,使得对每条边 u→v,u 在序列中都出现在 v 之前。
一、理论基石
拓扑排序的定义:
对一个有向无环图 G 进行拓扑排序,是将 G 中所有顶点排成一个线性序列,使得图中任意一对顶点 u 和 v,若存在边 u→v,则 u 在线性序列中出现在 v 之前。
重要性质:
- 拓扑排序不是唯一的(可能存在多种合理顺序)
- 图中存在环时,无法完成拓扑排序(想想:鸡生蛋,蛋生鸡,怎么排序?)
- 拓扑排序是图是否有环的判定工具
二、两大经典算法
2.1 Kahn 算法(BFS 思路,更直观)
核心思想:不断寻找入度为 0 的顶点,将其输出并移除。
就像选课:哪门课没有先修课要求,就可以先上;上完后,依赖它的课的“未满足先修课数量”就减 1。
算法步骤:
- 统计所有顶点的入度
- 将所有入度为 0 的顶点加入队列
- 当队列非空时:
- 取出队首顶点 u,加入结果序列
- 遍历 u 的所有邻接点 v,将 v 的入度减 1
- 若 v 的入度变为 0,则将 v 入队
- 如果结果序列长度等于顶点数,则排序成功;否则说明图中有环

详细步骤:
- 初始状态:
- 入度:1:0, 2:1, 3:1, 4:2
- 队列:[1](只有 1 入度为 0)
- 拓扑序列:[]
- 步骤 1:处理顶点 1
- 1 出队 → 序列:[1]
- 1 的邻接点 2、3 入度各 -1
- 新入度:2:0, 3:0, 4:2
- 队列:[2, 3]
- 步骤 2:处理顶点 2
- 2 出队 → 序列:[1, 2]
- 2 的邻接点 4 入度 -1 → 4:1
- 队列:[3]
- 步骤 3:处理顶点 3
- 3 出队 → 序列:[1, 2, 3]
- 3 的邻接点 4 入度 -1 → 4:0
- 队列:[4]
- 步骤 4:处理顶点 4
- 4 出队 → 序列:[1, 2, 3, 4]
- 队列为空,结束
- 结果
- 拓扑序列:[1, 2, 3, 4](也可能是 [1, 3, 2, 4],取决于队列顺序)
C++ 实现:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
vector<int> topologicalSortKahn(int n, vector<vector<int>>& edges) {
// 建图 + 统计入度
vector<vector<int>> adj(n);
vector<int> inDegree(n, 0);
for (const auto& edge : edges) {
int u = edge[0], v = edge[1]; // 边 u -> v
adj[u].push_back(v);
inDegree[v]++;
}
// 将所有入度为0的顶点入队
queue<int> q;
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
q.push(i);
}
}
vector<int> result;
while (!q.empty()) {
int u = q.front();
q.pop();
result.push_back(u);
// 遍历所有邻接点
for (int v : adj[u]) {
inDegree[v]--;
if (inDegree[v] == 0) {
q.push(v);
}
}
}
// 如果结果数量不等于顶点数,说明有环
if (result.size() != n) {
return {}; // 返回空数组表示存在环
}
return result;
}
复杂度分析:O(V + E),每个顶点和边各处理一次。
2.2 DFS 算法(递归思路,更巧妙)
核心思想:对图进行深度优先遍历,顶点在“完成探索”时加入结果,最后逆序。
一个顶点的“完成”意味着它的所有后继都已被访问。那么后完成的顶点应该排在前面——这正是“逆后序”就是拓扑序的原因。
算法步骤:
- 对每个未访问的顶点执行 DFS
- 在 DFS 中,先递归访问所有邻接点
- 递归返回后,将当前顶点加入结果栈
- 最后将栈中元素依次弹出,得到拓扑序列
- 如果在 DFS 过程中遇到“正在访问”的顶点,说明存在环

详细步骤- DFS (1) → 标记 1 = 访问中 → 递归 2
- DFS (2) → 标记 2 = 访问中 → 递归 4
- DFS (4) → 无邻接 → 标记 4 = 已访问 → 栈:[4]
- 回溯 2 → 标记 2 = 已访问 → 栈:[4, 2]
- 回溯 1 → 递归 3
- DFS (3) → 递归 4(已访问)→ 标记 3 = 已访问 → 栈:[4, 2, 3]
- 回溯 1 → 标记 1 = 已访问 → 栈:[4, 2, 3, 1]
- 栈逆序:[1, 3, 2, 4](拓扑序)
C++ 实现:
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
bool dfs(int u, vector<vector<int>>& adj, vector<int>& visited, stack<int>& st) {
visited[u] = 1; // 标记为正在访问
for (int v : adj[u]) {
if (visited[v] == 1) {
return false; // 发现后向边,存在环
}
if (visited[v] == 0) {
if (!dfs(v, adj, visited, st)) {
return false;
}
}
}
visited[u] = 2; // 标记为已完成
st.push(u); // 完成时入栈
return true;
}
vector<int> topologicalSortDFS(int n, vector<vector<int>>& edges) {
vector<vector<int>> adj(n);
for (const auto& edge : edges) {
adj[edge[0]].push_back(edge[1]);
}
vector<int> visited(n, 0); // 0=未访问, 1=访问中, 2=已完成
stack<int> st;
for (int i = 0; i < n; i++) {
if (visited[i] == 0) {
if (!dfs(i, adj, visited, st)) {
return {}; // 存在环
}
}
}
vector<int> result;
while (!st.empty()) {
result.push_back(st.top());
st.pop();
}
return result;
}
三、两种算法对比
| 特性 | Kahn (BFS) | DFS |
|---|---|---|
| 数据结构 | 队列 | 栈(隐式递归) |
| 直观程度 | 很直观(不断消去无依赖的节点) | 稍抽象(后序遍历的逆) |
| 环检测 | 最后判断结果长度 | 递归中直接检测后向边 |
| 字典序最小 | 改用优先队列即可实现 | 难以实现字典序 |
| 应用场景 | 任务调度、课程安排 | 依赖分析、编译顺序 |
四、常见应用场景
- 课程安排系统:如开篇例子,确定上课顺序
- 构建系统:Make、Maven 等工具决定编译顺序
- 包管理器:解析软件包依赖关系
- 任务调度:有依赖关系的任务执行顺序
- 死锁检测:检测进程资源分配图中的环
五、易错点与调试技巧
- 混淆入度计算出度:Kahn 算法使用的是入度
- 图不连通:两种算法都要确保遍历所有顶点
- 结果长度不等于顶点数:务必检查,这是环的明确信号
- DFS 三种状态要分清:未访问(0)、访问中(1)、已完成(2),尤其注意“访问中”碰到“访问中”才是环
六、完整测试示例
int main() {
// 0 -> 1 -> 3
// 0 -> 2 -> 3
int n = 4;
vector<vector<int>> edges = {{0,1}, {0,2}, {1,3}, {2,3}};
// 测试 Kahn 算法
vector<int> result1 = topologicalSortKahn(n, edges);
cout << "Kahn 算法结果: ";
for (int v : result1) cout << v << " "; // 可能的输出: 0 1 2 3
cout << endl;
// 测试 DFS 算法
vector<int> result2 = topologicalSortDFS(n, edges);
cout << "DFS 算法结果: ";
for (int v : result2) cout << v << " "; // 可能的输出: 0 2 1 3
cout << endl;
// 测试有环的情况
vector<vector<int>> cyclicEdges = {{0,1}, {1,2}, {2,0}};
vector<int> result3 = topologicalSortKahn(3, cyclicEdges);
if (result3.empty()) {
cout << "检测到环!无法进行拓扑排序。" << endl;
}
return 0;
}
七、总结
拓扑排序的精髓可以概括为:
- 对象:有向无环图(DAG)
- 目标:找到一种不违反依赖关系的线性顺序
- 方法一:贪心地移除入度为 0 的节点(Kahn)
- 方法二:DFS 后序遍历的逆序(递归)
- 复杂度:O(V + E),线性时间
掌握拓扑排序,你就掌握了处理“依赖关系”的利器。无论是刷题还是做项目,这种“先来后到”的智慧都会频繁派上用场。
记住那个选课的比喻——拓扑排序就是在告诉你:先做什么,后做什么,顺序不能乱。
拓扑排序的题型识别信号
当题目中出现以下关键词或模式时,就应该立刻联想到拓扑排序:
信号一:显式的“依赖关系”或“先后顺序”
- “A 必须在 B 之前完成”
- “学习课程 A 之前需要先学习课程 B”
- “任务之间有前置依赖”
- 题目直接给了
prerequisites、dependencies这样的参数名
经典题目:
- LeetCode 207. 课程表(判断能否完成所有课程)
- LeetCode 210. 课程表 II(输出一种上课顺序)
- LeetCode 1136. 平行课程(同时上多门课,问最少学期数)
信号二:“排序”+“有向关系”
- 给出一组偏序关系(如 A > B),要求给出一个符合所有关系的全序排列
- 字符串排序、数字排序中隐藏着偏序关系
经典题目:
- LeetCode 269. 火星词典(根据已排序的单词推导字母顺序)
- LeetCode 444. 序列重建(判断序列是否是唯一的拓扑序)
信号三:图中找环/判断可行性
- 判断一个有向图是否包含环
- 判断一系列依赖条件是否矛盾
经典题目:
- LeetCode 802. 找到最终的安全状态(反向图+拓扑排序找不在环上的节点)
信号四:层级/深度/并行度问题
- “最多可以同时做多少件事”
- “最少需要多少步/多少轮完成”
- 拓扑排序天然有“层”的概念
经典题目:
- LeetCode 310. 最小高度树(多源 BFS 拓扑剥洋葱)
- LeetCode 1203. 项目管理(双层拓扑排序)
题型分类与解题模板
类型一:可行性判断(判环)
题型特征:问“能否完成”,返回 true/false
解题思路:跑一遍拓扑排序,看结果长度是否等于节点数
bool canFinish(int n, vector<vector<int>>& prerequisites) {
// 建图 + 统计入度 + Kahn算法
// return result.size() == n;
}
代表题目:LC 207 课程表
类型二:求任一拓扑序
题型特征:问“给出一种可能的顺序”
解题思路:标准拓扑排序,返回结果数组
vector<int> findOrder(int n, vector<vector<int>>& prerequisites) {
// 标准Kahn算法,返回result
}
代表题目:LC 210 课程表 II
类型三:求字典序最小的拓扑序
题型特征:有多种可能时,要求字典序最小的那种
解题思路:将 Kahn 算法中的普通队列换成小顶堆(优先队列)
priority_queue<int, vector<int>, greater<int>> pq; // 小顶堆
// 每次取编号最小的入度为0的节点
代表题目:某些课程表的变形题,要求输出字典序最小的上课顺序
类型四:求拓扑序中的层级数(并行度/最小轮数)
题型特征:“每学期可以同时上多门课,最少需要几学期?”
本质:求拓扑序的最大深度/层数
解题思路:BFS 时记录层数
int minimumSemesters(int n, vector<vector<int>>& relations) {
// 标准建图+入度统计
queue<int> q;
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) q.push(i);
}
int semesters = 0;
int taken = 0;
while (!q.empty()) {
int size = q.size(); // 当前层的大小
for (int i = 0; i < size; i++) {
int course = q.front(); q.pop();
taken++;
for (int next : adj[course]) {
if (--inDegree[next] == 0) q.push(next);
}
}
semesters++; // 每处理完一层,学期+1
}
return taken == n ? semesters : -1;
}
代表题目:LC 1136 平行课程
类型五:从有序序列反推拓扑关系
题型特征:给你一个已排序的结果(如排好序的单词列表),反推字母之间的顺序关系
解题思路:
- 比较相邻元素,找出第一对不同的字符 → 建立偏序边
- 建图后跑拓扑排序
string alienOrder(vector<string>& words) {
// 1. 从相邻单词中提取边
for (int i = 0; i < words.size() - 1; i++) {
string w1 = words[i], w2 = words[i+1];
int len = min(w1.size(), w2.size());
for (int j = 0; j < len; j++) {
if (w1[j] != w2[j]) {
adj[w1[j]-'a'].insert(w2[j]-'a'); // 建立边
break;
}
}
}
// 2. 拓扑排序
}
代表题目:LC 269 火星词典(会员题,但很经典)
类型六:反向图 + 拓扑排序(找安全节点/不在环上的节点)
题型特征:找“最终会到达终点/不会陷入循环”的节点
解题思路:
- 建立反向图
- 从终点(出度为0的节点)开始反向拓扑排序
- 能被访问到的就是安全节点
vector<int> eventualSafeNodes(vector<vector<int>>& graph) {
int n = graph.size();
// 建立反向图
vector<vector<int>> reverseAdj(n);
vector<int> outDegree(n, 0);
for (int i = 0; i < n; i++) {
for (int v : graph[i]) {
reverseAdj[v].push_back(i); // 反向边
}
outDegree[i] = graph[i].size();
}
// 从出度为0的节点开始拓扑排序
queue<int> q;
for (int i = 0; i < n; i++) {
if (outDegree[i] == 0) q.push(i);
}
// ... Kahn算法的反向版本
}
代表题目:LC 802 找到最终的安全状态
类型七:拓扑排序的唯一性判断
题型特征:问“拓扑序是否唯一”
解题思路:在 Kahn 算法过程中,如果队列中同时存在超过1个元素,说明此时有多个选择,拓扑序不唯一
bool isUnique(int n, vector<vector<int>>& edges) {
// 标准Kahn算法
while (!q.empty()) {
if (q.size() > 1) return false; // 不唯一!
// ...
}
return true;
}
代表题目:LC 444 序列重建
刷题建议路线
- 入门:LC 207 课程表(判环)
- 巩固:LC 210 课程表 II(输出拓扑序)
- 进阶:LC 310 最小高度树(拓扑剥洋葱,无向图变种)
- 应用:LC 269 火星词典(从序列反推关系)
- 综合:LC 1203 项目管理(双层拓扑)

701

被折叠的 条评论
为什么被折叠?



