新学期,教务处的老周接到了一个头疼的任务:有两百多门课程要排进课表,而每门课都带着一张“先修锁”——比如“高等数学”不开,“概率论”就进不了教室。老周不需要按课程编号从大到小排,他只需要保证一条铁律:先修课永远排在后续课之前。这门课换到那门课之后,把约束理清楚,最后排出一份不冲突的课表——这套“在依赖约束下排出线性顺序”的方法,就是拓扑排序。可如果某几门课互相把对方设为先修(A要先修B,B要先修C,C又要求先修A),老周就会陷入死循环,怎么也排不完,这就是环检测要抓的“僵局”。选课系统背后、编译器里、AI 训练流水线中,处处都有这张“依赖网”。本文就借老周排课表的故事,把拓扑排序的两种主流做法(Kahn 队列法与 DFS 后序法)以及环检测讲透,让“依赖管理”变得像排课表一样自然。

岗位关联

对 AI 从业者而言,拓扑排序与环检测是构建数据流水线、任务编排与 DAG 调度系统的底层直觉,理解它,你就拥有了“把复杂依赖梳理成清晰顺序”的工程思维。

目录
  1. 可视化演示
  2. 拓扑排序定义
  3. 有向无环图
  4. Kahn算法
  5. DFS后序逆序
  6. 环检测判定
  7. 应用场景
  8. 算法对比与选型
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Kahn算法入度统计BFS排序、DFS后序逆序拓扑排序、环检测与DAG判定三种核心图算法。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Kahn\u7b97\u6cd5\uff1a\u5165\u5ea6BFS\u62d3\u6251\u6392\u5e8f \u5165\u5ea6\u4e3a0\u7684\u8282\u70b9\u5165\u961f\uff0c\u5904\u7406\u540e\u90bb\u63a5\u70b9\u5165\u5ea6\u51cf1 \u5904\u7406\u987a\u5e8f\u5373\u4e3a\u62d3\u6251\u5e8f\uff0c\u5168\u90e8\u5904\u7406\u5b8c\u6210=DAG indeg(v) = |{(u,v) \u2208 E}| \u961f\u5217\u9a71\u52a8\u7684BFS\u62d3\u6251\u6392\u5e8f\uff0c\u65f6\u590d\u6742\u5ea6O(V+E)

拓扑排序定义

老周把课程表摊开,上面密密麻麻画着箭头:“数学→概率论”表示要修完数学才能修概率论。他心里真正想找的,不是哪门课编号小先上,而是一份“顺着箭头方向从头排到尾”的名册。只要箭头从名册前面指向后面,任何一门课的先修课就都在它之前,学生可以安心照着名册学习——这份名册,就是计算机科学里说的拓扑排序。

拓扑排序的数学定义非常简单:对一个有向无环图(DAG)的所有顶点排序,使得对每一条有向边 (u, v),顶点 u 都排在顶点 v 之前。要注意,它排序的依据不是数值大小,而是“依赖关系”。同一张图可能排出多份合法名册,比如两门课都只需要先修数学,那它们谁前谁后都说得通。结果不唯一,恰恰说明拓扑排序描述的是“约束是否被满足”。

技术实现时,我们把每个任务抽象成顶点,任务间的依赖关系抽象成有向边。入度为 0 的顶点是“没有先修课”的任务,它的位置可以很靠前;出度为 0 的顶点是“不阻碍任何任务”的任务,它的位置可以很靠后。算法要做的,就是不断选择当前无入边的顶点输出,直到所有顶点排完。

老周盯着这张公式念叨:“箭头从前往后一直在顺,这就是我要的那份课表。”他一下子理解了,拓扑排序并不神秘——它就是给一张“先修网”找一个不违背任何箭头的直线排列。

