
在现实生活中,我们经常会遇到这样的场景:
这些场景的共同特点是 “存在依赖关系”—— 某些事件必须在其他事件完成后才能发生。而在图论中,这种 “有依赖关系的事件” 可以用 “有向无环图(DAG)” 来建模,解决这类 “先后次序” 问题的核心算法,就是拓扑排序(Topological Sorting)。 拓扑排序是图论中的基础算法,也是面试和算法竞赛的高频考点。它不仅能解决 “任务调度”“课程安排” 等经典问题,还能用于判断图中是否存在环(比如检测循环依赖)。本文将从拓扑排序的基本概念出发,用通俗的语言拆解算法原理,结合大量图解,再通过一些经典例题巩固实战能力。无论你是算法初学者还是有一定基础的开发者,相信都能从这篇文章中彻底吃透拓扑排序。下面就让我们正式开始吧!
在正式讲解算法之前,我们必须先理清几个关键概念 —— 只有明确了 “基础定义”,后续的算法学习才能事半功倍。
拓扑排序的前提是 “图中没有环”,因此我们首先要明确 “有向无环图” 的定义:
<u, v> 表示从顶点 u 到顶点 v 的单向关系(比如 “学完 u 才能学 v”);
举个例子:课程学习图(顶点表示课程,边表示 “先修关系”)就是典型的 DAG;而如果出现 “学 A 需要先学 B,学 B 需要先学 C,学 C 需要先学 A” 的情况,就形成了环,这类图无法进行拓扑排序。
在实际应用中,DAG 图常被用来表示 “活动之间的依赖关系”,这类图被称为AOV 网(Activity On Vertex Network):

