拓扑排序教学文档

TL;DR:统计有向无环图中每个顶点的入度,反复取出队首的入度为 0 的顶点并把它的后继入度减一,即可得到"每个顶点都排在其前驱之后"的线性序列;时间复杂度 O(V + E);若队列清空时仍有顶点未输出,说明图中存在有向环。

目录


一、概述

1.1 拓扑排序要解决的问题

给定一张有向图 G = (V, E),希望输出一个包含 V 中全部顶点的序列 T,使得对任意一条有向边 u → v,u 在 T 中的位置都早于 v。满足这一条件的序列称为 G 的拓扑序(topological ordering),产生拓扑序的算法称为拓扑排序(topological sort)。

拓扑排序的核心作用是把图上的先后约束转换成可以线性执行的顺序列表,用来回答两类问题:

  • 顺序问题:这些动作按什么次序执行,才能保证每一步执行时它的前置都已完成。
  • 相容性问题:这些约束本身是否存在冲突。

前提条件是 G 不含有向环。若 G 中存在有向环,环上每个顶点都被要求排在另一个顶点之前,约束无法同时满足,拓扑序不存在。

1.2 关键性质速览

性质 说明 工程含义
存在前提 G 不含有向环 有环时算法输出长度小于顶点数,可据此判环
时间复杂度 O(V + E) 邻接表表示下为线性时间,不存在比较排序的下界
空间复杂度 O(V),不含邻接表 与顶点数同阶
结果唯一性 一般不唯一 仅当图中存在覆盖全部顶点的有向路径时唯一
算法类别 不是比较排序 无排序键,不比较元素之间的大小
分批输出 Kahn 算法天然给出并行批次 同一批顶点可同时执行
典型用途 求任意一个拓扑序 需要最小字典序时改用堆,见 9.1

1.3 应用概览

场景 依赖关系 拓扑序的作用
构建系统(Makefile、CMake、npm、Maven、Gradle) 包或模块之间的引用与前置 决定编译、安装与执行顺序
表格软件公式求值(Excel、WPS、LibreOffice Calc) 单元格之间的引用 决定重算顺序并检出循环引用
编译器与链接器 符号定义与使用、模块初始化 决定链接次序与模块初始化次序
任务调度系统(Airflow、Dagster、Spark 作业) 作业之间的依赖 决定可并行执行的批次与失败回溯范围
课程与工序编排 先修关系、前置工序 输出可行的修读顺序或施工顺序
分级展示(电影、游戏评级) 评级之间的前置要求 决定展示与升级顺序

二、问题形式化

2.1 输入、输出与约束

输入:一张有向图 G = (V, E),通常用邻接表给出,G[u] 表示顶点 u 的后继顶点列表。顶点编号不要求连续,也不要求与插入顺序一致。

输出:V 的一个排列,或"不存在拓扑序"的判定。

约束:

  • 只处理有向图。无向图上的相邻关系不构成先后约束,因此无向图没有拓扑序的概念。
  • 输出必须包含全部顶点且各出现一次,不能遗漏或重复。
  • 顶点编号不参与任何比较,算法只依赖边集合与遍历顺序。

2.2 有向环与有向无环图

有向环是形如 u₁ → u₂ → … → uk → u₁ 的路径,其中 k ≥ 1。自环 u → u 是长度为 1 的环。含有向环的图无法排序:环上每个顶点的前驱都是环上的另一个顶点,沿环走一圈会要求某个顶点排在自己之前。

有向无环图(directed acyclic graph,DAG)是不含有向环的有向图。拓扑排序的定义域就是 DAG。

2.3 偏序、全序与线性扩展

关系 ≺ 称为偏序,当且仅当它满足三条性质:

  • 自反性:u ≺ u。
  • 反对称性:若 u ≺ v 且 v ≺ u,则 u = v。
  • 传递性:若 u ≺ v 且 v ≺ w,则 u ≺ w。

偏序只规定部分元素之间的先后,不要求任意两者都可比较。全序则要求任意两个不同元素之间都可比较。线性扩展是在保持偏序全部约束的前提下,把偏序扩充为全序所得的排列。

图上存在一个天然偏序:u ≺ v 当且仅当存在从 u 到 v 的有向路径。于是有

拓扑排序 = 求偏序集合的一个线性扩展。

举例:一门专业有四门课的先修关系,高等数学 → 线性代数 → 概率论,高等数学 → 数据结构 → 算法设计。偏序只固定了这几组先后关系,其余任意两门课之间的次序都不受约束;拓扑序的作用是给出一个满足全部已定约束的具体课表顺序。排列数可能不止一个。

2.4 存在性定理

定理:一张有向图存在拓扑序,当且仅当它不含有向环。

必要性:若 G 含有向环,取环上任意一个顶点,按拓扑序它必须排在环上后继之前,沿环推进一圈会得到该顶点排在自身之前的矛盾。

充分性:只要 G 无环,任意时刻至少存在一个入度为 0 的顶点,因此算法总能继续推进,详见 4.2。