∀(u,v)∈E: pos(u)<pos(v)\displaystyle \forall (u, v) \in E:\ \text{pos}(u) < \text{pos}(v)
这个公式说的是:图中每一条箭头 (u, v),都要求起点 u 在序列中的位置比终点 v 更靠前。
  1. 位置函数 pospos 返回顶点在输出序列中的下标,比如第一名的位置是 1。pos(i)=顶点 i 在序列中的序号\displaystyle \text{pos}(i) = \text{顶点 } i \text{ 在序列中的序号}
  2. 遍历所有边符号 ∀(u,v)∈E 表示“对图中每一条有向边都成立”。∀(u,v)∈E\displaystyle \forall (u, v) \in E
  3. 起点排在终点前把约束落实到序号的大小关系上。pos(u)<pos(v)\displaystyle \text{pos}(u) < \text{pos}(v)

有向无环图

老周在某门课程边上画了一个圈:图像处理要修完机器学习,机器学习要修完线性代数,线性代数居然又要求先修图像处理。他绕着圈走了三遍,发现这是一个死结——任何一门课都有一门课堵在它前面。这个死结在数学里有一个朴素名字:有向环。而一张没有任何有向环的有向图,就叫有向无环图(DAG)。

DAG 是拓扑排序的“准入证”。为什么?因为如果存在环,环上的每个顶点都有一条入边来自环内,没有任何一个顶点能成为“没有先修课”的起点,于是排序永远无法完整进行。反过来,只要保证无环,那么从数学上可以证明:这张图一定存在至少一个拓扑排序。这个“存在性”保证让所有依赖系统都放心大胆地去排——先把 DAG 建好,剩下的交给算法。

从更抽象的视角看,DAG 描述的是“偏序关系”:顶点之间只对部分对象定义了顺序(有箭头的才要求先后)。拓扑排序是把偏序关系扩展成“全序关系”的过程,术语叫线性扩展。这种图论模型在许多 AI 系统中都很常见,比如神经网络计算图、数据血缘图,往往都要求是 DAG。

老周把这句话抄在办公室墙上:“无环则有序,有序必无环。”以后他看到一张依赖图,第一反应永远是先找有没有圈出来的死结。

存在拓扑序  ⟺  G 是DAG\displaystyle \text{存在拓扑序} \iff G \text{ 是DAG}
这是整个领域的“定心丸”:只有无环的有向图才能保证拓扑排序有解,有环则必然无解。
  1. 充分性方向只要图是 DAG,就一定有至少一个拓扑序列。图无环⇒∃ 合法拓扑序列\displaystyle \text{图无环} \Rightarrow \exists\, \text{合法拓扑序列}
  2. 必要性方向如果存在合法拓扑序列,那么图中必无有向环。∃ 合法拓扑序列⇒图无环\displaystyle \exists\, \text{合法拓扑序列} \Rightarrow \text{图无环}
  3. 双向等价两个条件互为充要条件。P  ⟺  Q\displaystyle P \iff Q

Kahn算法

老周想出一个土办法:既然入度为 0 的课没人拦着,那就先把它们全部安排到课表里;上完一门课,就把它指向的所有“先修锁”都打开一把。比如数学上完,“概率论”和“算法设计”的先修要求各自减一;当某一门课的锁全部打开(入度归零),它就成为下一轮可以安排的候选。他准备了一个篮子,专门装这些“解锁课”。

这个土办法在算法课本上叫 Kahn 算法。它先统计每个顶点的入度,把所有入度为 0 的顶点放进队列;然后反复从队列中取出一个顶点输出,并遍历它的邻接表,把每个后继顶点的入度减 1,一旦减到 0 就立即入队。整个过程不需要回溯,也不需要递归,只要用一个普通队列就能完成,非常符合直觉。

Kahn 算法有个妙用:如果最终输出的顶点数小于图中的顶点总数,说明队列已经空了但还有顶点没被排进去,这些残留顶点一定形成了一个环。换句话说,Kahn 算法在排序的同时,顺手就完成了环检测,这也是工程系统里最常用的判环方式。

老周举着篮子笑了:“我根本没在想什么高深算法,只是把'上完课解锁下一门'这个动作循环做到底。”Kahn 算法的美,正在于它把生活中的排队直觉翻译成了严谨的图算法。