<u, v> 表示 “活动 u 必须先于活动 v 进行”(比如 “学完 u 才能学 v”“完成 u 才能开始 v”)。AOV 网的核心要求是 “无环”—— 如果存在环,就意味着存在 “循环依赖”,导致所有依赖于环的活动都无法进行(比如 “学 A 需要先学 B,学 B 需要先学 A”,两者都无法开始)。因此,AOV 网的合法性检测(是否有环)和合法次序安排(拓扑排序)是其核心问题。
拓扑排序是对 DAG 图的顶点进行排序的过程,排序后的顶点序列需满足:
<u, v>,顶点 u 在序列中一定位于顶点 v 之前。简单来说,拓扑排序的结果是一个 “满足所有依赖关系的顶点序列”。
举个例子:对于课程学习图(顶点:1 - 高等数学,2 - 线性代数,3 - 数据结构,4 - 算法),边为 <1,3>、<2,3>、<3,4>,则以下序列都是合法的拓扑排序:
<3,4> 要求 3 在 4 之前)。一个 DAG 图的拓扑排序可能有多个,但如果图中存在环,则不存在拓扑排序。
拓扑排序的核心是 “处理依赖关系”,因此其应用场景非常广泛:
了解了这些基本概念后,我们就可以正式进入算法的学习了。
拓扑排序的实现方法有两种:Kahn 算法(基于队列的入度法) 和DFS 深度优先搜索法。其中,Kahn 算法逻辑直观、易于理解,且能同时检测图中是否有环,是实际应用中最常用的方法。本文将重点讲解 Kahn 算法,DFS 方法将在后续作为拓展内容介绍。
Kahn 算法的核心是 “基于顶点的入度(In-degree)进行排序”。入度是指指向该顶点的边的条数(比如课程 3 的入度为 2,因为有两条边 <1,3> 和 <2,3> 指向它,表示有两个先修课程)。
算法的基本思路的是:
u,将其加入拓扑排序序列;u 的所有邻接顶点 v(即依赖于 u 的活动),删除边 <u, v>(相当于完成了 u,解除了对 v 的一个依赖);v 的入度减为 0(所有依赖都已完成),将 v 加入队列;举个通俗的例子:把每个顶点看作一个 “任务”,入度为 0 的任务是 “可以立即开始的任务”。我们每次选一个可以立即开始的任务完成,然后解除它对后续任务的依赖;如果某个后续任务的所有依赖都被解除(入度为 0),就可以开始执行它。直到所有任务都完成(排序成功)或没有可执行的任务(存在环,排序失败)。
为了让大家更直观地理解,我们用一个具体的 DAG 图来演示 Kahn 算法的执行过程:
<1,3>、<1,4>、<2,4>、<3,5>、<4,5>;in = [0, 0, 0, 1, 2, 2](索引 0 无用,顶点 1-5 对应索引 1-5);[1, 2];[]。[1];[2, 3];[2, 3]。[1, 2];[3, 4];[3, 4]。[1, 2, 3];[4]。[1, 2, 3, 4];[5];[5]。[1, 2, 3, 4, 5];[1, 2, 3, 4, 5](也可能是其他合法序列,比如 [2, 1, 3, 4, 5],取决于队列的取出顺序);Kahn 算法的实现需要用到 “邻接表” 存储图(高效遍历邻接顶点)和 “入度数组” 存储每个顶点的入度。以下是完整的 C++ 代码实现:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 1e5 + 10; // 顶点数上限
int n, m; // n:顶点数,m:边数
vector<int> edges[N]; // 邻接表存储图:edges[u] 存储u的所有邻接顶点v
int in_degree[N]; // 入度数组:in_degree[v] 表示v的入度
vector<int> topo_order; // 存储拓扑排序结果
// Kahn算法实现拓扑排序
bool kahn() {
// 步骤1:初始化队列,将所有入度为0的顶点加入
queue<int> q;
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
q.push(i);
}
}
// 步骤2:迭代处理队列
while (!q.empty()) {
int u = q.front(); // 取出队头顶点u
q.pop();
topo_order.push_back(u); // 加入拓扑序列
// 遍历u的所有邻接顶点v,解除依赖
for (int v : edges[u]) {
in_degree[v]--; // v的入度减1
if (in_degree[v] == 0) { // 若v的入度为0,加入队列
q.push(v);
}
}
}
// 步骤3:判断是否存在环
return topo_order.size() == n;
}
int main() {
cin >> n >> m;
// 初始化入度数组为0
memset(in_degree, 0, sizeof in_degree);
// 读入m条边(u→v)
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
edges[u].push_back(v); // 邻接表添加边
in_degree[v]++; // v的入度加1
}
// 执行拓扑排序
bool success = kahn();
if (success) {
// 输出拓扑排序序列
cout << "拓扑排序成功,序列为:";
for (int i = 0; i < topo_order.size(); i++) {
cout << topo_order[i] << " ";
}
cout << endl;
} else {
// 存在环,输出提示
cout << "图中存在环,无法进行拓扑排序!" << endl;
}
return 0;
}edges[N]:邻接表,存储图的边,edges[u] 是一个 vector,包含所有从 u 出发的边的终点 v;in_degree[N]:入度数组,存储每个顶点的入度;topo_order:vector,存储拓扑排序的结果序列;queue<int>:用于存储入度为 0 的顶点,实现 BFS 式的迭代处理。除了 Kahn 算法,拓扑排序还可以通过 DFS 深度优先搜索实现。其核心思想是:
以下是 DFS 实现的完整代码:
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
const int N = 1e5 + 10;
int n, m;
vector<int> edges[N]; // 邻接表存储图
bool visited[N]; // 标记顶点是否已访问
bool in_stack[N]; // 标记顶点是否在当前DFS栈中(用于检测环)
vector<int> topo_order; // 存储拓扑序列(逆序)
bool has_cycle = false; // 标记图中是否存在环
// DFS函数:u为当前顶点
void dfs(int u) {
visited[u] = true;
in_stack[u] = true; // 将u加入当前DFS栈
// 遍历u的所有邻接顶点v
for (int v : edges[u]) {
if (!visited[v]) {
dfs(v);
if (has_cycle) return; // 若已发现环,直接返回
} else if (in_stack[v]) {
// 发现回边,存在环
has_cycle = true;
return;
}
}
// u的所有邻接顶点都已处理完毕,加入拓扑序列
in_stack[u] = false; // 从当前DFS栈中移除
topo_order.push_back(u);
}
// 拓扑排序的DFS实现
void topo_sort_dfs() {
// 初始化
memset(visited, false, sizeof visited);
memset(in_stack, false, sizeof in_stack);
has_cycle = false;
topo_order.clear();
// 对每个未访问的顶点执行DFS
for (int i = 1; i <= n; i++) {
if (!visited[i] && !has_cycle) {
dfs(i);
}
}
// 若不存在环,反转拓扑序列(因为DFS是逆序加入的)
if (!has_cycle) {
reverse(topo_order.begin(), topo_order.end());
}
}
int main() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
edges[u].push_back(v);
}
// 执行DFS版拓扑排序
topo_sort_dfs();
if (has_cycle) {
cout << "图中存在环,无法进行拓扑排序!" << endl;
} else {
cout << "拓扑排序成功,序列为:";
for (int i = 0; i < topo_order.size(); i++) {
cout << topo_order[i] << " ";
}
cout << endl;
}
return 0;
}in_stack 数组用于标记当前 DFS 路径上的顶点。如果访问到一个已在当前路径上的顶点(in_stack[v] = true),说明存在环;对比维度 | Kahn 算法(基于队列的入度法) | DFS 算法(基于递归的逆序法) |
|---|---|---|
核心思想 | 基于入度,迭代处理入度为 0 的顶点 | 基于递归,逆序记录处理完所有依赖的顶点 |
环检测 | 自然支持(序列长度是否等于顶点数) | 需要额外维护 in_stack 数组检测回边 |
实现难度 | 低(逻辑直观,迭代实现) | 中(需理解递归和逆序序列) |
适用场景 | 大规模图、迭代编程场景 | 小规模图、递归编程场景 |
空间复杂度 | O (n + m)(邻接表 + 队列 + 入度数组) | O (n + m)(邻接表 + 递归栈 + visited 数组) |
栈溢出风险 | 无(迭代实现) | 有(递归深度过大时) |
实际应用中,Kahn 算法更为常用,尤其是在处理大规模图或需要避免栈溢出的场景下。本文后续的例题将主要基于 Kahn 算法实现。
理论学习之后,必须通过实战巩固。下面为大家精选 5 道洛谷上的经典例题,涵盖模板题、环检测、动态规划结合等场景,帮助大家灵活运用拓扑排序。
题目链接:https://www.luogu.com.cn/problem/B3644

