Algorithm Search⌘K

Read-Tarjan path enumeration in Python

Output-sensitive enumeration of simple s-t paths by DFS with pruning.

File
read_tarjan_path_enumeration.py, 101 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
read_tarjan_path_enumeration.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/read-tarjan-path-enumeration/. The authors are listed in prov/89EF45.jsonl.


def read_tarjan_path_enumeration(
    n: int, edges: list[tuple[int, int]], s: int, t: int, directed: bool = True
) -> list[list[int]]:
    """Return every simple path from s to t, each as a list of vertices.

    Vertices are 0 .. n-1. If directed is False, each edge (u, v) can be used both
    ways. Parallel edges are merged (a path is a vertex sequence) and self-loops are
    ignored, since no simple path uses them. Every step that the search takes leads to
    a new path, so the time is O((n + m)(N + 1)) for N paths and the delay between two
    outputs is O(n + m); the pending branches take O(n m) extra space in the worst case.
    """
    out_sets: list[set[int]] = [set() for _ in range(n)]
    for u, v in edges:
        if u != v:
            out_sets[u].add(v)
            if not directed:
                out_sets[v].add(u)
    out = [sorted(x) for x in out_sets]
    inc: list[list[int]] = [[] for _ in range(n)]
    for u in range(n):
        for v in out[u]:
            inc[v].append(u)

    paths: list[list[int]] = []
    if s == t:  # @line L1
        return [[s]]
    if _find_path(out, s, t, set()) is None:  # @line L2
        return paths
    # a pending branch is (full path of the parent, cut length, next vertex)
    stack: list[tuple[list[int], int, int]] = [([s], 0, -1)]  # @line L3
    while stack:  # @line L4
        full, cut, x = stack.pop()  # @line L5
        prefix = full if x < 0 else full[:cut] + [x]
        u = prefix[-1]
        if u == t:  # @line L6
            paths.append(prefix)
            continue
        blocked = set(prefix[:-1])  # @line L7
        p = _find_path(out, u, t, blocked)  # @line L8
        assert p is not None
        whole = prefix + p[1:]  # @line L9
        paths.append(whole)
        k = len(p) - 1  # @line L10
        for v in p[:k]:
            blocked.add(v)
        reach = _reverse_reach(inc, t, blocked)
        base = len(prefix) - 1
        for i in range(k - 1, -1, -1):  # @line L11
            if i < k - 1:  # @line L12
                w = p[i + 1]
                blocked.discard(w)
                if any(y in reach for y in out[w]):
                    _grow(inc, w, blocked, reach)
            for y in out[p[i]]:  # @line L13
                if y != p[i + 1] and y in reach:
                    stack.append((whole, base + i + 1, y))  # @line L14
    return paths


def _find_path(out: list[list[int]], u: int, t: int, blocked: set[int]) -> list[int] | None:
    """Iterative DFS for a path from u to t that avoids blocked; O(n + m)."""
    parent = {u: u}
    todo = [u]
    while todo:
        a = todo.pop()
        if a == t:
            path = [t]
            while path[-1] != u:
                path.append(parent[path[-1]])
            path.reverse()
            return path
        for b in out[a]:
            if b not in parent and b not in blocked:
                parent[b] = a
                todo.append(b)
    return None


def _reverse_reach(inc: list[list[int]], t: int, blocked: set[int]) -> set[int]:
    """The vertices outside blocked that reach t avoiding blocked."""
    reach = {t}
    _spread(inc, [t], blocked, reach)
    return reach


def _grow(inc: list[list[int]], w: int, blocked: set[int], reach: set[int]) -> None:
    reach.add(w)
    _spread(inc, [w], blocked, reach)


def _spread(inc: list[list[int]], todo: list[int], blocked: set[int], reach: set[int]) -> None:
    while todo:
        a = todo.pop()
        for b in inc[a]:
            if b not in reach and b not in blocked:
                reach.add(b)
                todo.append(b)
test_read_tarjan_path_enumeration.py 62 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/read-tarjan-path-enumeration/. The authors are listed in prov/89EF45.jsonl.

import itertools
import random

from read_tarjan_path_enumeration import read_tarjan_path_enumeration


def brute_paths(n, edges, s, t, directed=True):
    """Independent oracle: try every sequence of distinct vertices from s to t."""
    arcs = set()
    for u, v in edges:
        if u != v:
            arcs.add((u, v))
            if not directed:
                arcs.add((v, u))
    if s == t:
        return [[s]]
    found = []
    middle = [v for v in range(n) if v not in (s, t)]
    for k in range(len(middle) + 1):
        for mid in itertools.permutations(middle, k):
            seq = [s, *mid, t]
            if all((seq[i], seq[i + 1]) in arcs for i in range(len(seq) - 1)):
                found.append(seq)
    return found


EXAMPLE = [(0, 1), (0, 2), (1, 2), (2, 1), (1, 3), (2, 3), (3, 4), (2, 4), (4, 5)]


def test_example() -> None:
    got = read_tarjan_path_enumeration(6, EXAMPLE, 0, 4)
    assert sorted(got) == sorted(brute_paths(6, EXAMPLE, 0, 4))
    assert len(got) == 6
    assert got[0] == [0, 2, 4]


def test_edge_cases() -> None:
    assert read_tarjan_path_enumeration(1, [], 0, 0) == [[0]]
    assert read_tarjan_path_enumeration(3, [(0, 1)], 0, 2) == []
    assert read_tarjan_path_enumeration(2, [(1, 0)], 0, 1) == []
    assert read_tarjan_path_enumeration(2, [(1, 0)], 0, 1, directed=False) == [[0, 1]]
    # loops and parallel edges do not add paths
    assert read_tarjan_path_enumeration(2, [(0, 1), (0, 1), (0, 0), (1, 1)], 0, 1) == [[0, 1]]
    # complete digraph on 4 vertices: 1 + 2 + 2 = 5 paths from 0 to 3
    k4 = [(u, v) for u in range(4) for v in range(4) if u != v]
    assert len(read_tarjan_path_enumeration(4, k4, 0, 3)) == 5


def test_random_against_brute_force() -> None:
    rng = random.Random(1975)
    for _ in range(800):
        n = rng.randint(1, 7)
        edges = [(rng.randrange(n), rng.randrange(n)) for _ in range(rng.randint(0, 14))]
        s, t = rng.randrange(n), rng.randrange(n)
        directed = rng.random() < 0.6
        got = read_tarjan_path_enumeration(n, edges, s, t, directed)
        want = brute_paths(n, edges, s, t, directed)
        assert len(got) == len(want)
        assert sorted(got) == sorted(want)

What the next level needs

  • the tests did not run in this build

Read the full page about Read-Tarjan path enumeration