qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

拓扑排序停住后,剩下的任务为什么不全在环里?

A与B互相等待,还会拖住后面的C、D;用任务就绪队列把环成员、受阻任务和仍可完成的任务分开。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先约定箭头:前面的任务必须先完成
  4. 这六个任务里,队列可以做完E和F
  5. 剩余集合恰好包含环,以及从环能走到的所有节点
  6. Python报出CycleError后,仍可取出不受阻塞的任务
  7. 排序和诊断是两件事,开销也分别说明
  8. 完整程序:手算之外,再让独立方法核对
  9. 在环前面和后面各加一个任务
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道有向边表示先后依赖:A→B表示A先于B
  • 可先读 图中的环与反例;本文改为有向图
  • Python 3.10+;标准库graphlib即可
遇到不熟悉的词,先看这 2 个解释
拓扑排序
把任务排成一个顺序,让每条依赖边的起点都在终点之前。
入度
指向当前节点的边数;运行过程中表示还没完成的前置依赖数。
打开完整术语小抄 ↗

六个任务里,A要等B,B又要等A;C要等B,D要等C。另有互不相干的一条链E→F。队列最终做完E、F后停下来,剩下A、B、C、D。

现在给所有剩下的任务都标上“循环依赖成员”,会把C、D也算进去。它们确实被阻塞,却没有沿着箭头回到自己。定位故障时,阻塞范围和环本身需要分别回答。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。A与B互相等待,还会拖住后面的C、D;用任务就绪队列把环成员、受阻任务和仍可完成的任务分开。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

任务队列停下时,留下来的任务都在环里吗?

正在准备实验控件…

依赖边:A→B;B→A;B→C;C→D;E→F。

已完成
无
可执行队列
E
本步完成
尚未执行
剩余依赖数为零,才可加入队列
任务剩余依赖数当前状态
A1等待依赖
B1等待依赖
C1等待依赖
D1等待依赖
E0可以执行
F1等待依赖

箭头 A→B 表示 A 必须先完成,B 才能开始。逐步执行的是 Kahn 拓扑排序;末尾用独立可达性搜索区分真正的环成员和被环阻塞的后续节点。

先约定箭头:前面的任务必须先完成

本文A→B表示A是B的前置条件。因此图中有A→B、B→A、B→C、C→D、E→F。A、B互相依赖;C、D只在这条循环之后。请先看清方向,反过来画边会改变谁被谁阻塞。

一个任务的入度,就是当前还没完成的前置依赖数。Kahn方法先把入度为0的任务放进就绪队列。完成一个任务后,沿它发出的每条边,把后续任务的剩余依赖数减一;降到0时才加入队列。

这六个任务里,队列可以做完E和F

开始只有E没有前置条件,所以先完成E。删掉E→F这条已满足的依赖后,F的入度降为0,也可以完成。之后队列为空,A、B、C、D仍未完成。

A和B能互相走回起点,是实际的环成员。C只能走到D,D没有发出的边,所以二者都不在任何有向环上。然而它们的前置条件依赖于无法开始的A、B,照样不会进入就绪队列。

因此“拓扑排序没有处理完所有节点”足以说明图中存在环;但剩余节点集合不是一份精确的环成员名单。要定位具体循环,仍需另做环检测或其他图分析。

剩余集合恰好包含环,以及从环能走到的所有节点

环上的节点互相保留前置条件,无法靠不断取出入度0的节点消除。沿着环走向后续节点,前面那项始终不能完成,阻塞也会继续传下去。因此环成员和它们能到达的所有后续节点都会留下。

反过来,队列完全排空后,每个剩余节点都还有来自剩余集合的前置依赖。沿前置依赖一直向前找,在有限节点里必然重复遇到某个节点,于是找到一个有向环。从这个环顺着原来的箭头走,就能到达最初那个剩余节点。

这给出准确描述:剩余集合等于所有有向环成员及其可达后继。环之前但不受环反向依赖的任务可以已经完成;孤立任务也可以完成。一个节点指向自己同样形成环,程序的穷举包含这种自环。

Python报出CycleError后,仍可取出不受阻塞的任务

graphlib.TopologicalSorter的输入字典方向需要特别注意:键是任务,值是它的前置任务集合。本文箭头A→B要写进B的前置集合,而不是把B放到A的前置集合。

prepare()检测到环会抛出CycleError,但官方文档允许继续调用get_ready(),直到所有不被环阻塞的任务都处理完。示例实际捕获异常后继续执行,并用done()逐批标记已完成,真实库仍处理了E、F。