有个人的家族很大,辈分关系很混乱,请你帮整理一下这种关系。给出每个人的后代的信息,输出一个序列,使得每个人的后辈都比那个人后列出。如果有多种不同的序列,输出任意一种即可。
第一行一个整数 N(1 ≤ N ≤ 100),表示家族的人数。接下来 N 行,第 i 行描述第 i 个人的后代编号 a_{i,j},表示 a_{i,j} 是 i 的后代。每行最后是 0 表示描述完毕。
输出一个序列,使得每个人的后辈都比那个人后列出。
5
0
4 5 1 0
1 0
5 3 0
3 02 4 5 3 1这是拓扑排序的模板题,直接套用 Kahn 算法即可:
<<i, a_{i,j}> 表示 i 是 a_{i,j} 的前辈,a_{i,j} 是 i 的后辈(即 i 必须在 a_{i,j} 之前);#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 110;
int n;
vector<int> edges[N];
int in_degree[N];
vector<int> topo_order;
int main() {
cin >> n;
// 初始化入度数组
memset(in_degree, 0, sizeof in_degree);
// 读入每条边(i→j,表示i是j的前辈)
for (int i = 1; i <= n; i++) {
int j;
while (cin >> j, j != 0) { // 读到0结束
edges[i].push_back(j);
in_degree[j]++;
}
}
// Kahn算法
queue<int> q;
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
q.push(i);
}
}
while (!q.empty()) {
int u = q.front();
q.pop();
topo_order.push_back(u);
for (int v : edges[u]) {
in_degree[v]--;
if (in_degree[v] == 0) {
q.push(v);
}
}
}
// 输出拓扑序列
for (int i = 0; i < topo_order.size(); i++) {
cout << topo_order[i] << " ";
}
cout << endl;
return 0;
}输入示例对应的输出为
2 4 5 3 1,符合 “前辈在前,后辈在后” 的要求(比如 2 是 4、5、1 的前辈,4 是 5、3 的前辈,5 是 3 的前辈,3 是 1 的前辈)。
题目链接:https://www.luogu.com.cn/problem/P2712

