Johnson's algorithm for elementary cycles in Python
Lists all elementary cycles of a directed graph in O((n + m)(c + 1)) time, where c is the number of cycles, by a blocked depth-first search from the least vertex of each strong component.
- File
johnson_elementary_cycles_algorithm.py, 121 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/johnson-elementary-cycles-algorithm/. The authors are listed in prov/S5MRH3.jsonl.
def _least_nontrivial_scc(adj: list[list[int]], s: int) -> list[int] | None:
"""Strong component of G[s .. n-1] with the least vertex among those that have a cycle.
Iterative Tarjan algorithm restricted to the vertices s .. n-1. A component has a
cycle if it has two or more vertices or a self-loop. Time O(n + m).
"""
n = len(adj)
index = [-1] * n
low = [0] * n
on = [False] * n
comp: list[int] = []
best: list[int] | None = None
counter = 0
for root in range(s, n):
if index[root] >= 0:
continue
work = [(root, 0)]
index[root] = low[root] = counter
counter += 1
comp.append(root)
on[root] = True
while work:
v, i = work[-1]
if i < len(adj[v]):
work[-1] = (v, i + 1)
w = adj[v][i]
if w < s:
continue
if index[w] < 0:
index[w] = low[w] = counter
counter += 1
comp.append(w)
on[w] = True
work.append((w, 0))
elif on[w]:
low[v] = min(low[v], index[w])
continue
work.pop()
if work:
u = work[-1][0]
low[u] = min(low[u], low[v])
if low[v] == index[v]:
scc = []
while True:
w = comp.pop()
on[w] = False
scc.append(w)
if w == v:
break
if len(scc) > 1 or v in adj[v]:
if best is None or min(scc) < min(best):
best = scc
return best
def johnson_elementary_cycles_algorithm(adj: list[list[int]]) -> list[list[int]]:
"""Return every elementary cycle of the directed graph, each one time.
The graph has the vertices 0 .. n-1 and the adjacency lists adj; parallel arcs are
merged, since a cycle is reported by its vertices, and a self-loop at v is the cycle
[v]. Each cycle [v0, v1, ..., vk-1] starts at its least vertex v0 and has the arcs
v0 -> v1 -> ... -> vk-1 -> v0. Time O((n + m)(c + 1)) for c cycles, extra space
O(n + m) besides the output.
"""
n = len(adj)
succ = [sorted(set(a)) for a in adj]
out: list[list[int]] = []
blocked = [False] * n
bset: list[set[int]] = [set() for _ in range(n)]
s = 0 # @line L1
while s < n: # @line L2
comp = _least_nontrivial_scc(succ, s) # @line L3
if comp is None: # @line L4
break
s = min(comp) # @line L5
member = [False] * n
for v in comp: # @line L6
member[v] = True
blocked[v] = False
bset[v].clear()
ak = [[w for w in succ[v] if member[w]] if member[v] else [] for v in range(n)]
# CIRCUIT(s) with an explicit stack of frames [v, next index, found]
path = [s] # @line L7
blocked[s] = True # @line L9
frames = [[s, 0, False]]
while frames:
fr = frames[-1]
v = fr[0]
if fr[1] < len(ak[v]): # @line L10
w = ak[v][fr[1]]
fr[1] += 1
if w == s: # @line L11
out.append(list(path))
fr[2] = True
elif not blocked[w]: # @line L12
path.append(w)
blocked[w] = True # @line L9
frames.append([w, 0, False])
continue
if fr[2]: # @line L13
todo = [v] # UNBLOCK(v), iteratively
while todo:
u = todo.pop()
blocked[u] = False # @line L16
for x in bset[u]: # @line L17
if blocked[x]:
todo.append(x)
bset[u].clear()
else: # @line L14
for w in ak[v]:
bset[w].add(v)
frames.pop() # @line L15
path.pop()
if frames and fr[2]:
frames[-1][2] = True
s += 1 # @line L8
return out
test_johnson_elementary_cycles_algorithm.py 74 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/johnson-elementary-cycles-algorithm/. The authors are listed in prov/S5MRH3.jsonl.
import itertools
import random
from johnson_elementary_cycles_algorithm import johnson_elementary_cycles_algorithm
def brute_cycles(adj):
"""Independent oracle: every ordering of a vertex set that starts at its least vertex."""
n = len(adj)
arcs = {(u, v) for u in range(n) for v in adj[u]}
found = set()
for k in range(1, n + 1):
for vs in itertools.combinations(range(n), k):
for rest in itertools.permutations(vs[1:]):
c = (vs[0],) + rest
if all((c[i], c[(i + 1) % k]) in arcs for i in range(k)):
found.add(c)
return found
EXAMPLE = [[1], [2, 3], [0, 3], [0, 4], [3, 4], []]
def test_example() -> None:
got = johnson_elementary_cycles_algorithm(EXAMPLE)
assert got == [[0, 1, 2], [0, 1, 2, 3], [0, 1, 3], [3, 4], [4]]
def test_unblock_needed() -> None:
adj = [[1, 3], [2, 0], [1], [2]]
got = johnson_elementary_cycles_algorithm(adj)
assert got == [[0, 1], [0, 3, 2, 1], [1, 2]]
def test_small_cases() -> None:
assert johnson_elementary_cycles_algorithm([]) == []
assert johnson_elementary_cycles_algorithm([[]]) == []
assert johnson_elementary_cycles_algorithm([[0]]) == [[0]]
assert johnson_elementary_cycles_algorithm([[0, 0]]) == [[0]]
assert johnson_elementary_cycles_algorithm([[1, 1], [0]]) == [[0, 1]]
assert johnson_elementary_cycles_algorithm([[1], [2], []]) == []
assert johnson_elementary_cycles_algorithm([[], [], []]) == []
def test_complete_digraph() -> None:
for n, want in ((3, 5), (4, 20), (5, 84)):
adj = [[v for v in range(n) if v != u] for u in range(n)]
got = johnson_elementary_cycles_algorithm(adj)
assert len(got) == want and len({tuple(c) for c in got}) == want
def test_long_cycle() -> None:
n = 50000
adj = [[(i + 1) % n] for i in range(n)]
assert johnson_elementary_cycles_algorithm(adj) == [list(range(n))]
def test_random_against_brute_force() -> None:
rng = random.Random(1975)
for _ in range(600):
n = rng.randint(0, 6)
p = rng.random() * 0.6
adj = [[v for v in range(n) if rng.random() < p] for _ in range(n)]
for a in adj:
if a and rng.random() < 0.2:
a.append(a[0])
rng.shuffle(a)
got = johnson_elementary_cycles_algorithm(adj)
tuples = [tuple(c) for c in got]
assert len(tuples) == len(set(tuples))
assert set(tuples) == brute_cycles(adj)
What the next level needs
- the tests did not run in this build
Read the full page about Johnson's algorithm for elementary cycles