数据结构——拓扑排序

拓扑排序

在学习有向无环图的应用之前,读者通常会先询问一个最基本的问题:怎样给图中的所有顶点安排一个线性次序,使得每条有向边都"从前指向后"。为了回答这个问题,需要引入"拓扑排序"的概念,并通过可视化过程、算法步骤与带注释的 C 语言实现,帮助读者把抽象的定义变成可操作的步骤。

**引导语------**为了把"先后依赖关系"变成"线性执行顺序",需要一种能消除依赖、不断"解锁"后继顶点的方法。这种方法的核心思想是优先处理当前入度为零的顶点,并将其对后继的约束从图中移除,从而逐步得到一个满足所有依赖关系的线性序列。


1. 概念与问题背景

拓扑排序是针对有向无环图的一种线性排序方法。排序结果是把图中所有顶点排成一个序列,使得任意一条有向边"u→v"都满足顶点 u 在顶点 v 的前面。这样的排序体现了偏序关系的线性扩展,是任务编排、课程先修体系、构建系统依赖、数据管道调度等场景的基础操作。

图中不允许存在有向环,是开展拓扑排序的前提条件。若存在有向环,任何线性序列都无法同时满足环上各边的"先于"约束。实际应用中,拓扑排序往往同时承担两个目标:其一,给出可执行的先后顺序;其二,帮助检测是否存在环。


2. 有向无环图与偏序关系

将依赖关系建模为有向边后,图的无环性质对应着"可被线性化的偏序"。如果把顶点看成事件或任务,把有向边看成"必须先发生",拓扑序列就是把"必须先发生"的约束尽可能保持住的一条线性链。相比任意的线性排列,拓扑序列不任意,它遵循所有已知的因果或依赖约束,因此常被用作"正确的执行顺序"的近似定义。

为了直观展示后续算法的运行过程,先准备一张小型有向无环图,顶点集合为 {A,B,C,D,E,F},边集合如下:

A→C,A→D,B→D,C→E,D→E,E→F。该图体现了"由前向后逐层解锁"的依赖结构。


3. 两类主算法的总览

在实践中,拓扑排序常见的实现路径有两类。其一是基于入度削减的 Kahn 算法,思路是反复选取入度为零的顶点输出,并删除其外发边以降低后继入度。其二是基于深度优先搜索的后序入栈法,思路是沿着边做深度搜索,在回溯时把顶点压入栈顶,最终逆栈序得到拓扑序列。两种方法都能在线性时间内完成,但实现细节和适用习惯略有不同。

**引导语------**为了把抽象的过程具体化,先用一段可视化的"状态快照"展示 Kahn 算法在样例图上的推进过程。随后再给出 Kahn 与 DFS 两种实现的 C 语言代码与复杂度分析。


4. Kahn 算法的直观过程与细化步骤

在直觉层面,Kahn 算法可以理解为"不断从图的入口处取点"。所谓入口,就是当前没有任何入边指向它的顶点。把这些顶点取走并输出后,与它们相连的出边就会被删除,于是又会有新的顶点"变成入口"。这个过程一直持续,直到所有顶点都被输出,或者没有入口但仍有顶点残留(此时存在环)。

(1)整体流程说明。

· 初始化每个顶点的入度;

· 将所有入度为零的顶点加入候选集合(通常用队列);

· 反复从候选集合取出一个顶点,输出到结果序列,并删除它发出的每条边;

· 每删除一条边,就把该边的终点入度减一;若减为零,将该终点加入候选集合;

· 最终若输出顶点数等于图中顶点总数,则得到一个有效拓扑序列;否则图中存在有向环。

(2)样例图初始状态与快照展示。为了让读者能够"看见"算法如何逐步推进,下面给出从初始图到序列完成的多个状态快照。灰色实心表示"已输出的顶点",绿色表示"当前候选集合中的顶点",白色表示"尚未解锁的顶点"。图随步骤推进而更新。

状态快照①------初始图。
A C D B E F

说明:A、B 的入度为零,最先进入候选集合。其余顶点暂不可输出。

状态快照②------输出 A,更新入度后,C、D 的入度变化。
A B D A的边已删除 C E F

说明:A 被输出并从图中"移除"。A→C、A→D 被删除后,C 的入度降为零加入候选集合;D 仍有来自 B 的入边,入度暂不为零。候选集合现在包含 B、C。