indeg[v]←indeg[v]−1,∀v∈adj[u]\displaystyle \text{indeg}[v] \leftarrow \text{indeg}[v] - 1,\quad \forall v \in \text{adj}[u]
这是 Kahn 算法的核心动作:当顶点 u 被输出后,它所有后继 v 的入度都减 1,相当于“拔掉一把先修锁”。
  1. 入度数组 indegindeg[v] 表示还有多少门先修课没修完,也就是指向 v 的边数。indeg[v]=∣{(u,v)∈E}∣\displaystyle \text{indeg}[v] = \left|\{(u, v) \in E\}\right|
  2. 邻接表 adjadj[u] 保存 u 的所有直接后继,代表“u 上完后能解锁哪些课”。adj[u]={v∣(u,v)∈E}\displaystyle \text{adj}[u] = \{v \mid (u, v) \in E\}
  3. 输出后的连锁更新顶点 u 被处理,所有后继的入度减 1,归零者入队。indeg[v]←indeg[v]−1\displaystyle \text{indeg}[v] \leftarrow \text{indeg}[v] - 1

DFS后序逆序

老周的同事李老师提出了另一个思路:“我不按先修数量来,我只沿着箭头一直往后走,走到没路可走就回来。”比如从“数学”出发,走到“物理”,再走到“电子”,电子没有后继了,他就在便签上写下“电子”;回到“物理”后没别的可走,写下“物理”;再回到“数学”,把剩下的“算法”也探索完,最后写下“数学”。他手中的便签这样按“完成时间”记录,得到的是后序序列。

深度优先搜索(DFS)的做法正是如此:从某个未访问过的顶点出发,递归地访问它的后继,等所有后继都访问完毕后,再将当前顶点加入一个结果栈。因为“父顶点的完成后序”一定晚于“子顶点的完成后序”,所以把后序序列整体反转,父顶点就会出现在所有子顶点之前——这正是拓扑排序要求的箭头方向。

在这个过程里,DFS 还要维护一个三色标记:白色表示未访问,灰色表示正在递归栈中,黑色表示已处理完。如果在遍历时撞上一个灰色顶点,说明从它出发又绕回了它自己,此刻立刻可以断定:图中存在有向环。这种检测手段与 Kahn 计数法互为镜像,一个在“出队数量”上做文章,一个在“递归路径”上设关卡。

李老师把便签一字排开,发现倒着念就是一份合法课表。老周恍然大悟:看似截然不同的方法,其实都是为了让“箭头的起点永远在终点之前”,只是走路的方向恰好相反。

result=reverse(postorder(G))\displaystyle \text{result} = \text{reverse}(\text{postorder}(G))
DFS 后序序列里父节点总在子节点之后,反转之后父节点跳到子节点前面,从而得到合法拓扑序。
  1. 后序序列 postorder每个顶点在它的所有后继都记完之后才被记录。post(u)=u 的所有后继都记完后再记 u\displaystyle \text{post}(u) = \text{u 的所有后继都记完后再记 u}
  2. 反转操作 reverse把数组元素按倒序重新排列。reverse([a1,a2,…,an])=[an,…,a2,a1]\displaystyle \text{reverse}([a_1, a_2, \dots, a_n]) = [a_n, \dots, a_2, a_1]
  3. 反转后满足拓扑约束后序中父节点靠后,反转后父节点靠前。posrev(u)<posrev(v), ∀(u,v)∈E\displaystyle \text{pos}_\text{rev}(u) < \text{pos}_\text{rev}(v),\ \forall (u,v) \in E

环检测判定

老周发现,当课程之间形成环时,无论 Kahn 算法还是 DFS 都会“卡壳”。用 Kahn 算法时,队列越弹越空,可总有三门课永远入不了队,因为它们的入度始终至少有 1;用 DFS 时,递归走到一半,猛然发现自己访问了一个“正在访问中”的顶点,就像在迷宫里绕回原点。这两种症状,就是有向环的“指纹”。

