Evanalysis
5.4预计阅读时间: 13 分钟

5.4 DAG 的拓扑排序

使用 Kahn 算法与 DFS 构造拓扑序,证明正确性、检测有向环,并从邻接表推导 O(V+E) 时间。

课程目录

动机

很多调度问题并不要求一个数值最优解,而是要找到任意一个合法顺序。修读 某门课程前要先完成所有先修课;编译某个模块前要先处理它依赖的模块;穿鞋前 要先穿袜子。有些任务之间没有依赖,它们的先后次序就可以交换。

有向图恰好能够表达这种约束:顶点代表任务,边 u->v 表示 u 必须排在 v 前面。拓扑排序把每条局部先后关系整合成一个全局线性序列。我们需要回答 四个问题:顺序何时存在、为什么可能不唯一、怎样高效构造,以及算法失败时 如何确认图中存在有向环。

Tutorial 10 给出两个互补视角。Kahn 算法从“全部前置条件已经满足”的 顶点开始向前构造;DFS 方法等到顶点完成后才将它放进答案,利用反向完成 次序得到结果。两者不只是排序程序,也都能检测有向环。

实际系统往往会把输出顺序交给下一个处理阶段,因此漏掉顶点的部分序列并不 是小的格式问题,而是一个无法执行的错误调度。

定义

定义

有向无环图

有向图 G=(V,E) 的每条边都是有顺序的二元组 (u,v)。如果存在 v_0->v_1->...->v_k->v_0,图中就有一个有向环。没有任何有向环的有向图 称为有向无环图(directed acyclic graph,DAG)。

方向不能忽略。u->v 是从 u 指向 v 的单向约束,并不自动包含反方向。 因此,无向图中的环与这里讨论的有向环不是同一个概念。

定义

拓扑序

G=(V,E) 的拓扑序是一个恰好包含每个顶点一次的序列,而且对于每条边 u->vu 都出现在 v 之前。如果 pos(x) 表示位置,那么每条边都必须 让 pos(u) 严格小于 pos(v)

拓扑序不一定唯一。如果 xy 之间不存在任一方向的可达关系,图可能没 有规定它们谁先谁后。算法遇到多个同时合法的选择时,不同的 tie-breaking 会 产生不同但同样正确的答案。拓扑排序寻找的是满足约束的一个顺序,而不是必然 唯一的顺序。

定义

入度、源点与邻接表

indegree[v] 是进入 v 的边数;入度为零的顶点称为源点。邻接表 adj[u] 只列出所有由 u 指向的后继顶点。

两种算法都需要从已处理顶点沿出边前进,所以邻接表比每次扫描邻接矩阵的一整 行更合适。DFS 检测环时还要区分三种状态:未标记、位于当前递归路径上的临时 标记,以及连同全部后继都已完成的永久标记。遇到临时标记意味着一条边返回 当前路径上的祖先,从而闭合为有向环。

定理 / 命题

定理

拓扑序存在当且仅当图是 DAG

有向图如果包含有向环,就不存在拓扑序;反过来,每个有限 DAG 都至少有一个 拓扑序。

关键结构事实是:每个非空有限 DAG 都有源点。假如每个顶点都有入边,任选一 点并不断沿入边反向前进;由于顶点数有限,最终一定重复某个顶点并形成有向 环。因此 DAG 必然有一个可以安全放在首位的源点;移除它后剩余图仍是 DAG, 于是可以重复同一论证。

定理

Kahn 算法的处理数判据

Kahn 算法输出全部 |V| 个顶点,当且仅当输入图是 DAG。如果零入度容器已 空,但输出数 k 小于 |V|,未处理子图中必然存在有向环。

所以实现不能把部分序列当作成功结果;必须比较已输出数量与总顶点数。

定理

反向 DFS 完成次序是拓扑序

如果 DFS 没有遇到指向临时标记顶点的边,每个顶点完成时将它加入答案最前端, 最终序列就是一个拓扑序。

这里必须使用完成时刻,而不是首次发现时刻。另一种等价写法是在完成时 push 进 stack,并在整个 DFS 结束后反转。

证明思路

为什么有向环不可能排序