状态快照③------输出 B,再次更新入度。
A B C E D F

说明:删除 B→D 后,D 的入度也降为零,加入候选集合。此时候选集合包含 C、D。

状态快照④------输出 C,再输出 D,推动 E 的入度清零。
A B C D E F

说明:删除 C→E 与 D→E 后,E 入度变为零,可以进入候选集合。

状态快照⑤------输出 E,最后输出 F,得到一个拓扑序列。
A B C D E F

说明:所有顶点均已输出,样例的一种拓扑序列为"A,B,C,D,E,F"。若候选集合在某些时刻包含多个顶点,具体的弹出顺序可能不同,从而产生不同的合法序列。

(3)入度与候选集合的"账本"跟踪。为了让过程更易核对,下表记录了每一步的入度与候选集合变化。表中"入度变化"列仅给出发生变化的顶点。

步骤 输出顶点 入度变化(变为零的顶点) 候选集合(输出后)
初始 A,B 入度为 0 {A,B}
A C 入度→0 {B,C}
B D 入度→0 {C,D}
C E 入度暂未清零 {D}
D E 入度→0 {E}
E F 入度→0 {F}
F {}

5. Kahn 算法的 C 语言实现与注释

**引导语------**为了将上述过程落到代码层面,下面给出一个使用邻接表与顺序队列的实现。实现重点是两张"账本":一张是入度数组,另一张是队列维护的候选集合。每删除一条外发边,就在入度账本上做减法,并在入度归零时把相应顶点入队。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* 
 * 拓扑排序(Kahn 算法)------邻接表 + 顺序队列
 * 顶点编号假定为 0..n-1,对应示例可自行映射 A..F
 */

typedef struct Edge {
    int to;
    int next;
} Edge;

typedef struct {
    int *head;     // 邻接表表头数组,head[u] 指向第一条以 u 为起点的边下标
    Edge *edges;   // 边数组,使用 next 串成链
    int edge_cnt;  // 已插入的边数
    int n;         // 顶点数
} Graph;

Graph* create_graph(int n, int m) {
    Graph* g = (Graph*)malloc(sizeof(Graph));
    g->n = n;
    g->edge_cnt = 0;
    g->head = (int*)malloc(sizeof(int) * n);
    g->edges = (Edge*)malloc(sizeof(Edge) * m);
    for (int i = 0; i < n; ++i) g->head[i] = -1;
    return g;
}

void add_edge(Graph* g, int u, int v) {
    // 插入一条 u->v 的有向边
    g->edges[g->edge_cnt].to = v;
    g->edges[g->edge_cnt].next = g->head[u];
    g->head[u] = g->edge_cnt++;
}

int* kahn_toposort(Graph* g, int *indeg, int *out_len) {
    int n = g->n;
    int *q = (int*)malloc(sizeof(int) * n); // 简单顺序队列
    int front = 0, rear = 0;

    // 结果序列
    int *order = (int*)malloc(sizeof(int) * n);
    int cnt = 0;

    // 将所有入度为 0 的顶点入队
    for (int i = 0; i < n; ++i) {
        if (indeg[i] == 0) q[rear++] = i;
    }

    while (front < rear) {
        int u = q[front++];       // 取一个当前无前驱的顶点
        order[cnt++] = u;         // 输出到拓扑序列

        // 删除 u 的所有外发边:对每条 u->v,将 v 的入度减一
        for (int e = g->head[u]; e != -1; e = g->edges[e].next) {
            int v = g->edges[e].to;
            if (--indeg[v] == 0) {
                q[rear++] = v;    // v 入度降为 0,后继被"解锁",入队
            }
        }
    }

    free(q);

    // 若未输出完所有顶点,说明存在有向环
    if (cnt != n) {
        *out_len = 0;
        free(order);
        return NULL;
    }
    *out_len = cnt;
    return order;
}