推论:算法输出的顶点数小于 |V| 时,图必然含有向环。这是拓扑排序最重要的副产品。

2.5 与比较排序的区别

项目 比较排序 拓扑排序
排序依据 元素之间比较的结果 顶点之间的有向边
是否比较元素 需要 不需要
时间下界 Ω(n log n) O(V + E),无下界约束
稳定性 区分稳定与不稳定 不适用
结果唯一性 通常不唯一 通常不唯一,唯一性有充要条件
求解的问题类型 顺序排列 约束满足

拓扑排序求解的是约束满足问题。名称中的"排序"指的是把集合排成一个线性序列,而不是按元素大小排列,因此它与快排、归并排序不属于同一算法家族。


三、核心思想

3.1 Kahn 算法:入度归零法

Kahn 算法基于三条观察:

  1. 入度为 0 的顶点可以立即输出。它没有任何未完成的前驱。
  2. 输出一个顶点相当于删除它的全部出边。边被删除后,被它指向的顶点入度减一。
  3. 入度减到 0 的顶点同样可以立即输出。它因此进入候选集合。
graph LR L1[第一层 入度为 0 的顶点] -->|输出并删除其出边| L2[第二层 新的入度为 0 的顶点] L2 -->|重复上述过程| L3[第三层及以后各层] L3 -->|队列清空| Chk{输出个数是否等于顶点数} Chk -- 等于 --> Ret([得到拓扑序]) Chk -- 小于 --> Cyc([图中存在有向环 无拓扑序])

由此得到一条重要性质:Kahn 算法第 k 轮出队的顶点集合,恰好是第 k 层顶点,也就是从任一源点出发最长路径长度为 k - 1 的全部顶点。这一性质使算法输出天然带有"并行批次"的含义,见 9.2。

3.2 DFS 逆后序法

深度优先搜索在顶点 u 的全部出边处理完毕之后才把 u 追加到结果末尾。此时 u 的每一个后继都已经在结果中。把这一序列整体逆序,每个顶点就排在了它的后继之前,也就是排在了它的前驱之后。

graph LR Enter[进入顶点 u] --> Mark[标记 u 已访问] Mark --> Scan[遍历 u 的出边] Scan --> Pick[取下一个后继 w] Pick --> Check{w 是否已访问} Check -- 未访问 --> Rec[递归访问 w 直至收尾] Rec --> Pick Check -- 已访问 --> More{还有未处理的出边} More -- 有 --> Pick More -- 没有 --> Exit[把 u 追加到结果末尾]

三处必须留意:

  • 追加时机:结果在回溯(出栈)阶段追加,而不是进入顶点时。
  • 最外层循环:全部顶点都要被覆盖,否则未与起点连通的源点会被漏掉。
  • 逆后序不等于先序的逆序:先序遍历把顶点放在其子树之前,逆后序把顶点放在其子树之后,正是这一差异保证了前驱在前。

3.3 两种方法的共同本质

两者执行的是同一个过程:从剩余约束中挑出当前没有任何未满足前驱的顶点,追加到结果末尾,然后解除它带来的约束。区别只在于"哪些顶点当前可选"如何确定:

  • Kahn 算法用入度数组实时维护候选集合,入度归零即入队。
  • DFS 逆后序用递归栈的退出时刻隐式维护,退出顺序本身就是候选集合的逆序。

由此可知,两种方法必然给出不同的结果,而不同不等于错误:拓扑序本身一般不唯一,见 5.4。


四、算法流程

4.1 变量约定

符号 类型 含义
G 邻接表 图的表示,G[u] 为 u 的后继顶点列表
indeg 整数数组 各顶点的当前入度,算法过程中被就地修改
q 队列 当前入度为 0 的未输出顶点集合
order 列表 已确定的拓扑序前缀
u 顶点 本轮出队的顶点
v 顶点 u 的某个后继顶点

4.2 Kahn 算法步骤

  1. 统计全部顶点的入度 indeg。
  2. 把入度为 0 的顶点按任意顺序放入队列 q。
  3. 当 q 非空时重复:
    • 取出队首顶点 u,把它追加到 order 末尾;
    • 遍历 u 的每条出边 u → v,执行 indeg[v] -= 1;
    • 若 indeg[v] 变为 0,把 v 加入队列 q。
  4. 队列为空后,若 |order| = |V|,输出 order;否则报告图中存在有向环。

4.3 Kahn 算法流程图

flowchart TD S([开始 统计全部顶点的入度]) --> Init[把入度为 0 的顶点全部入队] Init --> Q{队列是否为空} Q -- 否 --> Pop[出队顶点 u 追加到结果末尾] Pop --> Dec[遍历 u 的每条出边 目标顶点入度减一] Dec --> Zero{该后继入度是否变为 0} Zero -- 是 --> Enq[该后继入队] Zero -- 否 --> Q Enq --> Q Q -- 是 --> Chk{结果个数是否等于顶点数} Chk -- 是 --> Ret([输出拓扑序]) Chk -- 否 --> Cyc([报告图中存在有向环])