食品店里有 n 个摄像头,每个摄像头能拍摄到固定位置。一个摄像头能被砸毁的条件是该摄像头所在位置不被其他摄像头监视。请计算是否可以砸掉所有摄像头,如不能则输出还没砸掉的摄像头的数量。
第一行一个整数 n,表示摄像头的个数。接下来 n 行,每行包含摄像头的位置 x,以及该摄像头可以监视到的位置数 m,之后 m 个数 y 是此摄像头可以监视到的位置。(砸了摄像头后,该摄像头监视的位置就无法监视了)
若可以砸掉所有摄像头则输出 "YES",否则输出还没砸掉的摄像头的数量。
5
1 1 2
2 1 1
3 1 7
4 1 1
5 02这道题的核心是环检测—— 摄像头之间的监视关系形成一个有向图,若存在环,则环中的摄像头无法被砸毁(每个摄像头都被环中的其他摄像头监视);若不存在环,则所有摄像头都可以被砸毁。
具体建模:
<x, y> 表示位置 x 的摄像头能监视位置 y(即若要砸毁位置 y 的摄像头,必须先砸毁位置 x 的摄像头,因为 x 监视 y);#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int MAX_POS = 510; // 位置编号最大为500(根据题目隐含条件)
int n;
vector<int> edges[MAX_POS]; // 邻接表:edges[x] 存储x能监视的位置y
int in_degree[MAX_POS]; // 入度数组
bool has_camera[MAX_POS]; // 标记位置x是否有摄像头
int main() {
cin >> n;
// 初始化
memset(in_degree, 0, sizeof in_degree);
memset(has_camera, false, sizeof has_camera);
// 读入每个摄像头的信息
for (int i = 0; i < n; i++) {
int x, m, y;
cin >> x >> m;
has_camera[x] = true; // 标记位置x有摄像头
while (m--) {
cin >> y;
edges[x].push_back(y);
in_degree[y]++;
}
}
// Kahn算法:找出所有能被砸毁的位置(拓扑序列中的位置)
queue<int> q;
for (int x = 0; x <= MAX_POS; x++) {
// 位置x有摄像头,且入度为0(不被其他摄像头监视)
if (has_camera[x] && in_degree[x] == 0) {
q.push(x);
}
}
int destroyed = 0; // 被砸毁的摄像头数量
while (!q.empty()) {
int x = q.front();
q.pop();
destroyed++; // 砸毁位置x的摄像头
// 遍历x能监视的位置y,解除监视(y的入度减1)
for (int y : edges[x]) {
in_degree[y]--;
// 若y有摄像头且入度为0,加入队列
if (has_camera[y] && in_degree[y] == 0) {
q.push(y);
}
}
}
// 总摄像头数 - 被砸毁的数量 = 未被砸毁的数量
int remaining = n - destroyed;
if (remaining == 0) {
cout << "YES" << endl;
} else {
cout << remaining << endl;
}
return 0;
}示例输入中,摄像头位置 1 和 2 形成环(1 监视 2,2 监视 1),无法被砸毁;位置 3 监视 7(无摄像头),入度为 0,可被砸毁;位置 4 监视 1(入度 1,砸毁 1 后入度为 0,可被砸毁);位置 5 无监视对象,入度为 0,可被砸毁。被砸毁的摄像头数为 3,总摄像头数为 5,因此未被砸毁的数量为 2,输出
2,与示例一致。
题目链接:

给你一个食物网,求这个食物网中最大食物链的数量。(“最大食物链” 指生物学意义上的食物链,即最左端是生产者,最右端是消费者)。结果需模 80112002。
第一行两个正整数 n、m,表示生物种类 n 和吃与被吃的关系数 m。接下来 m 行,每行两个正整数 A 和 B,表示被吃的生物 A 和吃 A 的生物 B(即 A→B,A 是 B 的食物)。
一行一个整数,表示最大食物链的数量模 80112002 的结果。
5 7
1 2
1 3
2 3
3 5
2 5
4 5
3 45这道题是拓扑排序结合动态规划的经典问题:
dp[i] 表示以生物 i 为终点的最大食物链的数量;<A, B>(A 是 B 的食物),dp[B] += dp[A](所有以 A 为终点的食物链,都可以延伸到 B);dp[i] = 1(对于生产者,自身是一条长度为 1 的食物链);dp[i]之和。#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 5010;
const int MOD = 80112002;
int n, m;
vector<int> edges[N]; // 邻接表:edges[A] 存储吃A的生物B(A→B)
int in_degree[N]; // 入度数组
int out_degree[N]; // 出度数组
int dp[N]; // dp[i]:以i为终点的最大食物链数量
int main() {
cin >> n >> m;
// 初始化
memset(in_degree, 0, sizeof in_degree);
memset(out_degree, 0, sizeof out_degree);
memset(dp, 0, sizeof dp);
// 读入m条边(A→B,A被B吃)
for (int i = 0; i < m; i++) {
int A, B;
cin >> A >> B;
edges[A].push_back(B);
in_degree[B]++;
out_degree[A]++;
}
// Kahn算法:拓扑排序 + 动态规划
queue<int> q;
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
q.push(i);
dp[i] = 1; // 生产者的食物链数量为1
}
}
while (!q.empty()) {
int A = q.front();
q.pop();
// 遍历A的所有捕食者B
for (int B : edges[A]) {
dp[B] = (dp[B] + dp[A]) % MOD; // 状态转移
in_degree[B]--;
if (in_degree[B] == 0) {
q.push(B);
}
}
}
// 计算所有消费者(出度为0)的dp之和
int result = 0;
for (int i = 1; i <= n; i++) {
if (out_degree[i] == 0) {
result = (result + dp[i]) % MOD;
}
}
cout << result << endl;
return 0;
}示例输入中,生产者是 1(入度为 0),消费者是 5(出度为 0)。以 5 为终点的食物链有:1→2→3→5、1→2→5、1→3→5、1→3→4→5、1→2→3→4→5,共 5 条,因此输出
5,与示例一致。
题目链接:https://www.luogu.com.cn/problem/P1113