int main(void) {
    // 样例:A..F 映射为 0..5
    // 边:A->C, A->D, B->D, C->E, D->E, E->F
    int n = 6, m = 6;
    Graph* g = create_graph(n, m);

    add_edge(g, 0, 2); // A->C
    add_edge(g, 0, 3); // A->D
    add_edge(g, 1, 3); // B->D
    add_edge(g, 2, 4); // C->E
    add_edge(g, 3, 4); // D->E
    add_edge(g, 4, 5); // E->F

    // 预置入度
    int indeg[6] = {0};
    // 手工统计或在 add_edge 时同步维护
    indeg[2]++; // C
    indeg[3]+=2; // D
    indeg[4]+=2; // E
    indeg[5]++; // F

    int out_len = 0;
    int *order = kahn_toposort(g, indeg, &out_len);

    if (!order) {
        printf("图中存在有向环,无法得到拓扑序列。\n");
    } else {
        printf("一种拓扑序列为:");
        for (int i = 0; i < out_len; ++i) {
            // 将 0..5 映射回 A..F 展示
            printf("%c%s", 'A' + order[i], (i + 1 == out_len) ? "\n" : " ");
        }
        free(order);
    }

    free(g->edges);
    free(g->head);
    free(g);
    return 0;
}

实现要点说明。

· 顶点表使用"表头数组+边数组"的紧凑邻接表,便于线性遍历外发边。

· 入度数组是驱动算法的"账本",每删除一条外发边,就在终点的入度上做自减操作。

· 候选集合用顺序队列即可,若需要按字典序输出,可将队列替换为小根堆或有序容器。

· 以输出顶点计数与总顶点数比较,能在同一遍扫描中完成"是否有环"的判定。


6. 基于 DFS 的后序入栈法与实现

**引导语------**另一种思路是利用深度优先的"后序"性质。沿着边深入访问到最深处后再回溯,将回溯时刻的顶点依次压栈,最终弹栈反向得到拓扑次序。若在 DFS 过程中遇到"回到递归栈中的祖先顶点"的情况,就说明存在有向环。

(1)核心思想与状态约定。

· 使用三色标记或等价的访问数组区分"未访问""递归栈中""已完成"三种状态;

· 当沿边访问到一个"递归栈中"的顶点时,即检测到有环;

· 每个顶点在所有后继都处理完毕时入栈,最终逆栈序即为拓扑序列。

(2)C 语言实现(递归版)。为便于理解,下面示例仍使用邻接表,颜色数组取值 0 表示未访问,1 表示递归栈中,2 表示已完成。

c 复制代码
#include <stdio.h>
#include <stdlib.h>

typedef struct Edge {
    int to;
    int next;
} Edge;

typedef struct {
    int *head;
    Edge *edges;
    int edge_cnt;
    int n;
} Graph;

Graph* create_graph2(int n, int m) {
    Graph* g = (Graph*)malloc(sizeof(Graph));
    g->n = n;
    g->edge_cnt = 0;
    g->head = (int*)malloc(sizeof(int) * n);
    g->edges = (Edge*)malloc(sizeof(Edge) * m);
    for (int i = 0; i < n; ++i) g->head[i] = -1;
    return g;
}

void add_edge2(Graph* g, int u, int v) {
    g->edges[g->edge_cnt].to = v;
    g->edges[g->edge_cnt].next = g->head[u];
    g->head[u] = g->edge_cnt++;
}

int *stack_arr;
int top_ptr;
int *color; // 0: 未访问;1: 递归栈中;2: 已完成
int has_cycle;

void dfs(Graph* g, int u) {
    color[u] = 1; // 入栈路径
    for (int e = g->head[u]; e != -1; e = g->edges[e].next) {
        int v = g->edges[e].to;
        if (color[v] == 0) {
            dfs(g, v);
            if (has_cycle) return; // 提前结束
        } else if (color[v] == 1) {
            has_cycle = 1; // 发现回到递归栈,存在环
            return;
        }
    }
    color[u] = 2;          // 完成回溯
    stack_arr[++top_ptr] = u; // 后序入栈
}