4.4 DFS 逆后序步骤与流程图

  1. 把全部顶点标记为未访问,结果列表置空。
  2. 对每个顶点依次检查:若未访问,则从它出发执行深度优先搜索。
  3. 搜索过程:对当前顶点 u,先标记已访问;再遍历 u 的出边,对每个未访问的后继递归调用;全部出边处理完毕后把 u 追加到结果末尾。
  4. 全部顶点处理完毕后,把结果整体逆序。
flowchart TD S([开始 全部顶点标记为未访问]) --> Outer[按编号顺序取下一个顶点 v] Outer --> Check{v 是否已访问} Check -- 是 --> Nxt{是否还有未访问的顶点} Check -- 否 --> Call[从 v 开始深度优先搜索] Call --> Mark[标记 v 已访问] Mark --> Edge[遍历 v 的下一条出边] Edge --> Color{后继 w 的状态} Color -- 灰色 在递归栈中--> Cyc([存在有向环]) Color -- 白色 未访问 --> Call Color -- 黑色 已完成 --> Edge Color --> More{v 是否还有出边} More -- 有 --> Edge More -- 没有 --> App[把 v 追加到结果末尾] App --> Nxt Nxt -- 是 --> Outer Nxt -- 否 --> Rev[把结果整体逆序] Rev --> Ret([输出拓扑序])

4.5 两种方法的对比

对比项 Kahn 算法 DFS 逆后序
核心维护量 入度数组 访问状态数组与递归栈
额外结构 队列 调用栈
是否天然分批 是,出队轮次即并行批次 否
是否便于判环 是,结果长度即判据 需借助访问状态的三色标记
递归深度风险 无,迭代实现 有,最坏可达顶点总数
结果受什么影响 入队顺序 顶点遍历顺序与邻接表顺序
典型用途 构建调度、并行分批、字典序最小 求任意拓扑序、与递归结构相近的代码

五、演算示例

5.1 示例图与初始入度

以一次应用启动流程为背景,构造 9 个动作与 12 条依赖:

动作 直接前置动作 初始入度
读取配置 无 0
解析参数 无 0
连接数据库 无 0
加载插件 读取配置 1
生成代码 读取配置、解析参数 2
编译模块 加载插件、生成代码 2
执行迁移 生成代码、连接数据库 2
注册服务 连接数据库、编译模块、执行迁移 3
启动应用 编译模块、注册服务 2

依赖边共 12 条:读取配置指向加载插件与生成代码;解析参数指向生成代码;连接数据库指向执行迁移与注册服务;加载插件指向编译模块;生成代码指向编译模块与执行迁移;编译模块指向注册服务与启动应用;执行迁移指向注册服务;注册服务指向启动应用。全部入度之和为 12,与边数一致。

5.2 分层与并行批次

偏序关系决定的分层:同一层的动作可以并行执行 箭头由先行动作指向依赖它的后继动作;动作的层数等于它全部前置动作层数的最大值加一 第一层 无前置动作 第二层 前置全在第一层 第三层 前置全在前两层 第四层 前置最晚在第三层 第五层 前置最晚在第四层 读取配置 0 解析参数 0 连接数据库 0 加载插件 1 生成代码 2 编译模块 2 执行迁移 2 注册服务 3 启动应用 2 读图要点 1 层内没有依赖边 同一层的动作互不为前驱后继 交换它们的次序不影响正确性 2 层数由最长路径决定 动作的层数等于其全部前置动 作层数的最大值加一 3 分层即并行批次 第 k 层动作可同时开始执行 这是并行调度的批次单位 4 分层结果唯一 层内排列可以不同 但每层包含哪些动作是固定的 蓝色圆点为该动作的初始入度 实线箭头为依赖方向 ×

图中每一层对应一批可以同时开始执行的动作。层数的计算规则只有一条:一个动作的层数等于它全部前置动作层数的最大值加一,无前置动作的动作属于第一层。按此规则,读取配置、解析参数、连接数据库属于第一层;加载插件与生成代码的全部前置都在第一层,属于第二层;编译模块与执行迁移的全部前置都在前两层,属于第三层;注册服务的最晚前置在第三层,属于第四层;启动应用的最晚前置在第四层,属于第五层。

需要强调两点:

  • 层内顺序无关紧要。同一层的动作之间不存在依赖边,任意交换它们的次序都不影响结果的合法性,这正是拓扑序不唯一的根源。
  • 分层结果唯一。每一层包含哪些动作由图的偏序关系唯一确定,与顶点的编号、邻接表的存储顺序都无关。

5.3 Kahn 算法逐轮演算