在实现层面,Kahn 法的判环条件极为简洁:记录实际输出的顶点数量,当队列变空而结果长度小于顶点总数时,必有环。DFS 法则在递归函数入口检查状态:若发现某个后继仍处于“灰色”状态,立即抛异常。二者选其一,都能提前拦截配置错误。

边界情况也值得留意:单个顶点自环(一条从自身指向自身的边)会让入度永远无法归零;多个连通分量的 DAG 则各自独立排序,只需把它们拼接起来即可,环检测不受连通性影响。

老周把这个判定写进排课系统里:只要出现“还有课排不进课表”,立刻弹窗提示“存在循环先修”。他长舒一口气——再复杂的依赖网,也逃不过这双法眼。

∣result∣<∣V∣  ⟹  G 含有向环\displaystyle |\text{result}| < |V| \implies G \text{ 含有向环}
Kahn 算法输出的顶点数量如果少于图中顶点总数,说明有顶点永远无法入队,这正是环存在的证据。
  1. 已输出顶点数result 是已经成功排入课表的顶点集合。∣result∣\displaystyle |\text{result}|
  2. 总顶点数V 是图中的全部顶点集合。∣V∣\displaystyle |V|
  3. 长度不足即判环队列空但还没排完,必然存在环。∣result∣<∣V∣\displaystyle |\text{result}| < |V|

应用场景

老周后来发现,“排课表”这件事几乎无处不在。编译器要决定先编译哪个源文件,就得看头文件的依赖关系;AI 训练流水线要先做数据清洗、再做特征工程、最后才喂给模型训练;连安装软件时的包管理器,也要按依赖顺序把各个库装好。这些场景本质上都是一张 DAG:任务作顶点,依赖作边。

在编译领域,Make 和 Bazel 等构建系统会把源文件间的依赖关系建模成 DAG,用拓扑排序得到一个合法的构建顺序,若出现循环依赖就直接报错。在任务调度领域,Airflow、Spark 等 DAG 调度器则用拓扑序决定 Stage 的执行先后,保证上游任务完成后下游任务才启动,每个任务的开始时间必须满足依赖约束。

AI 工程中常见的“数据血缘”也是典型的 DAG 应用:一份特征表依赖多张原始表,一个模型依赖若干特征表。平台每天用拓扑排序生成调度计划,一旦发现血缘成环,就说明数据链路配置出了问题,需要人工干预。

老周感叹,自己排课表多年的经验,其实就是工程师们每天都在做的事。只要把世界抽象成“顶点 + 边”,拓扑排序就是那把万能钥匙。

∀(u,v)∈E: start(u)<start(v)\displaystyle \forall (u, v) \in E:\ \text{start}(u) < \text{start}(v)
在任务调度里,依赖任务 u 的开始时间必须早于任务 v,拓扑排序恰好保证这条规则对全图成立。
  1. 任务开始时间start(i) 表示第 i 个任务的启动时刻。start(i)\displaystyle \text{start}(i)
  2. 每一条依赖边对任意一条边 (u, v) 逐一检查。∀(u,v)∈E\displaystyle \forall (u, v) \in E
  3. 依赖先执行u 必须先于 v 开始。start(u)<start(v)\displaystyle \text{start}(u) < \text{start}(v)

算法对比与选型

派课现场两位老师各有一套本领:Kahn 算法像一位后勤管家,捧着入度清单,谁的门槛最低谁先进场,顺序完全由“解放进度”决定;DFS 后序法像一位巡查员,沿着箭头一路探险,走到死路再回头,用“回溯时间”说话。二者结果都是合法拓扑序,但性格截然不同。

从复杂度看,两者的运行时间都是 O(|V| + |E|),因为每个顶点和每条边都只被处理一次。选型时主要看工程需求:如果系统需要随时了解“当前有哪些任务可以并行执行”,Kahn 的队列天然提供了这个集合;如果希望单次 DFS 同时完成判环和排序,DFS 后序法则更顺手。Kahn 的迭代写法避免了递归栈溢出风险,适合顶点规模极大的图。