int main(void) {
    int n = 6, m = 6;
    Graph* g = create_graph2(n, m);
    // A..F -> 0..5
    add_edge2(g, 0, 2); // A->C
    add_edge2(g, 0, 3); // A->D
    add_edge2(g, 1, 3); // B->D
    add_edge2(g, 2, 4); // C->E
    add_edge2(g, 3, 4); // D->E
    add_edge2(g, 4, 5); // E->F

    color = (int*)calloc(n, sizeof(int));
    stack_arr = (int*)malloc(sizeof(int) * n);
    top_ptr = -1;
    has_cycle = 0;

    for (int i = 0; i < n; ++i) {
        if (color[i] == 0) {
            dfs(g, i);
            if (has_cycle) break;
        }
    }

    if (has_cycle) {
        printf("图中存在有向环,无法拓扑排序。\n");
    } else {
        printf("一种拓扑序列为:");
        while (top_ptr >= 0) {
            int u = stack_arr[top_ptr--];
            printf("%c%s", 'A' + u, (top_ptr < 0) ? "\n" : " ");
        }
    }

    free(stack_arr);
    free(color);
    free(g->edges);
    free(g->head);
    free(g);
    return 0;
}

(3)两种方法的对照理解。

1)Kahn 方法"从入口拆边",显式维护入度与候选集合,适合实时调度、并行可行性判断等场景。

2)DFS 方法"从末端回溯",不直接维护入度,代码更短,适合快速生成一个合法顺序与检测环存在。

3)两者时间复杂度均为 O(V+E),空间复杂度主要来自邻接表、队列或递归栈,通常为 O(V+E)。


7. 复杂度、唯一性与健壮性讨论

**引导语------**在掌握了基本实现后,常见的进一步问题是:是否唯一、如何判环、怎样保证输出序列的"稳定可控"。这些问题决定了算法在工程中的可用性与可维护性。

(1)时间与空间复杂度。

· 两种算法都对每条边与每个顶点做常数次处理,时间复杂度为 O(V+E)。

· 邻接表占用 O(V+E) 空间;Kahn 的队列最多装下 O(V) 个顶点;DFS 的递归深度最坏为 O(V)。

(2)唯一性判定。

· 若在 Kahn 的执行过程中,任意时刻候选集合的大小都为 1,则拓扑序列唯一;

· 若出现某一步候选集合里有多个顶点,则至少存在两种不同的合法序列。

(3)环的检测。

· Kahn:输出计数小于顶点总数即可判定存在环,因为没有入度为零的顶点但仍有顶点残留;

· DFS:访问到"递归栈中的顶点"即检测到回到祖先的反向路径,从而判定为环。

(4)稳定性与可控输出。

· 若希望得到"字典序最小"的拓扑序列,可将 Kahn 的队列结构改用小根堆或有序集合;

· 若图很大且入度变化频繁,可考虑"批量入队"的策略减少堆操作次数。


8. 样例数据的"操作台"复盘

**引导语------**为了便于与前面的状态快照互相验证,下面把样例的入度账本、队列账本与输出序列三者并排展示。读者只需按行比对,就能理解每一步的逻辑一致性。

轮次 候选集合取出 输出序列累计 入度被削减的顶点 新进入候选集合
初始 A(0),B(0) {A,B}
1 A A C:1→0,D:2→1 {B,C}
2 B A,B D:1→0 {C,D}
3 C A,B,C E:2→1 {D}
4 D A,B,C,D E:1→0 {E}
5 E A,B,C,D,E F:1→0 {F}
6 F A,B,C,D,E,F {}

若将候选集合的数据结构替换为小根堆,且把 A...F 的字典序映射为相应编号,那么在每一步都会优先弹出字母序最小的顶点,最终得到字典序最小的拓扑序列。这个策略在课程编排、构建流水线中常用,用以保证输出结果的可解释性与稳定性。


9. 细节与边界情形的处理建议

**引导语------**真实数据往往会在细节处"刁难"实现者,例如单点无边、多源多汇、稀疏或稠密的极端度分布等。把这些情况预先纳入设计,有助于提升代码的鲁棒性。

(1)孤立点与无边图。

· 若某个顶点既无入边也无出边,其入度为零,会自然被最先输出;

· 一个完全无边的图,任何排列都是合法拓扑序列。

(2)并行可行性与层级划分。

· 可使用"分层输出"的思路,把同一轮入队的所有顶点视为同一层,从而得到一种"层序拓扑";

· 这一做法常用于批量调度,单轮中互不依赖的任务可并行执行。

(3)数据规模与内存。

· 当边数接近顶点数的数量级时,邻接表的空间效率显著优于邻接矩阵;

· 极大规模图可考虑压缩存储与块式读取,但算法思想不变。


10. 过程可视化的"层序视角"