Kahn 算法的逐轮演算:入度归零后立即入队 每轮取出队首顶点并追加到结果,随后把它的每个后继的入度减一,减到 0 的后继进入下一轮 状态一 初始入度 统计全部顶点入度 入度为 0 的顶点入队 读取配置 入度 0 解析参数 入度 0 连接数据库 入度 0 加载插件 入度 1 生成代码 入度 2 编译模块 入度 2 执行迁移 入度 2 注册服务 入度 3 启动应用 入度 2 队列 读取配置 解析参数 连接数据库 已输出 尚未开始 状态二 第一轮出队后 输出 读取配置 解析参数 连接数据库 并删除出边 读取配置 已输出 解析参数 已输出 连接数据库 已输出 加载插件 入度 0 生成代码 入度 0 编译模块 入度 2 执行迁移 入度 1 注册服务 入度 2 启动应用 入度 2 队列 加载插件 生成代码 已输出 读取配置 解析参数 连接数据库 状态三 第二轮出队后 输出 加载插件 生成代码 并删除出边 读取配置 已输出 解析参数 已输出 连接数据库 已输出 加载插件 已输出 生成代码 已输出 编译模块 入度 0 执行迁移 入度 0 注册服务 入度 2 启动应用 入度 2 队列 编译模块 执行迁移 已输出 读取配置 解析参数 连接数据库 加载插件 生成代码 状态四 第三轮出队后 输出 编译模块 执行迁移 并删除出边 读取配置 已输出 解析参数 已输出 连接数据库 已输出 加载插件 已输出 生成代码 已输出 编译模块 已输出 执行迁移 已输出 注册服务 入度 0 启动应用 入度 1 队列 注册服务 已输出 读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 状态五 全部输出完成 入队顶点全部输出 算法结束 读取配置 已输出 解析参数 已输出 连接数据库 已输出 加载插件 已输出 生成代码 已输出 编译模块 已输出 执行迁移 已输出 注册服务 已输出 启动应用 已输出 队列已空 9 个顶点全部输出 已输出 读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 注册服务 启动应用 已输出 入度 0 等待入队 入度 1 入度 2 及以上 依赖边 ×

逐轮过程如下:

轮次 出队顶点 受影响的入度变化 新入队顶点 累计结果
1 读取配置 加载插件 1 → 0,生成代码 2 → 1 加载插件 读取配置
1 解析参数 生成代码 1 → 0 生成代码 读取配置 解析参数
1 连接数据库 执行迁移 2 → 1,注册服务 3 → 2 无 读取配置 解析参数 连接数据库
2 加载插件 编译模块 2 → 1 无 追加 加载插件
2 生成代码 编译模块 1 → 0,执行迁移 1 → 0 编译模块、执行迁移 追加 生成代码
3 编译模块 注册服务 2 → 1,启动应用 2 → 1 无 追加 编译模块
3 执行迁移 注册服务 1 → 0 注册服务 追加 执行迁移
4 注册服务 启动应用 1 → 0 启动应用 追加 注册服务
5 启动应用 无后继 无 追加 启动应用

得到拓扑序:

读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 注册服务 启动应用

对照 5.1 的依赖表可以逐条验证:注册服务排在其三个前置之后,启动应用排在其两个前置之后,其余各条约束同样成立。出队轮次依次为 1、1、1、2、2、3、3、4、5,与 5.2 的分层完全一致。

5.4 DFS 逆后序演算

DFS 逆后序:退出次序整体逆序即为拓扑序 节点上方的数字为访问次序,顶点出栈时才追加到序列末尾 读取配置 1 解析参数 8 连接数据库 9 加载插件 2 生成代码 6 编译模块 3 执行迁移 7 注册服务 4 启动应用 5 读图要点 1 蓝色实线为树边 深度优先搜索首次进入顶点 时选定的那些出边 2 灰色虚线为非树边 指向已访问顶点或本次搜索 尚未进入的顶点 3 数字为访问次序 从 1 开始按首次进入顶点 的先后顺序编号 4 退出次序记录收尾 顶点的全部出边处理完毕 后才追加到序列末尾 5 最外层循环补齐源点 解析参数 与 连接数据库 无前驱 在主图遍历完毕后单独访问 退出次序 整体逆序 即拓扑序 启动应用 注册服务 编译模块 加载插件 执行迁移 生成代码 读取配置 解析参数 连接数据库 连接数据库 解析参数 读取配置 生成代码 执行迁移 加载插件 编译模块 注册服务 启动应用 ×

按顶点编号顺序发起搜索,顶点内部按邻接表顺序展开,过程如下:

步骤 动作 说明
1 进入 读取配置 起始顶点
2 进入 加载插件 沿 读取配置 指向 加载插件 的边
3 进入 编译模块 沿 加载插件 指向 编译模块 的边
4 进入 注册服务 沿 编译模块 指向 注册服务 的边
5 进入 启动应用 沿 注册服务 指向 启动应用 的边,无出边随即退出
6 注册服务 退出 出边全部处理完毕
7 编译模块 退出 第二个后继 启动应用 已访问
8 加载插件 退出 唯一出边处理完毕
9 进入 生成代码 回到 读取配置 的第二条出边
10 进入 执行迁移 沿 生成代码 指向 执行迁移 的边
11 执行迁移 退出 唯一出边处理完毕
12 生成代码 退出 出边全部处理完毕
13 读取配置 退出 出边全部处理完毕
14 进入并退出 解析参数 无前驱,由最外层循环发起
15 进入并退出 连接数据库 无前驱,由最外层循环发起