边界情况也要心里有数:空图直接返回空序列;单节点图一步完成;重复边不影响正确性但可能让入度重复减扣,实现时需去重;非连通图则把各连通分量依次处理,最终拼接即可。在扩展方向上,基于 Kha 的“字典序最小拓扑序”只需把队列换成优先队列;而 DAG 最长路(关键路径)则常配合拓扑序做动态规划求解。

老周最后总结:选 Kahn 还是 DFS,就像挑顺手的工具,没有高下之分。理解了它们都在维护“箭头方向”这一共同目标,看到任何依赖图都能从容落笔。

T(∣V∣,∣E∣)=O(∣V∣+∣E∣)\displaystyle T(|V|, |E|) = O(|V| + |E|)
无论 Kahn 还是 DFS,拓扑排序的时间复杂度都是线性的:每个顶点处理一次,每条边处理一次。
  1. 顶点与边的规模V 是顶点集合,E 是边集合。V={顶点}, E={边}\displaystyle V = \{\text{顶点}\},\ E = \{\text{边}\}
  2. 每个顶点只处理一次入队出队或访问标记各一次。O(∣V∣)\displaystyle O(|V|)
  3. 每条边只扫描一次邻接表遍历的开销与边数成正比。O(∣E∣)\displaystyle O(|E|)

AI 应用场景

AI 训练流水线的 DAG 调度

阶段:任务编排与调度(MLOps 平台)

解决的问题:传统 ETL 流水线靠人工按固定脚本串行执行,一旦上游特征表更新,下游训练任务的启动时机难以把控;如果任务间存在循环依赖,还会导致死等待。AI 平台将每个数据处理任务和训练任务抽象成 DAG 顶点,用拓扑排序计算可执行顺序,让每个任务严格等上游完成后启动,并在排不齐时自动报警,避免了人工排查依赖黑洞。

具体案例:某推荐团队的特征平台中,“日志清洗→特征聚合→样本拼接→模型训练”四个任务存在递进依赖。调度器每天早上通过拓扑排序生成运行序列;某次配置误将“样本拼接”设为“日志清洗”的先修,形成环,Kahn 算法检测出输出顶点数不足,直接拒绝发布该配置,保护了生产链路。

编译器与构建系统的依赖顺序

阶段:模型服务打包与部署(代码构建)

解决的问题:大型 AI 服务的代码仓库包含上百个模块,头文件、库文件互相引用。人工指定编译顺序极易踩到“未定义的引用”错误。编译器后端利用依赖图进行拓扑排序,自动确定先编译哪个源文件、后链接哪个库,并识别出循环 include 导致的环。

具体案例:在某次模型推理服务的 CI 流水线中,Bazel 构建系统通过拓扑排序快速定位出两个模块互相依赖的死锁配置,开发者按报错信息拆分了公共模块,构建时间从 40 分钟降到 12 分钟。

知识库课程先修校验

阶段:智能学习系统的内容治理

解决的问题:在线教育平台用知识图谱组织课程,每条“先修关系”就像排课表里的箭头。若编辑误把课程 A 设为 B 的先修,又把 B 设为 A 的先修,学生学习时会陷入两难。平台用环检测算法定期扫描知识图谱,标记出所有循环先修关系,方便内容团队及时修正。

具体案例:某职业教育平台上线新算法课程时,图谱出现“机器学习↔深度学习”循环先修,系统自动发出告警并与门推荐调整方案,将深度学习改为机器学习的后续课程,学生路径重新变得清晰。

Python 演示

