Algorithm Search⌘K

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
johnson_elementary_cycles_algorithm.pyRawGitHub
# 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