CycleError提供的环信息不保证列举图里所有环。合法拓扑顺序也可能不唯一:两个互不依赖的任务可以互换先后。因此测试比较可处理的节点集合和依赖先后,没有强求所有合法实现返回一模一样的排列。

排序和诊断是两件事,开销也分别说明

使用邻接表和队列的Kahn核心过程让每个节点入队一次、每条边被减计一次,时间O(V+E),工作空间O(V+E)。本例为了教学先做小图输入校验;Python校验里的节点列表查找最坏会增加O(VE)时间,不能把整个脚本都直接归为严格的线性实现。

独立参考对每个起点做路径搜索:只把至少走过一条边后能回到自己的点算作环成员,避免把零步“停在原地”误算成环。Python参考直接扫描边列表找后继,最坏可到O(V²E+V²);浏览器使用邻接表,参考搜索为O(V(V+E)),并额外保存步骤快照。这些诊断适合本文限制的小图,未做大型依赖图性能测试。

完整程序:手算之外,再让独立方法核对

保存下面全部代码为 demo.py,执行 python demo.py。本次在 Windows / CPython 3.14.0 实跑,只使用标准库;同时通过 Python 3.10 语法解析,未在 Python 3.10 解释器中运行。

枚举3个节点的全部512张有向图,包括自环。Kahn剩余节点对照独立的可达性参考,另与真实graphlib可处理节点集合、CycleError和依赖顺序交叉核对;补充空图与4类输入拒绝检查。

输出来自本次真实执行。检查使用显式 AssertionError,python -O 不会把检查删掉。小范围穷举支持实现核对,算法的一般理由仍由前面的依赖关系说明。