python61 行
from collections import deque  def kahn_topo(num_nodes, edges):    """Kahn算法:返回拓扑序;若存在环则抛出异常。"""    adj = [[] for _ in range(num_nodes)]    indeg = [0] * num_nodes    for u, v in edges:        adj[u].append(v)        indeg[v] += 1     queue = deque([i for i in range(num_nodes) if indeg[i] == 0])    result = []    while queue:        u = queue.popleft()        result.append(u)        for v in adj[u]:            indeg[v] -= 1            if indeg[v] == 0:                queue.append(v)     if len(result) != num_nodes:        raise ValueError("图中存在环,无法完成拓扑排序")    return result  def dfs_topo(num_nodes, edges):    """DFS后序逆序:同样返回拓扑序并检测环。"""    adj = [[] for _ in range(num_nodes)]    for u, v in edges:        adj[u].append(v)     visited = [0] * num_nodes  # 0未访问, 1访问中, 2已完成    postorder = []     def dfs(u):        if visited[u] == 1:            raise ValueError("图中存在环")        if visited[u] == 2:            return        visited[u] = 1        for v in adj[u]:            dfs(v)        visited[u] = 2        postorder.append(u)     for i in range(num_nodes):        if visited[i] == 0:            dfs(i)    return postorder[::-1]  if __name__ == "__main__":    edges = [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4)]    print("Kahn拓扑序:", kahn_topo(5, edges))    print("DFS拓扑序:", dfs_topo(5, edges))     try:        kahn_topo(3, [(0, 1), (1, 2), (2, 0)])    except ValueError as e:        print("环检测:", e)
目的:用 Python 实现 Kahn 算法与 DFS 后序逆序两种拓扑排序方案,并利用“输出顶点数不足”和“递归访问中顶点”分别完成环检测,帮助读者直观对比两种范式。
  1. indeg = [0] * num_nodes初始化入度数组,记录每个顶点还有几条边指向它,也就是“几门先修未修完”。
  2. queue = deque([i for i in range(num_nodes) if indeg[i] == 0])把所有“没有先修课”的顶点放进队列,它们是首批可输出任务。
  3. u = queue.popleft()从队首取出一门“解锁课”,类似老周从篮子里拿出下一门可排的课。
  4. indeg[v] -= 1当前课程上完,解锁后续课程,把它的一把“先修锁”打开。
  5. if len(result) != num_nodesKahn 法环检测的关键:队列弹空却没排完,说明有环残留。
  6. visited[u] == 1DFS 撞上“正在递归中”的顶点,说明沿箭头绕回了自己,立即判定环存在。
  7. return postorder[::-1]关键一步:后序序列整体反转,父节点才跑到子节点前面,得到合法拓扑序。

常见误区

误区我刚学拓扑排序的时候觉得,一个图既然能排,那就应该只有一种标准排列才对,就像从小到大排序那样固定唯一。课上讲的算法每次弹出一个顶点,弹完就结束了,所以结果应该是唯一的。

正解拓扑排序结果的唯一性只在“任意时刻都只有一个入度为 0 的顶点”时成立,而绝大多数 DAG 在算法过程中会同时出现多个入度为 0 的顶点,选择哪一个输出都会得到不同但同样合法的序列。就像老周面对数学、英语都没先修课时,数学先排还是英语先排都合理,“唯一性”是特例,“多解”才是常态。

为何常见:人们容易把拓扑排序类比成数值排序,潜意识里认为“排序就该有唯一答案”,忽略了拓扑排序只关心依赖约束是否被满足,不关心并列任务之间的绝对先后。

误区有人认为只要在环上随便选一个顶点先输出,再顺着环输出其余顶点,也能得到一个看起来有头有尾的序列,所以环并不是什么大问题。

正解环上每个顶点的入度都至少为 1,没有任何顶点能成为“无先修课”的合法起点。就算人为挑一个顶点先输出,也必然违反它对应的某条先修边。拓扑排序的定义要求所有边都满足 pos(u) < pos(v),环上永远有一条边会反向,所以“含环图可拓扑排序”在数学上不成立。

为何常见:初学者看到的是“点的先后顺序”,容易忽略“边的方向约束”,误以为只要能连成一条线就是拓扑序,实际上那条线必须完全顺着箭头方向。

误区有人看到 DFS 方法里大量谈论“访问中撞到灰色顶点”的判环逻辑,就认为 DFS 的定位是“环检测工具”,而真正做拓扑排序只能用 Kahn 算法,后序逆序顶多算一种副产品。

