一、Kahn 算法到底是什么
Kahn 算法是用于解决 有向无环图(DAG, Directed Acyclic Graph)拓扑排序 的经典算法。
它回答的问题是:
已知一组"先后依赖关系",怎样排出一个合法顺序,使得每个点都出现在它所有前置依赖之后?
比如:
- 课程学习顺序
- 任务调度顺序
- 编译依赖顺序
- 模块初始化顺序
- 工作流 DAG 执行顺序
二、什么是拓扑排序
先理解拓扑排序本身。
假设有图:
A -> B表示 A 必须在 B 之前A -> CB -> DC -> D
那么合法顺序可以是:
A, B, C, DA, C, B, D
但不能是:
B, A, C, DD, A, B, C
因为这违反了依赖关系。
所以:
拓扑排序 = 对 DAG 中所有顶点进行线性排序,满足所有有向边 u->v 中,u 都排在 v 前面。
三、Kahn 算法的核心思想
Kahn 算法的思想非常直观:
1)先找"没有前置依赖"的点
也就是 入度为 0 的点。
因为它没有任何东西依赖在它前面,所以它一定可以最先执行。
2)把它放入结果序列
既然它可以先做,就把它输出。
3)删除它对后继节点的影响
如果有边:
A -> BA -> C
当 A 被处理完,相当于:
- B 少了一个前置依赖
- C 少了一个前置依赖
所以要把 B、C 的入度减 1。
4)谁的入度变成 0,就说明它现在也可以执行了
继续重复这个过程,直到没有点可处理。
四、为什么它能工作
因为它每次都在做一件很合理的事:
从当前"所有依赖都满足"的节点里,选出一个处理。
这个思想和现实世界的任务调度很像:
- 没有前置条件的任务先做
- 做完后解除后面任务的部分依赖
- 谁的依赖全部满足,谁就进入可执行集合
五、Kahn 算法的标准步骤
设图有 n 个节点。
步骤 1:统计每个节点的入度
入度 = 有多少条边指向这个节点。
步骤 2:把所有入度为 0 的节点放入队列
这些节点可以立即执行。
步骤 3:循环处理队列
每次取出一个节点 u:
- 把
u加入拓扑序列 - 遍历
u的所有出边u -> v - 将
v的入度减 1 - 若
v入度变成 0,则把v入队
步骤 4:检查处理的节点数
- 若最终处理节点数 == n,说明图无环,拓扑排序成功
- 若处理节点数 < n,说明图中有环,无法完成拓扑排序
六、为什么可以检测环
这是 Kahn 算法非常重要的能力。
原因
如果图里有环,例如:
A -> BB -> CC -> A
那么:
- A 入度不是 0
- B 入度不是 0
- C 入度不是 0
队列一开始就空了,根本没有节点能开始处理。
或者图里一部分有环,另一部分无环,那么无环部分能处理完,但环里的点永远入度不可能减到 0。
所以:
处理不完所有节点 = 图中存在环
这也是很多依赖系统判断"循环依赖"的方法。
七、举一个完整例子
图如下:
0 -> 10 -> 21 -> 32 -> 32 -> 4
1)先统计入度
in[0] = 0in[1] = 1in[2] = 1in[3] = 2in[4] = 1
2)初始队列
只有 0
队列:
[0]
结果:
[]
3)取出 0
结果:
[0]
删除 0 的影响:
1入度1 -> 02入度1 -> 0
队列:
[1, 2]
4)取出 1
结果:
[0, 1]
删除 1 的影响:
3入度2 -> 1
队列:
[2]
5)取出 2
结果:
[0, 1, 2]
删除 2 的影响:
3入度1 -> 04入度1 -> 0
队列:
[3, 4]
6)取出 3
结果:
[0, 1, 2, 3]
队列:
[4]
7)取出 4
结果:
[0, 1, 2, 3, 4]
全部处理完成,拓扑排序成功。
八、Kahn 算法和 DFS 拓扑排序的区别
拓扑排序常见有两种做法:
1)Kahn 算法
本质:
- 基于入度
- 基于队列
- 逐层"释放依赖"
特点:
- 容易理解
- 容易检测环
- 很适合工程实现
- 很适合"任务调度/依赖系统"
2)DFS 拓扑排序
本质:
- 深度优先遍历
- 一个点的所有后继处理完,再把它加入结果
- 最后逆序得到拓扑序
特点:
- 更偏图遍历思维
- 理论上也很好
- 适合递归表达
- 但工程里检测环时通常要额外维护"访问中"状态
对比总结
Kahn 更像:
"谁现在能做,我就先做谁"
DFS 更像:
"我先一路走到底,回来时再记顺序"
九、Kahn 算法的时间复杂度
设:
V= 顶点数E= 边数
那么:
时间复杂度
O(V + E)
原因:
- 每个点最多入队出队一次
- 每条边最多被访问一次
空间复杂度
O(V + E)
需要:
- 图的邻接表
- 入度数组
- 队列
- 结果数组
十、Kahn 算法适合哪些工程场景
这个很重要,因为它不仅是算法题。
1)课程依赖
例如:
- 学操作系统前要学数据结构
- 学编译原理前要学离散数学
可用于排课程先修顺序。
2)编译系统
例如:
- 模块 A 依赖公共头文件
- 模块 B 依赖模块 A
- 模块 C 依赖模块 B
编译顺序可以通过拓扑排序得到。
3)任务调度系统
例如:
- 任务 C 必须等 A 和 B 完成
- 任务 D 必须等 C 完成
这就是标准 DAG 调度。
4)包管理器 / 依赖安装
例如:
- 安装某个软件包前,必须先安装其依赖库
这也是一个依赖图。
5)系统初始化顺序
例如:
- 驱动初始化
- 服务启动依赖
- 子模块装载顺序
都可以抽象成 DAG。
十一、Kahn 算法的关键数据结构
通常需要:
1)邻接表
存每个节点能到哪些后继节点。
例如:
c
0: 1, 2
1: 3
2: 3, 4
2)入度数组
例如:
c
indegree[0] = 0
indegree[1] = 1
indegree[2] = 1
indegree[3] = 2
indegree[4] = 1
3)队列
保存当前所有"可执行"的节点,即入度为 0 的节点。
4)结果数组
保存最终拓扑排序结果。
十二、C 语言版 Kahn 算法实现
下面给你一个清晰、教学型的 C 版本。
1)适合初学理解的版本
这里使用邻接矩阵,代码最直观。
c
#include <stdio.h>
#define MAXN 100
typedef struct {
int data[MAXN];
int front;
int rear;
} Queue;
/* 初始化队列 */
void queue_init(Queue *q)
{
q->front = 0;
q->rear = 0;
}
/* 队列是否为空 */
int queue_empty(Queue *q)
{
return q->front == q->rear;
}
/* 入队 */
void enqueue(Queue *q, int x)
{
q->data[q->rear++] = x;
}
/* 出队 */
int dequeue(Queue *q)
{
return q->data[q->front++];
}
/*
* Kahn 拓扑排序
* graph[i][j] = 1 表示 i -> j
* n 为节点个数,节点编号默认 0 ~ n-1
* topo 用于保存拓扑序结果
* 返回值:
* 1 表示成功,图无环
* 0 表示失败,图有环
*/
int kahn_topo_sort(int graph[MAXN][MAXN], int n, int topo[MAXN])
{
int indegree[MAXN] = {0};
Queue q;
int i, j;
int count = 0;
queue_init(&q);
/* 1. 统计每个节点的入度 */
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
if (graph[i][j]) {
indegree[j]++;
}
}
}
/* 2. 所有入度为 0 的节点入队 */
for (i = 0; i < n; i++) {
if (indegree[i] == 0) {
enqueue(&q, i);
}
}
/* 3. 不断取出队列中的节点 */
while (!queue_empty(&q)) {
int u = dequeue(&q);
topo[count++] = u;
/* 删除 u 对其他点的影响 */
for (j = 0; j < n; j++) {
if (graph[u][j]) {
indegree[j]--;
if (indegree[j] == 0) {
enqueue(&q, j);
}
}
}
}
/* 4. 判断是否所有节点都处理完 */
if (count == n) {
return 1; /* 无环 */
} else {
return 0; /* 有环 */
}
}
int main(void)
{
int graph[MAXN][MAXN] = {0};
int topo[MAXN];
int n = 5;
int i;
/*
* 构建图:
* 0 -> 1
* 0 -> 2
* 1 -> 3
* 2 -> 3
* 2 -> 4
*/
graph[0][1] = 1;
graph[0][2] = 1;
graph[1][3] = 1;
graph[2][3] = 1;
graph[2][4] = 1;
if (kahn_topo_sort(graph, n, topo)) {
printf("拓扑排序结果: ");
for (i = 0; i < n; i++) {
printf("%d ", topo[i]);
}
printf("\n");
} else {
printf("图中存在环,无法进行拓扑排序\n");
}
return 0;
}
十三、这段代码怎么理解
1)先计算入度
c
if (graph[i][j]) {
indegree[j]++;
}
意思是:
- 如果存在边
i -> j - 那么
j的入度加 1
2)把所有入度为 0 的点丢进队列
c
if (indegree[i] == 0) {
enqueue(&q, i);
}
3)每次弹出一个可以执行的点
c
int u = dequeue(&q);
topo[count++] = u;
4)删除这个点对后继的影响
c
if (graph[u][j]) {
indegree[j]--;
if (indegree[j] == 0) {
enqueue(&q, j);
}
}
含义是:
u -> j这条依赖已经满足了- 所以
j少了一个前置依赖 - 如果减完后变成 0,就可以执行了
十四、为什么这个实现适合教学,但不一定适合大图
因为用了 邻接矩阵:
c
int graph[MAXN][MAXN];
优点:
- 简单直观
- 适合入门
缺点:
- 空间复杂度高
- 如果点很多而边很少,会浪费内存
- 遍历某个点的后继时,要扫一整行
工程里更常用 邻接表。
十五、工程里更常见的写法:邻接表
如果图很稀疏,用邻接表更好。
思路是:
c
head[u] 存 u 的第一条边
to[idx] 存边指向谁
next[idx] 串起同一个起点的所有边
这样遍历 u 的所有出边时,只访问真实存在的边,效率更高。
如果你后面要,我可以继续给你展开:
- 邻接表版 Kahn 算法 C 实现
- 支持字符串任务名的版本
- 任务调度系统版的工程框架
- 和 DFS 拓扑排序逐行对比
十六、Kahn 算法常见面试问法
1)怎么判断一个图有没有环?
答:
- 用 Kahn 算法做拓扑排序
- 如果最终输出节点数小于总节点数,说明有环
2)拓扑序是不是唯一?
答:
- 不一定唯一
- 当某一时刻有多个入度为 0 的点,都可以先处理
- 不同的选择可能产生不同合法拓扑序
3)怎么得到字典序最小的拓扑序?
答:
- 把普通队列换成 小根堆 / 优先队列
- 每次都取编号最小的入度为 0 的节点
4)为什么只能用于 DAG?
答:
- 因为有环时不存在满足所有依赖关系的线性顺序
十七、Kahn 算法的本质总结
你可以把它记成一句话:
不断找入度为 0 的点,输出它,并删除它对后继节点的影响。
再抽象一点:
谁没有前置依赖,谁先执行;执行后释放后续任务。
这就是 Kahn 算法的本质。
十八、你可以怎样真正学会它
建议按这个顺序掌握:
第一步:先彻底理解"入度为 0 为什么可以先做"
这是根本。
第二步:手算 2~3 个例子
自己画图,自己算入度变化。
第三步:背下标准流程
- 统计入度
- 入度 0 入队
- 出队加入结果
- 后继入度减 1
- 新的入度 0 入队
- 看最终处理数量
第四步:自己手写 C 代码
最好不看答案重写一遍。
第五步:再学习 DFS 版拓扑排序
这样你就能真正理解两种思路。