假设存在环 v_0->v_1->...->v_k->v_0。每条边都要求下一个位置严格更大, 沿环走一圈就会要求 pos(v_0) 严格小于自己,产生矛盾。这不仅是说某幅图 里碰巧有一支箭向左,而是任何线性排列都至少违反环上的一条边。

Kahn 不变量与正确性

维护不变量:indegree[v] 等于尚未输出的顶点指向 v 的边数。开始时 扫描所有边一次计算入度;输出 u 时只扫描 adj[u],并将每个后继的入度减 一。某个顶点第一次降为零,恰好说明它的全部前驱已经输出,因此把它接到答案 末尾不会违反任何边。

KAHN(adj, V):
    indegree[v] = 0 for every v
    for each u in V:
        for each v in adj[u]: indegree[v] += 1
    S = all v with indegree[v] == 0
    order = empty list
    while S is not empty:
        remove one u from S
        append u to order
        for each v in adj[u]:
            indegree[v] -= 1
            if indegree[v] == 0: insert v into S
    if length(order) != |V|: return error directed cycle
    return order

如果仍有顶点而 S 已空,剩余有限子图没有源点;根据上述结构事实,它不可 能是 DAG。S 可以用 queue、stack、set 或 priority queue;选择只影响返回 哪一个合法顺序,不影响正确性。

DFS 标记、完成逻辑与正确性

临时标记的顶点恰好组成当前 recursion stack。如果 u->v 指向临时标记的 v,stack 已经给出路径 v->...->u,新边把它闭合成环。指向永久标记则是 安全的,因为 v 已完成并且已经放入答案。

若没有环,考虑任意边 u->v。当 u 准备完成时,v 要么由这次 DFS 递归 完成,要么之前已经永久完成;两种情况下 v 都已在答案中。此时把 u 加到 最前面,就保证 u 位于 v 之前。这个论证适用于每条边。

DFS-TOPO(adj, V):
    mark[v] = UNMARKED for every v
    order = empty list
    for each v in V:
        if mark[v] == UNMARKED: VISIT(v)
    return order

VISIT(u):
    if mark[u] == TEMPORARY: abort with error directed cycle
    if mark[u] == PERMANENT: return
    mark[u] = TEMPORARY
    for each v in adj[u]: VISIT(v)
    mark[u] = PERMANENT
    add u to the front of order

邻接表复杂度

Kahn 初始化数组并寻找源点需要 O(V);计算与更新入度时,每个邻接表条目 只被扫描常数次,共 O(E),所以 queue 或 stack 版本的总时间是 O(V+E)。 DFS 同样只启动每个顶点一次、检查每条出边一次,因此也是 O(V+E)。两者在 图存储之外都需要 O(V) 辅助空间。若改用邻接矩阵,每个顶点都要扫描 V 个可能邻居,即使图很稀疏也需要 O(V^2) 时间。

例题详解

例题

检查顺序并观察非唯一性

Tutorial 10 的图包含边 A->B, A->D, B->C, B->D, C->D, C->E, C->F, D->EA,B,C,D,E,FA,B,C,D,F,E 都合法,因为没有边限制 EF 的 相对次序。A,B,C,E,D,F 不合法,因为 D->E 的尾端反而更晚。检查拓扑 序时必须检查所有图边,不能只看序列中的相邻顶点。

例题

Tutorial 10 图的完整 Kahn trace

初始入度为 A:0, B:1, C:1, D:3, E:2, F:1。按照 tutorial 中的分支选 择,状态如下:

步骤取出入度更新随后可选输出
起点{A}
1AB:1->0, D:3->2{B}A
2BC:1->0, D:2->1{C}A,B
3CD:1->0, E:2->1, F:1->0{D,F}A,B,C
4F{D}A,B,C,F
5DE:1->0{E}A,B,C,F,D
6EA,B,C,F,D,E

六个顶点全部输出,因此结果有效。第三步之后也可以先选 D;这个分支就是 多个拓扑序的具体来源。

例题

同一幅图的完整 DFS 完成 trace

B 开始,并按照 tutorial 的 F,E,D 次序检查 C 的后继。完成事件依 次把前置答案变为:

完成顶点加到最前后的答案
FF
EE,F
DD,E,F
CC,D,E,F
BB,C,D,E,F