正解DFS 后序逆序本身就是一套完整且正确的拓扑排序算法。它的正确性来自一个事实:在 DFS 中,父顶点的后序位置一定晚于所有子顶点,反转后父顶点就跑到子顶点之前,恰好满足每条边起点在终点前的约束。它和 Kahn 算法是等价的两种实现,只是思考角度一个是“消除入度”,一个是“记录完成时间”。

为何常见:因为 DFS 讲解常常把判环放在最前面,而把逆序输出一笔带过,学习者于是把主次关系弄反了:判环只是顺手之举,逆序输出才是它的排序主张。

误区Kahn 算法里入度为 0 的顶点用什么容器装似乎无所谓,反正弹出的都是合法顶点,所以有的同学喜欢把队列换成栈来写,认为这样代码更简洁,结果也一样。

正解换成栈得到的仍然是一个合法拓扑序列,但顺序会完全不同——队列是“先进先出”的横向展开风格,栈是“后进先出”的深度优先风格。如果题目额外要求“字典序最小的拓扑序”,就必须使用优先队列,栈和普通队列都无法保证。容器选择决定了并列顶点之间的相对顺序,所以并不“没有区别”。

为何常见:拓扑排序的正确性只要求满足依赖约束,这让很多人在初学时忽略了“并列顶点的顺序”也是一个值得控制的设计维度,直到遇到字典序或层次化输出需求时才意识到容器的重要性。

面试题

Q1. 拓扑排序适用于哪种类型的图?

单选题 · easy

  • A.无向连通图
  • B.有向无环图(DAG)← 正确答案
  • C.任何有向图
  • D.带权重的有向图

答案:B。拓扑排序要求每条边 (u, v) 都让 u 排在 v 之前,只有无环的有向图才能保证这一点。有环图中环上顶点互相依赖,无法排出合法序列;无向图没有方向概念,更无从谈起。带权图并不会自动消除环,所以也不是充分条件。就像老周只有在一张没有死结的课程依赖图上才能排出完整课表。

Q2. Kahn 算法每次从队列中取出的是哪种顶点?

单选题 · easy

  • A.出度为 0 的顶点
  • B.入度最大的顶点
  • C.入度为 0 的顶点← 正确答案
  • D.边数最少的顶点

答案:C。Kahn 算法的核心思想是“没有先修课的先上”,入度为 0 表示没有任何边指向它,也就是没有前置依赖。出度为 0 的顶点是“不阻碍别人”的顶点,应该尽量靠后而不是靠前弹出。入度最大的顶点恰恰是“被最多先修课拦住”的顶点,最不可能先弹出。

Q3. 对一个有 n 个顶点、m 条边的 DAG 执行 Kahn 拓扑排序,时间复杂度是?

单选题 · mid

  • A.O(n)
  • B.O(n²)
  • C.O(n + m)← 正确答案
  • D.O(n × m)

答案:C。Kahn 算法中每个顶点入队一次、出队一次,耗时 O(n);每条边在顶点弹出时被扫描一次,耗时 O(m),总复杂度为 O(n + m)。这个线性复杂度使得拓扑排序非常适合处理大规模依赖图。O(n) 只计算了顶点忽略了边,O(n²) 和 O(n×m) 则是把遍历边的方式估计过重了。

Q4. 以下哪些方法可以有效检测有向图中是否存在环?

多选题 · mid

  • A.Kahn 算法统计输出顶点数← 正确答案
  • B.DFS 递归中检测“访问中”的顶点← 正确答案
  • C.计算所有顶点的入度之和
  • D.用 BFS 求最短路距离

答案:AB。Kahn 算法一旦发现输出顶点数少于总顶点数,就能断定存在环;DFS 中如果访问到正在递归栈中的顶点(灰色顶点),也说明绕回了自身。入度之和只是顶点入度的简单相加,与环的存在性没有必然关系;BFS 最短路并不能直接判定有向环,只能用于加权图的最短路问题。两种判环法分别对应“计数式”和“路径式”的思路。

