拓扑排序

2 阅读4分钟

拓扑排序(Topological Sort)—— C语言链式栈+邻接表实现详解

一、什么是拓扑排序?

拓扑排序是对 有向无环图(DAG) 的顶点进行线性排序,使得对于每一条有向边 u → v,顶点 u 都排在顶点 v 的前面。

应用场景:

  • 课程安排(先修课关系)
  • 工程编译依赖
  • 任务调度

拓扑排序只存在于有向无环图中。若图中存在环,则无法进行拓扑排序。


二、Kahn 算法思想

Kahn 算法(卡恩算法)是拓扑排序的经典算法,核心思想:

  1. 统计入度:遍历所有边,计算每个顶点的入度(有多少条边指向它)。
  2. 入度为 0 的入队/入栈:将入度为 0 的顶点加入容器(可以是队列或栈)。
  3. 取出顶点:从容器中取出一个顶点,加入拓扑序列。
  4. 更新入度:将该顶点的所有出边邻接点的入度减 1,如果某个邻接点的入度变为 0,则将其加入容器。
  5. 重复步骤 3-4,直到所有顶点都处理完。

如何判断有环? 如果处理完的顶点数 < 总顶点数,说明图中存在环。


三、代码逐层解析

3.1 链式栈的实现

typedef struct stackNode {
    int data;
    struct stackNode* next;
} sNode, *Stack;

使用带头结点的单链表实现栈,栈顶是头结点的下一个结点。

操作说明
InitStack()创建空栈(头结点)
Push(s, k)头插法插入元素
Pop(s)删除头结点后的第一个元素
GetTop(s)返回栈顶元素
IsEmpty(s)判断栈是否为空

3.2 图的邻接表存储

struct Graph {
    char data;      // 顶点名称(如 A, B, C...)
    ENode* first;   // 出边链表头指针
} g[105];
  • g[i] 表示第 i 个顶点
  • ENode 是出边链表的结点,adj 存储邻接点的下标
  • ind[] 数组记录每个顶点的入度
  • topo[] 数组记录最终的拓扑序列

3.3 Find() 函数

int Find(char x) {
    for (int i = 1; i <= n; i++)
        if (g[i].data == x) break;
    return i;
}

根据顶点字符名称查找其在数组中的下标。时间复杂度 O(n),图不大时够用。

3.4 建图过程

for (int i = 1; i <= m; i++) {
    scanf(" %c %c", &x, &y);
    xi = Find(x); yi = Find(y);
    ind[yi]++;                  // y 的入度 +1
    // 头插法将 y 插入 x 的出边链表
    ENode* e = (ENode*)malloc(sizeof(ENode));
    e->adj = yi;
    e->next = g[xi].first;
    g[xi].first = e;
}

对每条有向边 x → y

  • y 的入度 ind[yi]++
  • x 的出边链表中头插一个结点,值为 y 的下标

3.5 核心:TopoSort()

void TopoSort() {
    Stack s = InitStack();
    // 第一轮:把所有入度为 0 的点入栈
    for (int i = 1; i <= n; i++)
        if (ind[i] == 0) s = Push(s, i);

    for (int i = 1; i <= n; i++) {
        if (IsEmpty(s)) break;      // 栈空 → 有环,提前退出
        int j = GetTop(s); s = Pop(s);
        topo[++k] = g[j].data;      // 加入拓扑序列

        ENode* p = g[j].first;
        while (p != NULL) {
            ind[p->adj]--;          // 邻接点入度 -1
            if (ind[p->adj] == 0)
                s = Push(s, p->adj);
            p = p->next;
        }
    }
}

核心逻辑:

  1. 初始化栈,将入度为 0 的顶点全部入栈
  2. 每次循环弹出一个栈顶顶点,加入拓扑序列
  3. 遍历该顶点的所有出边,将邻接点入度减 1
  4. 如果邻接点入度变为 0,则入栈
  5. 若过程中栈变空但顶点还没处理完,说明有环

这里用而不是队列,得到的拓扑序可能与用队列不同(但都是合法的拓扑序)。

3.6 主函数与测试

int main() {
    scanf("%d %d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf(" %c", &g[i].data);
        g[i].first = NULL;
    }
    // ... 读入 m 条边 ...

    TopoSort();
    if (k != n)
        printf("有环\n");
    else
        for (int i = 1; i <= n; i++)
            printf("%c ", topo[i]);
    return 0;
}

四、测试样例

6 8
A B C D E F
A B
A C
A D
C B
C E
F D
F E
D E

图的结构:

AB, C, D
B → (无出边)
C → B, E
D → E
F → D, E

运行结果:

A F C B D E

F A C B D E

(取决于入度为0的顶点的入栈顺序)


五、算法复杂度分析

维度复杂度
时间复杂度O(n + m),n 个顶点,m 条边,每个顶点入栈一次,每条边被遍历一次
空间复杂度O(n),栈 + 入度数组 + 拓扑序列数组

六、总结

要点说明
适用范围有向无环图
核心数据结构邻接表 + 栈/队列
判断有环最终拓扑序列长度 < 顶点总数
入度思想每次去掉"没有前置依赖"的顶点

拓扑排序的关键在于入度这个概念的运用——入度为 0 意味着"没有前置约束",可以放心地加入结果序列。Kahn 算法直观且高效,是图论入门的必学算法之一。