**引导语------**除了按步骤输出,很多读者也喜欢把拓扑排序理解为"按层剥离"的过程。下一张图把每一层的顶点摆放在同一列,读者可以把它看成"流水线的时间片"。

graph LR classDef layer0 fill:#A7F3D0,stroke:#10B981,stroke-width:1px,color:#0B5; classDef layer1 fill:#BFDBFE,stroke:#1D4ED8,stroke-width:1px,color:#123; classDef layer2 fill:#FDE68A,stroke:#D97706,stroke-width:1px,color:#321; classDef layer3 fill:#FCA5A5,stroke:#B91C1C,stroke-width:1px,color:#311; subgraph L0[第 0 层(入度为零)] A((A)):::layer0 B((B)):::layer0 end subgraph L1[第 1 层] C((C)):::layer1 D((D)):::layer1 end subgraph L2[第 2 层] E((E)):::layer2 end subgraph L3[第 3 层] F((F)):::layer3 end A --> C A --> D B --> D C --> E D --> E E --> F

说明:把同一轮被"解锁"的顶点放到同一层,可以一眼看出潜在的并行度。图中 L0 中的 A、B 可并行处理;当它们完成后,C、D 被解锁,进入 L1;随后 E 进入 L2,最后 F 进入 L3。


11. 常见错误与纠正思路

**引导语------**在练习与应用中,经常遇到一些"看似合理"的实现错误。总结这些错误并给出纠正策略,有助于在阅读他人代码或调试时快速定位问题。

(1)只在初始化时把入度为零的顶点入队。

纠正思路:每次削减入度后,一旦入度降为零,必须立刻入队,否则候选集合不完整,会漏解。

(2)删除边时少减入度或重复减入度。

纠正思路:以"遍历外发边"的粒度准确执行一次自减,不要用"统计后统一减"的粗放做法,否则容易与边多重性不一致。

(3)未做环检测或误检。

纠正思路:Kahn 用"输出计数是否等于顶点数"判定,DFS 用"三色标记回到递归栈"判定,两种方法都可靠。

(4)忽略输出的可控性。

纠正思路:若需要稳定的序列,必须把候选集合改为有序结构,在弹出时强制最小者优先。


12. 小结与延伸阅读建议

**引导语------**回顾全文,拓扑排序的关键在于把"局部无前驱"的顶点一批一批地拿走,同时保证任何时刻都不违反依赖。无论采用 Kahn 还是 DFS,本质都是沿着"先后约束"构造一条线性链。前者强调"显式削边",后者依赖"后序回溯",两者在复杂度上等价,在工程习惯与可控性上各有侧重。

为了在更复杂的调度系统中使用拓扑排序,读者可以进一步思考如下问题:如何在大规模稀疏图中维持字典序最小;如何与关键路径分析配合做时长估计;如何在增量更新的依赖图中维护拓扑序列的最小变更量。这些思考将把"正确性"与"工程性"结合起来,形成面向实际系统的完整能力。


本节要点回看(便于速览):

· 拓扑排序的目标是把偏序关系线性化,满足每条边的"先于"约束。

· Kahn 算法以入度账本与候选集合为核心,按层"解锁"顶点;DFS 以后序回溯压栈为核心。

· 线性时间完成排序,借由输出计数或三色标记完成环检测。

· 候选集合的组织方式决定了输出序列的稳定性与可控性。

相关推荐
草莓工作室6 小时前
数据结构10:树和二叉树
数据结构
当战神遇到编程8 小时前
链表的概念和单向链表的实现
数据结构·链表
INGNIGHT9 小时前
单词搜索 II · Word Search II
数据结构·c++·算法
QuantumLeap丶10 小时前
《数据结构:从0到1》-06-单链表&双链表
数据结构·算法
violet-lz10 小时前
数据结构八大排序:快速排序-挖坑法(递归与非递归)及其优化
数据结构
Mrliu__11 小时前
Python数据结构(七):Python 高级排序算法:希尔 快速 归并
数据结构·python·排序算法
大数据张老师13 小时前
数据结构——广度优先搜索
数据结构·图论·宽度优先
小梁努力敲代码13 小时前
java数据结构--LinkedList与链表
java·数据结构·链表
再睡一夏就好13 小时前
【C++闯关笔记】深究继承
java·数据结构·c++·stl·学习笔记