Q5. 为什么 DFS 拓扑排序需要对后序序列进行逆序输出?

单选题 · mid

  • A.因为栈的特性是后进先出
  • B.因为后序中父节点在所有子孙之后,逆序后父节点才能排到子孙之前← 正确答案
  • C.因为递归函数必须返回倒序结果
  • D.不需要逆序,后序本身就是拓扑序

答案:B。DFS 的后序序列中,一个顶点要等它的所有后继都访问完才会被记录,所以父节点一定排在子孙之后。为了满足“边 (u,v) 中 u 在 v 之前”的拓扑约束,必须把后序整体反转,让父节点跳到子孙前面。栈的 LIFO 特性只是实现载体,不是逆序的根本原因;后序序列直接使用则会把父子顺序排反。

Q6. 在何种情况下,一张 DAG 的拓扑排序结果可能不唯一?

多选题 · hard

  • A.算法运行过程中存在多个入度为 0 的顶点← 正确答案
  • B.图包含多个互不连通的连通分量← 正确答案
  • C.图中边的数量远大于顶点数量
  • D.所有顶点排成一条链式依赖

答案:AB。拓扑排序只约束有边相连的顶点对,对没有直接依赖的顶点不规定先后。当多个入度为 0 的顶点同时候选时,选择顺序不同就会产生不同合法序列;多个连通分量之间的排序更是完全自由的。边多并不意味着并列选择多,链式依赖(每个时刻只有一个入度为 0 的顶点)反而会使结果唯一。

Q7. 在编译构建系统中,拓扑排序主要用于解决什么问题?

单选题 · mid

  • A.决定内存分配策略
  • B.确定源文件按依赖顺序进行编译← 正确答案
  • C.优化网络请求速度
  • D.压缩静态资源体积

答案:B。头文件与源文件之间存在“先编译依赖”关系,构建工具需要先编译被依赖的模块,再编译依赖它的模块,这正是拓扑排序的应用场景。内存分配、网络请求和资源压缩属于运行时或优化层的其他问题,与依赖顺序无关。

Q8. Kahn 算法检测到有向环的充要条件是什么?

单选题 · hard

  • A.队列为空时 result 的长度恰好等于顶点数
  • B.队列为空时 result 的长度小于顶点数← 正确答案
  • C.某个顶点的入度出现了负数
  • D.图中存在多个入度为 0 的顶点

答案:B。Kahn 算法只有在整个队列清空后才停止,如果此时输出的顶点数少于总顶点数,说明剩下每个顶点的入度都至少为 1,它们彼此之间形成了环。length 等于顶点数表示排序成功完成;入度出现负数通常是代码 bug 而不是判环条件;多个入度为 0 的顶点反而说明图更“健康”,与环无关。

Q9. 以下哪些是拓扑排序的典型应用场景?

多选题 · easy

  • A.课程先修关系排课← 正确答案
  • B.分布式任务调度← 正确答案
  • C.求无向图的最短路径
  • D.判断图是否连通

答案:AB。课程排课是拓扑排序最经典的比喻式应用,把所有课程按先修约束排成顺序;分布式任务调度则把任务看成顶点、依赖看成边,确定执行先后。最短路问题属于加权路径优化,通篇和“依赖排序”无关;判断图连通可以借助并查集或 DFS 遍历,但不需要拓扑排序介入。

Q10. 在 Kahn 算法中,若把普通队列替换成栈(后进先出),最终输出的序列会怎样?

单选题 · extreme

  • A.一定不是合法拓扑序
  • B.结果必然和原队列结果相同
  • C.得到另一个合法拓扑序← 正确答案
  • D.必然触发环检测失败

答案:C。栈替换队列后,每次弹出的是“最新加入”的入度为 0 顶点,弹出的顶点集合并没有改变——每个顶点仍然是在其所有先修都被移除后才被弹出,所以结果仍然是合法拓扑序。合法序列不一定唯一,因此与队列结果不同不代表错误。这正说明容器的选择只会影响并列元素的输出顺序,不会破坏拓扑约束。