退出次序为:

启动应用 注册服务 编译模块 加载插件 执行迁移 生成代码 读取配置 解析参数 连接数据库

整体逆序后得到拓扑序:

连接数据库 解析参数 读取配置 生成代码 执行迁移 加载插件 编译模块 注册服务 启动应用

把 5.3 与 5.4 的两个结果并列比较可以发现:它们并不相同,但都满足 12 条边的全部约束。不同的合法结果正说明拓扑序一般不唯一,因此程序不应依赖某一个特定顺序。

5.5 含环图的处理

在示例图中补上一条边 注册服务 → 生成代码,使 生成代码 → 执行迁移 → 注册服务 → 生成代码 构成有向环。再次运行 Kahn 算法:

步骤 事件
初始 队列为 读取配置、解析参数、连接数据库
输出 读取配置 加载插件入度归零入队;生成代码入度降为 1
输出 解析参数 生成代码入度归零入队
输出 连接数据库 执行迁移入度降为 1;注册服务入度降为 2
输出 加载插件 编译模块入度降为 1,无法归零
队列清空 累计输出 4 个顶点,剩余 5 个顶点未输出

结果长度为 4,小于顶点数 9,因此报告图中存在有向环。值得注意的是,注册服务虽然有三个前置且其中两个已输出,但来自 生成代码 的入边始终存在,注册服务的入度永远无法归零,环上的顶点以及依赖它们的顶点全部滞留。这是拓扑排序判环的典型形态:参与环的顶点及其全部后继都无法被输出。若需要定位具体是哪些顶点构成环,应改用三色标记或强连通分量算法,见 9.6。

六、复杂度分析

6.1 时间与空间复杂度

阶段 时间 说明
统计入度 O(V + E) 遍历全部顶点与全部边
每个顶点入队出队各一次 O(V) 队列操作摊还 O(1)
每条出边被遍历一次 O(E) 入度减一与判零
合计 O(V + E) 线性时间

空间方面,除邻接表外的额外空间为 O(V):入度数组、队列与结果列表都是顶点规模。DFS 逆后序版本的额外空间同为 O(V),其中递归栈深度最坏可达 |V|。

拓扑排序的耗时只由图的结构决定,与顶点编号的大小无关,因此不存在比较排序的 Ω(n log n) 下界。

6.2 实现细节对常数的影响

细节 不推荐写法 推荐写法 原因
队列操作 list.pop(0) collections.deque().popleft list.pop(0) 每次搬移全部元素,复杂度 O(V)
入度复用 复用被就地修改的入度数组 每次调用重新统计或先做拷贝 第二次调用会得到空结果
图的表示 邻接矩阵 邻接表 邻接矩阵空间 O(V²),稀疏图浪费严重
顶点标识 字符串直接参与运算 整数编号或查表转换 字符串比较与拼接会放大常数
大规模实现 递归式 DFS Kahn 迭代实现 递归深度可能超出栈上限

6.3 与常见排序算法的对比

算法 适用对象 时间复杂度 额外空间 结果是否唯一
拓扑排序 有向无环图的顶点 O(V + E) O(V) 一般不唯一
快速排序 可比较元素数组 平均 O(n log n) O(log n) 不适用
归并排序 可比较元素数组 O(n log n) O(n) 不适用
堆排序 可比较元素数组 O(n log n) O(1) 不适用
基数排序 整数或定长字符串 O(d(n + k)) O(k) 不适用
计数排序 值域受限的整数 O(n + k) O(k) 不适用

七、参考实现

7.1 Python

from collections import deque
import heapq

GRAPH = {
    "读取配置": ["加载插件", "生成代码"],
    "解析参数": ["生成代码"],
    "连接数据库": ["执行迁移", "注册服务"],
    "加载插件": ["编译模块"],
    "生成代码": ["编译模块", "执行迁移"],
    "编译模块": ["注册服务", "启动应用"],
    "执行迁移": ["注册服务"],
    "注册服务": ["启动应用"],
    "启动应用": [],
}
VERTICES = list(GRAPH)

# 顶点标识为整数编号时的对照图:0 指向 1 与 2,1 与 2 都指向 3
NUMBERED = {0: [1, 2], 1: [3], 2: [3], 3: []}

def indegrees(graph, vertices):
    """统计各顶点的入度,不修改传入的图。"""
    indeg = dict.fromkeys(vertices, 0)
    for u in vertices:
        for v in graph[u]:
            indeg[v] += 1
    return indeg