John 的农场在挤奶前有很多杂务要完成,有些杂务必须在其他杂务完成后才能进行。请计算完成所有杂务所需的最短时间(互相无关的杂务可以同时进行)。
第一行一个整数 n(3 ≤ n ≤ 10000),表示杂务的数目。接下来 n 行,每行包含:工作序号(1~n)、完成时间 len、若干必须完成的准备工作(以 0 结束)。
一个整数,表示完成所有杂务所需的最短时间。
7
1 5 0
2 2 1 0
3 3 2 0
4 6 1 0
5 1 2 4 0
6 8 2 4 0
7 4 3 5 6 023这道题是拓扑排序结合最长路径的问题(因为杂务需要按依赖关系进行,且无关杂务可同时进行,最短总时间是 “最长路径” 的长度):
<u, v>(u 是 v 的准备工作,u 必须先完成);dp[i] 表示完成杂务 i 所需的最短时间(即从起点到 i 的最长路径长度);<u, v>,dp[v] = max(dp[v], dp[u] + len[v])(完成 v 的时间是完成 u 的时间加上 v 自身的时间,取最大值因为 v 可能有多个准备工作,需全部完成才能开始);dp[i] = len[i](若杂务 i 无准备工作,完成时间就是自身的时间);dp[i]中的最大值(因为最后完成的杂务决定了总时间)。#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int N = 10010;
int n;
vector<int> edges[N]; // 邻接表:edges[u] 存储依赖u的杂务v(u→v)
int in_degree[N]; // 入度数组
int len[N]; // len[i]:杂务i的完成时间
int dp[N]; // dp[i]:完成杂务i所需的最短时间
int main() {
cin >> n;
// 初始化
memset(in_degree, 0, sizeof in_degree);
memset(len, 0, sizeof len);
memset(dp, 0, sizeof dp);
// 读入每个杂务的信息
for (int i = 1; i <= n; i++) {
int id, l, prep;
cin >> id >> l;
len[id] = l;
dp[id] = l; // 初始化:完成时间至少是自身的时间
while (cin >> prep, prep != 0) {
edges[prep].push_back(id); // 准备工作prep→杂务id
in_degree[id]++;
}
}
// Kahn算法:拓扑排序 + 动态规划(最长路径)
queue<int> q;
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
q.push(i);
}
}
int max_time = 0; // 完成所有杂务的最短时间
while (!q.empty()) {
int u = q.front();
q.pop();
// 更新max_time
if (dp[u] > max_time) {
max_time = dp[u];
}
// 遍历所有依赖u的杂务v
for (int v : edges[u]) {
// 状态转移:完成v的时间 = max(当前时间, 完成u的时间 + v的时间)
if (dp[v] < dp[u] + len[v]) {
dp[v] = dp[u] + len[v];
}
in_degree[v]--;
if (in_degree[v] == 0) {
q.push(v);
}
}
}
cout << max_time << endl;
return 0;
}示例输入中,杂务 7 的依赖最多(3、5、6),其完成时间是
dp[3] + len[7] = (dp[2] + 3) + 4 = (dp[1] + 2 + 3) + 4 = (5 + 2 + 3) + 4 = 14?不,实际计算中,杂务 5 的完成时间是max(dp[2], dp[4]) + 1 = max(7, 11) + 1 = 12,杂务 6 的完成时间是max(dp[2], dp[4]) + 8 = max(7, 11) + 8 = 19,杂务 7 的完成时间是max(dp[3], dp[5], dp[6]) + 4 = max(9, 12, 19) + 4 = 23,因此总时间为 23,与示例一致。
在实际应用中,拓扑排序容易出现一些细节错误,以下是常见问题的总结:
<u, v>,必须将 v 的入度加 1,而不是 u 的入度。in_stack数组检测回边,仅使用visited数组无法区分 “已处理完的顶点” 和 “当前路径上的顶点”,会导致环检测错误。如果题目要求输出 “字典序最小” 的拓扑序列(比如顶点编号从小到大),只需将 Kahn 算法中的普通队列替换为优先队列(小根堆),每次取出入度为 0 的顶点中编号最小的。
示例代码(基于例题 1 修改):
#include <iostream>
#include <vector>
#include <priority_queue> // 优先队列(小根堆)
using namespace std;
const int N = 110;
int n;
vector<int> edges[N];
int in_degree[N];
vector<int> topo_order;
int main() {
cin >> n;
memset(in_degree, 0, sizeof in_degree);
for (int i = 1; i <= n; i++) {
int j;
while (cin >> j, j != 0) {
edges[i].push_back(j);
in_degree[j]++;
}
}
// 小根堆:每次取出入度为0的顶点中编号最小的
priority_queue<int, vector<int>, greater<int>> pq;
for (int i = 1; i <= n; i++) {
if (in_degree[i] == 0) {
pq.push(i);
}
}
while (!pq.empty()) {
int u = pq.top();
pq.pop();
topo_order.push_back(u);
for (int v : edges[u]) {
in_degree[v]--;
if (in_degree[v] == 0) {
pq.push(v);
}
}
}
for (int x : topo_order) {
cout << x << " ";
}
cout << endl;
return 0;
}拓扑排序是图论中的基础算法,也是解决 “依赖关系” 问题的万能钥匙。希望本文能帮助你彻底掌握这一知识点,在后续的学习和实践中灵活运用。如果有任何疑问或建议,欢迎在评论区留言讨论!