算法基础 -- Kahn 算法简介(C语言版本)

一、Kahn 算法到底是什么

Kahn 算法是用于解决 有向无环图(DAG, Directed Acyclic Graph)拓扑排序 的经典算法。

它回答的问题是:

已知一组"先后依赖关系",怎样排出一个合法顺序,使得每个点都出现在它所有前置依赖之后?

比如:

  • 课程学习顺序
  • 任务调度顺序
  • 编译依赖顺序
  • 模块初始化顺序
  • 工作流 DAG 执行顺序

二、什么是拓扑排序

先理解拓扑排序本身。

假设有图:

  • A -> B 表示 A 必须在 B 之前
  • A -> C
  • B -> D
  • C -> D

那么合法顺序可以是:

  • A, B, C, D
  • A, C, B, D

但不能是:

  • B, A, C, D
  • D, A, B, C

因为这违反了依赖关系。

所以:

拓扑排序 = 对 DAG 中所有顶点进行线性排序,满足所有有向边 u->v 中,u 都排在 v 前面。


三、Kahn 算法的核心思想

Kahn 算法的思想非常直观:

1)先找"没有前置依赖"的点

也就是 入度为 0 的点

因为它没有任何东西依赖在它前面,所以它一定可以最先执行。


2)把它放入结果序列

既然它可以先做,就把它输出。


3)删除它对后继节点的影响

如果有边:

  • A -> B
  • A -> C

当 A 被处理完,相当于:

  • B 少了一个前置依赖
  • C 少了一个前置依赖

所以要把 B、C 的入度减 1。


4)谁的入度变成 0,就说明它现在也可以执行了

继续重复这个过程,直到没有点可处理。


四、为什么它能工作

因为它每次都在做一件很合理的事:

从当前"所有依赖都满足"的节点里,选出一个处理。

这个思想和现实世界的任务调度很像:

  • 没有前置条件的任务先做
  • 做完后解除后面任务的部分依赖
  • 谁的依赖全部满足,谁就进入可执行集合

五、Kahn 算法的标准步骤

设图有 n 个节点。

步骤 1:统计每个节点的入度

入度 = 有多少条边指向这个节点。


步骤 2:把所有入度为 0 的节点放入队列

这些节点可以立即执行。


步骤 3:循环处理队列

每次取出一个节点 u

  1. u 加入拓扑序列
  2. 遍历 u 的所有出边 u -> v
  3. v 的入度减 1
  4. v 入度变成 0,则把 v 入队

步骤 4:检查处理的节点数

  • 若最终处理节点数 == n,说明图无环,拓扑排序成功
  • 若处理节点数 < n,说明图中有环,无法完成拓扑排序

六、为什么可以检测环

这是 Kahn 算法非常重要的能力。

原因

如果图里有环,例如:

  • A -> B
  • B -> C
  • C -> A

那么:

  • A 入度不是 0
  • B 入度不是 0
  • C 入度不是 0

队列一开始就空了,根本没有节点能开始处理。

或者图里一部分有环,另一部分无环,那么无环部分能处理完,但环里的点永远入度不可能减到 0。

所以:

处理不完所有节点 = 图中存在环

这也是很多依赖系统判断"循环依赖"的方法。


七、举一个完整例子

图如下:

  • 0 -> 1
  • 0 -> 2
  • 1 -> 3
  • 2 -> 3
  • 2 -> 4

1)先统计入度

  • in[0] = 0
  • in[1] = 1
  • in[2] = 1
  • in[3] = 2
  • in[4] = 1

2)初始队列

只有 0

队列:
[0]

结果:
[]


3)取出 0

结果:
[0]

删除 0 的影响:

  • 1 入度 1 -> 0
  • 2 入度 1 -> 0

队列:
[1, 2]


4)取出 1

结果:
[0, 1]

删除 1 的影响:

  • 3 入度 2 -> 1

队列:
[2]


5)取出 2

结果:
[0, 1, 2]

删除 2 的影响:

  • 3 入度 1 -> 0
  • 4 入度 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 的所有出边时,只访问真实存在的边,效率更高。

如果你后面要,我可以继续给你展开:

  1. 邻接表版 Kahn 算法 C 实现
  2. 支持字符串任务名的版本
  3. 任务调度系统版的工程框架
  4. 和 DFS 拓扑排序逐行对比

十六、Kahn 算法常见面试问法

1)怎么判断一个图有没有环?

答:

  • 用 Kahn 算法做拓扑排序
  • 如果最终输出节点数小于总节点数,说明有环

2)拓扑序是不是唯一?

答:

  • 不一定唯一
  • 当某一时刻有多个入度为 0 的点,都可以先处理
  • 不同的选择可能产生不同合法拓扑序

3)怎么得到字典序最小的拓扑序?

答:

  • 把普通队列换成 小根堆 / 优先队列
  • 每次都取编号最小的入度为 0 的节点

4)为什么只能用于 DAG?

答:

  • 因为有环时不存在满足所有依赖关系的线性顺序

十七、Kahn 算法的本质总结

你可以把它记成一句话:

不断找入度为 0 的点,输出它,并删除它对后继节点的影响。

再抽象一点:

谁没有前置依赖,谁先执行;执行后释放后续任务。

这就是 Kahn 算法的本质。


十八、你可以怎样真正学会它

建议按这个顺序掌握:

第一步:先彻底理解"入度为 0 为什么可以先做"

这是根本。


第二步:手算 2~3 个例子

自己画图,自己算入度变化。


第三步:背下标准流程

  • 统计入度
  • 入度 0 入队
  • 出队加入结果
  • 后继入度减 1
  • 新的入度 0 入队
  • 看最终处理数量

第四步:自己手写 C 代码

最好不看答案重写一遍。


第五步:再学习 DFS 版拓扑排序

这样你就能真正理解两种思路。

相关推荐
小肝一下3 小时前
每日两道力扣,day7
数据结构·c++·算法·leetcode·双指针·hot100·接雨水,四数之和
小O的算法实验室1 天前
2026年ASOC,基于深度强化学习的无人机三维复杂环境分层自适应导航规划方法,深度解析+性能实测
算法·无人机·论文复现·智能算法·智能算法改进
qq_339554821 天前
英飞凌ModusToolbox环境搭建
c语言·eclipse
张張4081 天前
(域格)环境搭建和编译
c语言·开发语言·python·ai
郭涤生1 天前
STL vector 扩容机制与自定义内存分配器设计分析
c++·算法
༾冬瓜大侠༿1 天前
vector
c语言·开发语言·数据结构·c++·算法
Ricky111zzz1 天前
leetcode学python记录1
python·算法·leetcode·职场和发展
汀、人工智能1 天前
[特殊字符] 第58课:两个正序数组的中位数
数据结构·算法·数据库架构··数据流·两个正序数组的中位数
liu****1 天前
第16届省赛蓝桥杯大赛C/C++大学B组(京津冀)
开发语言·数据结构·c++·算法·蓝桥杯