外层循环随后遇到未标记的 A;它的后继 B,D 已永久完成,所以 A 可以 直接完成并加到最前,得到 A,B,C,D,E,F。加入最前的次序与完成次序相反。

例题

两种方法怎样揭示同一个环

在 tutorial 图中加入 E->B,就得到 B->C->E->B(同时也存在更长的 B->C->D->E->B)。Kahn 只能移除 A,随后还有五个顶点但不存在零入 度点,因此报告有环。DFS 从 B 临时标记 C,先完成 F,再按既定的 F,E,D 次序临时标记 E;当 E 指回仍带临时标记的 B 时,立即报告 B->C->E->B。此时 D 还没有被访问。两种方法都不能把部分答案当成成功 排序。

常见错误

常见错误

只检查序列中的相邻顶点

合法性要求每条 u->v 都让 u 更早;并不是大部分箭向前,或者相邻项看起 来合理就足够。

常见错误

每轮重新计算全部入度

应当先计算一次入度,移除 u 时再沿 adj[u] 精确递减。反复扫描全图会破 坏线性时间界限。

常见错误

忘记检查 Kahn 的输出数量

容器为空只有在全部顶点已经输出时才表示成功;否则剩余子图含有环。

常见错误

DFS 只使用一个 visited bit

已经见过的顶点可能仍在当前路径上,也可能早已完成。只有前一种情况证明有 环,因此必须区分临时标记与永久标记。

常见错误

DFS 在发现顶点时就加入答案

正确规则是在完成时加到最前,或者完成时 push 并在最后反转;发现次序没有这 个保证。

总结

  • 拓扑序恰好包含每个顶点一次,并且尊重每条有向边。
  • 存在拓扑序当且仅当图是 DAG;有向环会产生互相矛盾的先后要求。
  • Kahn 维护剩余入度并反复输出零入度点;输出少于 |V| 就证明有环。
  • DFS 用未标记、临时、永久三种状态检测环,并用反向完成次序排序。
  • 不同 tie-breaking 或 DFS 探索次序可以产生不同但同样合法的答案。
  • 使用邻接表时,两种方法都是 O(V+E) 时间和额外 O(V) 空间。

练习

思考检查

1. Tutorial 10 图中,Kahn 先后输出 A、B 后,C、D、E、F 的剩余入度分别是多少?下一个可以选谁?

只更新从已移除顶点发出的边。

思考检查

2. 一个有 9 个顶点的图只输出 7 点后,零入度容器就变为空。Kahn 可以得出什么结论?

比较处理数量与总顶点数。

思考检查

3. DFS 遇到指向永久标记 v 的边 u->v,是否已经证明有环?它与指向临时标记有何区别?

永久标记说明 v 的递归调用已经返回。

思考检查

4. 只有 P->R 与 Q->R,而 P、Q 之间没有路径。写出两个拓扑序并解释。

两个前置点都要早于 R,但彼此没有约束。

思考检查

5. 不依靠背诵,从邻接表循环说明 Kahn 为什么是 O(V+E)。

计算初始顶点工作,以及每个邻接条目被读取的次数。

答案与解答

解答 · 解答 1

移除 A 后,B 可以选择而 D 降为二;再移除 BC 从一降为零, D 从二降为一。因此答案是 C:0, D:1, E:2, F:1,下一步只能选 C

解答 · 解答 2

图中存在有向环。剩余两个顶点所在的子图没有源点;七个顶点的部分序列不是 原九顶点图的完整拓扑序。

解答 · 解答 3

不是。永久标记表示 v 已完成,并不代表它还在当前路径上。临时标记则表示 stack 已有 v->...->u 的路径,加入 u->v 就闭合成一个有向环。

解答 · 解答 4

P,Q,RQ,P,R 都合法。两者都把 P,Q 放在 R 之前,而图没有规定 P,Q 的相对次序。

解答 · 解答 5

初始化与寻找源点需要 O(V)。计算入度时每条边读取一次;主循环中每个顶点 最多取出一次,每条出边再更新一次,共 O(V+E)。相加以后仍是 O(V+E)

练习

先自行作答,再检查答案。你可以修改后重试。

加载中…

本单元重点词汇