展开收起代码 · 小实验python · 129 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Kahn leftovers include cycle descendants, not just cycle members."""
from collections import deque
from graphlib import CycleError, TopologicalSorter
from itertools import product


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def validate(nodes, edges):
    if not isinstance(nodes, (list, tuple)) or not 0 <= len(nodes) <= 12:
        raise ValueError("at most 12 nodes required")
    if any(type(x) is not str or not x or len(x) > 20 for x in nodes) or len(set(nodes)) != len(nodes):
        raise ValueError("distinct nonempty string node names required")
    if not isinstance(edges, (list, tuple)):
        raise ValueError("edge sequence required")
    seen = set()
    for edge in edges:
        if not isinstance(edge, (list, tuple)) or len(edge) != 2:
            raise ValueError("each edge is (prerequisite, dependent)")
        a, b = edge
        if a not in nodes or b not in nodes or (a, b) in seen:
            raise ValueError("known nodes and no duplicate edges required")
        seen.add((a, b))


def kahn(nodes, edges):
    validate(nodes, edges)
    children = {node: [] for node in nodes}
    indegree = dict.fromkeys(nodes, 0)
    for a, b in edges:
        children[a].append(b)
        indegree[b] += 1
    ready = deque(node for node in nodes if indegree[node] == 0)
    done = []
    while ready:
        node = ready.popleft()
        done.append(node)
        for child in children[node]:
            indegree[child] -= 1
            if indegree[child] == 0:
                ready.append(child)
    return done, [node for node in nodes if indegree[node] > 0]


def reachable(nodes, edges, start):
    # Paths have at least one edge: start is reached only if a cycle returns.
    found = set()
    todo = [b for a, b in edges if a == start]
    while todo:
        node = todo.pop()
        if node not in found:
            found.add(node)
            todo.extend(b for a, b in edges if a == node)
    return found


def oracle(nodes, edges):
    reach = {node: reachable(nodes, edges, node) for node in nodes}
    cyclic = {node for node in nodes if node in reach[node]}
    blocked = set(cyclic)
    for node in cyclic:
        blocked.update(reach[node])
    return cyclic, blocked


def graphlib_run(nodes, edges):
    # graphlib takes node -> predecessors; our arrows point the other way.
    predecessors = {node: set() for node in nodes}
    for a, b in edges:
        predecessors[b].add(a)
    sorter = TopologicalSorter(predecessors)
    has_cycle = False
    try:
        sorter.prepare()
    except CycleError:
        has_cycle = True
    done = []
    while sorter.is_active():
        ready = sorter.get_ready()
        require(bool(ready), "all issued ready nodes are completed each round")
        done.extend(ready)
        sorter.done(*ready)
    return done, has_cycle


def main():
    nodes = list("ABCDEF")
    edges = [("A", "B"), ("B", "A"), ("B", "C"), ("C", "D"), ("E", "F")]
    done, left = kahn(nodes, edges)
    cyclic, blocked = oracle(nodes, edges)
    actual, has_cycle = graphlib_run(nodes, edges)
    print(f"edges={edges}")
    print(f"kahn_done={done}; leftovers={left}")
    print(f"actual_cycle_members={sorted(cyclic)}; blocked_descendants={sorted(blocked - cyclic)}")
    print(f"graphlib_cycle_error={has_cycle}; still_processed={actual}")
    count = 0
    nodes = list("ABC")
    possible = list(product(nodes, repeat=2))
    for mask in range(1 << len(possible)):
        edges = [edge for i, edge in enumerate(possible) if mask & (1 << i)]
        done, left = kahn(nodes, edges)
        cyclic, blocked = oracle(nodes, edges)
        actual, has_cycle = graphlib_run(nodes, edges)
        require(set(left) == blocked, "leftovers are cycles and all their descendants")
        require(set(actual) == set(done), "graphlib processes same node set")
        require(has_cycle == bool(cyclic), "graphlib detects cycles")
        position = {node: i for i, node in enumerate(done)}
        for a, b in edges:
            if b in position:
                require(a in position and position[a] < position[b], "processed prerequisite comes first")
        count += 1
    invalid = [(["A", "A"], []), (["A"], [("A", "B")]), (["A"], [("A", "A"), ("A", "A")]), ([""], [])]
    for nodes, edges in invalid:
        try:
            kahn(nodes, edges)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    require(kahn([], []) == ([], []), "empty graph")
    print(f"directed_graphs_checked={count}; self_loops=included; invalid_inputs={len(invalid)}")
    print("PASS: reachability oracle, dependency order and real graphlib agree")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。A与B互相等待,还会拖住后面的C、D;用任务就绪队列把环成员、受阻任务和仍可完成的任务分开。

运行后对照结果
edges=[('A', 'B'), ('B', 'A'), ('B', 'C'), ('C', 'D'), ('E', 'F')]
kahn_done=['E', 'F']; leftovers=['A', 'B', 'C', 'D']
actual_cycle_members=['A', 'B']; blocked_descendants=['C', 'D']
graphlib_cycle_error=True; still_processed=['E', 'F']
directed_graphs_checked=512; self_loops=included; invalid_inputs=4
PASS: reachability oracle, dependency order and real graphlib agree

在环前面和后面各加一个任务

练习不预填答案:在A↔B这对循环前加P→A,再在B后加B→Q→R,另外保留孤立任务S。先分别写出真正的环成员、将被阻塞的节点、能够完成的节点。

然后把A→B移除,只保留B→A,再预测队列是否还能停住。运行前先写理由,运行后检查每个已完成任务的所有前置条件是否都更早完成。

记住这些要点

  • 剩下未处理的节点可能只是被环拖住,并不全在环上。
  • 完整运行Kahn后,剩余集合是环成员及其所有可达后继。
  • 局部循环不一定阻止整张图里所有任务完成。
  • graphlib字典保存前置节点,方向要与箭头含义对齐。

别把例子用过头

  • 给所有未处理节点都标成循环依赖成员。
  • 把可达性中的零步路径误认成自环。
  • 捕获CycleError后认为任何无关任务也不能继续。
  • 要求拓扑结果只有一种固定顺序。

自己试一小步

图P→A、A↔B、B→Q→R和孤立S中,环成员、受阻节点、可完成节点分别是谁?

  1. 先按箭头写下每个节点的前置条件。
  2. 手动移除入度0的节点直到队列排空。
  3. 单独检查哪些节点能沿至少一条边走回自己。

做到这些,就算完成

  • 三个集合的含义没有混在一起。
  • 已完成节点的前置任务确实更早完成。
  • 移除一条环边后先预测再执行。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Python graphlib:TopologicalSorter 与 CycleError (新标签页)↗Python Software Foundation · 英文

    前置节点字典方向;prepare检测环;捕获CycleError后仍可取出未被环阻塞的任务;get_ready/done协议与非唯一顺序。剩余集合等式由本文给出证明并独立穷举核对。

    已阅读TopologicalSorter构造、prepare、get_ready、done和CycleError正文。真实执行版本为CPython 3.14.0;文档3.14分支页面可能随维护版更新。