def kahn(graph, vertices):
    """Kahn 算法。存在有向环时返回长度小于顶点数的部分结果。"""
    indeg = indegrees(graph, vertices)
    queue = deque(v for v in vertices if indeg[v] == 0)
    order = []
    while queue:
        u = queue.popleft()
        order.append(u)
        for v in graph[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                queue.append(v)
    return order

def kahn_layers(graph, vertices):
    """分层拓扑。返回列表的列表,同一层内的顶点可并行执行。"""
    indeg = indegrees(graph, vertices)
    queue = deque(v for v in vertices if indeg[v] == 0)
    layers = []
    while queue:
        layer = []
        for _ in range(len(queue)):
            u = queue.popleft()
            layer.append(u)
            for v in graph[u]:
                indeg[v] -= 1
                if indeg[v] == 0:
                    queue.append(v)
        layers.append(layer)
    return layers

def lexicographic_topological(graph, vertices):
    """字典序最小拓扑序:候选集合由队列换成最小堆。"""
    indeg = indegrees(graph, vertices)
    heap = [v for v in vertices if indeg[v] == 0]
    heapq.heapify(heap)
    order = []
    while heap:
        u = heapq.heappop(heap)
        order.append(u)
        for v in graph[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                heapq.heappush(heap, v)
    return order

def dfs_reverse(graph, vertices):
    """DFS 逆后序:退出时追加到结果末尾,最后整体逆序。"""
    WHITE, BLACK = 0, 1
    color = dict.fromkeys(vertices, WHITE)
    order = []

    def visit(u):
        color[u] = BLACK
        for v in graph[u]:
            if color[v] == WHITE:
                visit(v)
        order.append(u)

    for v in vertices:
        if color[v] == WHITE:
            visit(v)
    order.reverse()
    return order

def has_cycle(graph, vertices):
    """三色标记判环:遇到指向递归栈中顶点的边即存在有向环。"""
    WHITE, GRAY, BLACK = 0, 1, 2
    color = dict.fromkeys(vertices, WHITE)

    def visit(u):
        color[u] = GRAY
        for v in graph[u]:
            if color[v] == GRAY:
                return True
            if color[v] == WHITE and visit(v):
                return True
        color[u] = BLACK
        return False

    return any(visit(v) for v in vertices if color[v] == WHITE)

if __name__ == "__main__":
    print("Kahn        ", " ".join(kahn(GRAPH, VERTICES)))
    print("DFS 逆后序  ", " ".join(dfs_reverse(GRAPH, VERTICES)))
    print("中文字典序  ", " ".join(lexicographic_topological(GRAPH, VERTICES)))
    print("编号字典序  ", " ".join(map(str, lexicographic_topological(NUMBERED, sorted(NUMBERED)))))
    for index, layer in enumerate(kahn_layers(GRAPH, VERTICES), 1):
        print(f"第 {index} 层      ", " ".join(layer))

    cycle_graph = {u: list(vs) for u, vs in GRAPH.items()}
    cycle_graph["注册服务"].append("生成代码")
    print("含环时输出长度", len(kahn(cycle_graph, VERTICES)), "顶点数", len(VERTICES))
    print("含环判定    ", has_cycle(cycle_graph, VERTICES))

# 运行结果:
# Kahn         读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 注册服务 启动应用
# DFS 逆后序   连接数据库 解析参数 读取配置 生成代码 执行迁移 加载插件 编译模块 注册服务 启动应用
# 中文字典序   解析参数 读取配置 加载插件 生成代码 编译模块 连接数据库 执行迁移 注册服务 启动应用
# 编号字典序   0 1 2 3
# 第 1 层       读取配置 解析参数 连接数据库
# 第 2 层       加载插件 生成代码
# 第 3 层       编译模块 执行迁移
# 第 4 层       注册服务
# 第 5 层       启动应用
# 含环时输出长度 4 顶点数 9
# 含环判定     True
#
# 结果解读:
# 1. Kahn 与 DFS 逆后序的结果分别与 5.3、5.4 的手工演算一致。两者顺序不同但都满足 12 条边的
#    全部约束,印证拓扑序一般不唯一,程序不应依赖某一个特定顺序。
# 2. 字典序最小拓扑序的比较依据是顶点标识本身。标识为整数编号时,算法每一步都取当前可选顶点中
#    编号最小的一个,对照图输出 0 1 2 3 即是此意;标识为中文标签时,比较依据变为字符的码位顺序,
#    中文字典序那一行的结果不具备直观含义,但合法性不受影响:读取配置 排在 加载插件 与 生成代码 之前,
#    连接数据库 排在 执行迁移 与 注册服务 之前,其余约束同样成立。工程实践中应把该算法与整数编号
#    配合使用。
# 3. 含环时输出长度 4 小于顶点数 9,据此判定图中存在有向环;三色标记进一步确认该结论。

7.2 Java

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

/**
 * Kahn 拓扑排序。存在有向环时返回长度为 0 的数组。
 */
public static int[] topologicalSort(List<List<Integer>> graph) {
    int n = graph.size();
    int[] indegree = new int[n];
    for (int u = 0; u < n; u++) {
        for (int v : graph.get(u)) {
            indegree[v]++;
        }
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int v = 0; v < n; v++) {
        if (indegree[v] == 0) {
            queue.addLast(v);
        }
    }
    int[] order = new int[n];
    int count = 0;
    while (!queue.isEmpty()) {
        int u = queue.pollFirst();
        order[count++] = u;
        for (int v : graph.get(u)) {
            if (--indegree[v] == 0) {
                queue.addLast(v);
            }
        }
    }
    return count == n ? order : new int[0];
}

// 运行结果:以 5.1 的示例图为例,顶点按 读取配置、解析参数、连接数据库、加载插件、生成代码、
// 编译模块、执行迁移、注册服务、启动应用 的顺序编号为 0 到 8,返回的数组为
//   { 0, 1, 2, 3, 4, 5, 6, 7, 8 }
// 即 读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 注册服务 启动应用,
// 与 5.3 的手工演算一致。若图中含有向环,返回长度为 0 的数组。

7.3 C++

#include <vector>
#include <queue>

/**
 * Kahn 拓扑排序。hasCycle 为 true 时 order 中的内容无效。
 * 顶点编号取值范围为 0 到 n-1。
 */
std::vector<int> topologicalSort(const std::vector<std::vector<int>>& graph,
                                 bool& hasCycle) {
    int n = static_cast<int>(graph.size());
    std::vector<int> indegree(n, 0);
    for (int u = 0; u < n; u++) {
        for (int v : graph[u]) {
            indegree[v]++;
        }
    }
    std::queue<int> q;
    for (int v = 0; v < n; v++) {
        if (indegree[v] == 0) {
            q.push(v);
        }
    }
    std::vector<int> order;
    order.reserve(n);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        order.push_back(u);
        for (int v : graph[u]) {
            if (--indegree[v] == 0) {
                q.push(v);
            }
        }
    }
    hasCycle = order.size() != static_cast<size_t>(n);
    return order;
}

// 运行结果:以 5.1 的示例图为例,顶点按 读取配置、解析参数、连接数据库、加载插件、生成代码、
// 编译模块、执行迁移、注册服务、启动应用 的顺序编号为 0 到 8,order 为
//   { 0, 1, 2, 3, 4, 5, 6, 7, 8 }
// 即 读取配置 解析参数 连接数据库 加载插件 生成代码 编译模块 执行迁移 注册服务 启动应用,
// 与 5.3 的手工演算一致。若图中含有向环,hasCycle 被置为 true,order 中的内容无效。

八、易错点与边界

  1. 必须先完整统计入度。边遍历与入队同时进行会把尚未统计到入边的顶点误判为入度 0。
  2. 不能用"已访问"标记代替入度减一。同一顶点可能被多条入边指向,只有入度精确归零时才入队;改用访问标记会导致顶点重复入队,结果中出现重复顶点。
  3. DFS 版本不能省略最外层循环。只从一个顶点出发会漏掉其他连通分量,也会漏掉那些不与该点连通但仍需排序的源点。
  4. 追加结果的时机不能错。DFS 版本必须在回溯时追加;进入顶点时追加得到的是先序遍历的逆序,不能保证前驱在前。
  5. 队列改为栈会改变语义。改成栈后算法仍给出合法拓扑序,但出队轮次不再对应并行批次,"同批可并行"的结论随之失效。
  6. 入度数组会被就地修改。同一张图需要多次排序时必须重新统计,否则第二次得到空结果。
  7. 递归深度可能超出栈上限。DFS 版本在长链图上深度可达 |V|,Python 默认递归上限为 1000,大规模数据应改用 Kahn 算法。
  8. 结果变短只能说明有环,不能指出环的位置。要定位参与环的顶点需改用三色标记或强连通分量算法。
  9. 唯一性不要凭直觉判断。拓扑序唯一当且仅当图中存在覆盖全部顶点的有向路径,判据见 9.5。
  10. 自环最容易被忽略。u → u 会使 u 的入度至少为 1,任何顶点都无法被输出,必须在输入阶段先行排除。
  11. 不要与 DFS 树序、生成树序混淆。拓扑排序只保证有向边的相对顺序,与无向树结构无关。
  12. 无向图没有拓扑序。双向依赖必须用两条方向相反的边分别表达,不能共用一条边。

九、拓展与应用

9.1 字典序最小拓扑序

把 Kahn 算法中的队列换成最小堆,每一轮取出编号最小的候选顶点,即可在全部合法拓扑序中得到字典序最小的一个。时间复杂度为 O(E + V log V),实现见 7.1 的 lexicographic_topological。

典型用途包括:模块加载顺序尽量贴近命名习惯;把一批文件按字典序回放;增量编译时使先处理的模块尽可能靠近目录根。

9.2 分层拓扑与并行调度

Kahn 算法的出队轮次就是并行批次:同一轮被取出的顶点,其全部前置都已在更早的轮次完成,因此可以同时开始执行。实现见 7.1 的 kahn_layers。

调度系统中还有一条实用规则:若某个动作因资源不足暂时无法启动,把它推迟到后续轮次而其余顶点照常推进,不会破坏结果的正确性,因为推迟只增加等待而不违反任何约束。图中存在有向环时,最后一批之后必然残留无法分配的顶点,可据此向用户报告具体的依赖冲突。

9.3 与强连通分量的关系

拓扑排序只能直接用于 DAG。处理一般有向图时,先求强连通分量再缩点,缩点后的图必然无环,此时才能做拓扑排序。

graph LR G1[含环的原图] --> Scc[用 Tarjan 或 Kosaraju 求强连通分量] Scc --> Cond[把每个强连通分量缩成一个顶点] Cond --> Dag[得到有向无环图] Dag --> Topo[对缩点后的图做拓扑排序] Topo --> Order[各强连通分量的一种输出顺序] Order --> Inside[分量内部的次序需另外处理]

分量内部仍可能含环,因此不能直接给出内部顶点的顺序;定位到具体参与环的顶点,需要在分量内部继续用三色标记分析。

9.4 逆拓扑序与资源释放

把反图的边方向全部取反后做拓扑排序,得到的顺序称为逆拓扑序,它同样给出"每个顶点都排在其后继之后"的排列。工程上用于按与申请相反的次序释放资源:注册顺序的逆序就是注销顺序,文件打开顺序的逆序就是关闭顺序。也可以直接把正向拓扑序整体逆序,两种做法结果相同。

9.5 拓扑序唯一性的判定

定理:有向无环图的拓扑序唯一,当且仅当它存在一条经过全部顶点的有向路径。

充分性:路径上每个顶点在图中的唯一前驱就是它在路径上的前一个顶点,因此路径上的次序被唯一确定;由于路径覆盖了全部顶点,整个拓扑序也被唯一确定。

必要性:若拓扑序唯一,则任意时刻入度为 0 的顶点必定恰好只有一个。从唯一的起点出发,每一步的选择都没有分歧,沿途经过的顶点构成一条有向路径;路径终止时若仍有未访问顶点,则这些顶点都还不在路径上,与"无入度为 0 的顶点"矛盾,因此路径覆盖了全部顶点。

实现上的等价判据是:运行 Kahn 算法,若每一轮的队列中都恰好只有一个顶点,则拓扑序唯一;只要某一轮出现两个及以上候选,结果就不唯一。

9.6 其他判环方法

flowchart TD A([对每个未访问顶点发起深度优先搜索]) --> B[把顶点染成灰色 表示在递归栈中] B --> C[取下一条出边] C --> D{后继的颜色} D -- 灰色 --> E([存在有向环]) D -- 白色 --> B2[把后继染成灰色并递归] B2 --> C D -- 黑色 --> F[跳过 该顶点已完成] F --> G{还有出边吗} G -- 有 --> C G -- 没有 --> H[把当前顶点染成黑色 表示已完成] H --> I[返回上一层继续]
方法 适用对象 是否给出环的位置 说明
Kahn 拓扑排序 有向图 否,只报告有环 顺带给出已输出顶点的合法排序
三色标记 有向图 是,能给出全部参与环的顶点 额外空间 O(V)
Tarjan 或 Kosaraju 有向图 是,给出强连通分量 分量大小大于 1 即含环
并查集 无向图 适用于无向图 有向图中并查集无法表达方向约束,不适用

三种有向图判环方法的时间复杂度都是 O(V + E),选择取决于是否需要定位具体的环:只需判断有无环时,拓扑排序的副产品已经足够。


附录

术语中英文对照

中文 英文 含义
拓扑序 topological ordering 满足全部边约束的顶点排列
拓扑排序 topological sort 求拓扑序的算法
有向无环图 directed acyclic graph, DAG 不含有向环的有向图
有向环 directed cycle 形如 u 指向 v 指向 u 的回路
入度 in-degree 指向某顶点的边数
出度 out-degree 从某顶点发出的边数
前驱 predecessor 存在指向该顶点的边的顶点
后继 successor 该顶点指向的顶点
偏序 partial order 只规定部分元素先后的关系
全序 total order 任意两元素都可比较的关系
线性扩展 linear extension 保持偏序约束的排列
邻接表 adjacency list 用后继列表表示图的数据结构
强连通分量 strongly connected component 内部互相可达的最大顶点集合
三色标记 three-color DFS 用三种颜色标记顶点状态判环
逆拓扑序 reverse topological order 每个顶点都排在其后继之后的排列

判环方法对照

判据 结论 适用场景
Kahn 算法输出长度等于顶点数 无环 只需判断有环,并顺带得到一个排序
Kahn 算法输出长度小于顶点数 有环 需要向上游报告无法推进的动作
三色标记遇到灰色后继 有环,且该后继参与环 需要定位冲突的具体顶点
存在大小大于 1 的强连通分量 有环 需要把顶点划分成可整体处理的块
无向图中某条边连接了同一并查集 有环 仅适用于无向图
posted @ 2026-11-07 22:55  RK5123153  阅读(2)  评论(0)    收藏  举报