Files
troupe-standup/wizard-work/beam/toposort_kahn.py
T

70 lines
2.1 KiB
Python

#!/usr/bin/env python3
"""toposort_kahn.py -- deterministic topological sort with cycle reporting.
Implements Kahn's algorithm. Returns (order, cycle) where cycle is None on
success and a list of nodes forming a cycle otherwise. Ties broken by node
name for reproducibility.
"""
from __future__ import annotations
import heapq
from typing import Dict, List, Tuple
def toposort(adj: Dict[str, List[str]]) -> Tuple[List[str], List[str] | None]:
# adj: node -> list of successors (edges node -> succ)
nodes = set(adj)
for succs in adj.values():
for s in succs:
nodes.add(s)
indeg = {n: 0 for n in nodes}
for n in adj:
for s in adj.get(n, []):
indeg[s] += 1
# min-heap for deterministic order
heap = [n for n in nodes if indeg[n] == 0]
heapq.heapify(heap)
order: List[str] = []
while heap:
n = heapq.heappop(heap)
order.append(n)
for s in sorted(adj.get(n, [])):
indeg[s] -= 1
if indeg[s] == 0:
heapq.heappush(heap, s)
if len(order) != len(nodes):
# find a cycle via remaining nodes
remaining = [n for n in nodes if n not in set(order)]
# build cycle: follow successors among remaining
remset = set(remaining)
# pick start
start = min(remaining)
# walk
path = [start]
seen = {start}
cur = start
while True:
succs = [s for s in adj.get(cur, []) if s in remset]
if not succs:
# shouldn't happen if indegree>0; fallback
break
nxt = min(succs)
if nxt in seen:
idx = path.index(nxt)
return order, path[idx:] + [nxt]
path.append(nxt)
seen.add(nxt)
cur = nxt
return order, remaining
return order, None
if __name__ == "__main__":
# self-test
g = {"a": ["b"], "b": ["c"], "c": []}
o, c = toposort(g)
assert c is None and o == ["a", "b", "c"], (o, c)
g2 = {"a": ["b"], "b": ["a"]}
o2, c2 = toposort(g2)
assert c2 is not None, (o2, c2)
print("OK", o, c2)