Algorithm Search⌘K

Eppstein's k shortest paths in Python

Enumerates k shortest (not necessarily simple) paths in O(m + n log n + k) time using a path-sidetrack structure.

File
eppstein_k_shortest_paths.py, 194 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
eppstein_k_shortest_paths.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/eppstein-k-shortest-paths/. The authors are listed in prov/WNETNH.jsonl.

import heapq
import math

INF = math.inf


class _Node:
    """A node of a persistent leftist heap. It holds the best sidetrack out of one vertex."""

    __slots__ = ("key", "vertex", "left", "right", "rank")

    def __init__(self, key, vertex, left=None, right=None):
        self.key = key
        self.vertex = vertex
        self.left = left
        self.right = right
        self.rank = 1 + min(_rank(left), _rank(right))


def _rank(x) -> int:
    return x.rank if x else 0


def _insert(h, node):
    """Persistent insertion of a fresh one-node heap: h is left unchanged.

    Only the nodes on the right spine of h are copied, O(log size) of them.
    """
    if h is None:
        return node
    if node.key < h.key:
        return _Node(node.key, node.vertex, h, None)
    left, right = h.left, _insert(h.right, node)
    if _rank(left) < _rank(right):
        left, right = right, left
    return _Node(h.key, h.vertex, left, right)


def _relevant(adj, radj, s, t):
    """Vertices on some s-t walk: reachable from s and reaching t."""
    n = len(adj)

    def reach(g, r):
        seen = [False] * n
        seen[r] = True
        stack = [r]
        while stack:
            u = stack.pop()
            for v, *_ in g[u]:
                if not seen[v]:
                    seen[v] = True
                    stack.append(v)
        return seen

    a, b = reach(adj, s), reach(radj, t)
    return [a[v] and b[v] for v in range(n)]


def _distances_to_t(adj, radj, t, keep):
    """d[v] = dist(v, t) inside the kept vertices, and the tree edge out of each v.

    The tree edge of v is the edge (v, nxt[v]) with index tree_edge[v] in adj[v] that
    last lowered d[v]. Returns None if a negative cycle lies among the kept vertices.
    """
    n = len(adj)
    d = [INF] * n
    nxt = [-1] * n
    tree_edge = [-1] * n
    d[t] = 0
    if all(w >= 0 for u in range(n) if keep[u] for _, w in adj[u]):
        heap = [(0, t)]
        done = [False] * n
        while heap:
            dv, v = heapq.heappop(heap)
            if done[v]:
                continue
            done[v] = True
            for u, w, i in radj[v]:
                if keep[u] and dv + w < d[u]:
                    d[u], nxt[u], tree_edge[u] = dv + w, v, i
                    heapq.heappush(heap, (d[u], u))
        return d, nxt, tree_edge
    for _ in range(n):  # Bellman-Ford on the reversed graph
        changed = False
        for v in range(n):
            if not keep[v] or d[v] == INF:
                continue
            for u, w, i in radj[v]:
                if keep[u] and d[v] + w < d[u]:
                    d[u], nxt[u], tree_edge[u] = d[v] + w, v, i
                    changed = True
        if not changed:
            return d, nxt, tree_edge
    return None


def eppstein(
    adj: list[list[tuple[int, float]]], s: int, t: int, k: int
) -> list[tuple[float, list[int]]]:
    """Return the k shortest s-t walks as (weight, vertices), lightest first.

    The graph has the vertices 0 .. n-1; adj[u] lists the edges (v, w) out of u.
    Weights are real numbers, negative ones allowed. Walks may repeat vertices and
    edges. If a negative cycle lies on some s-t walk, there is no shortest walk and
    ValueError is raised; negative cycles elsewhere are ignored. If t is not
    reachable from s, the list is empty; if fewer than k walks exist, all are returned.
    """
    n = len(adj)
    radj: list[list[tuple[int, float, int]]] = [[] for _ in range(n)]
    for u in range(n):
        for i, (v, w) in enumerate(adj[u]):
            radj[v].append((u, w, i))
    keep = _relevant(adj, radj, s, t)
    if not keep[s] or k <= 0:
        return []
    found = _distances_to_t(adj, radj, t, keep)  # @line L1
    if found is None:
        raise ValueError("a negative cycle lies on an s-t walk")
    d, nxt, tree_edge = found
    # sidetracks out of each vertex, as a binary heap array; the best one is first
    side: list[list[tuple[float, int]]] = [[] for _ in range(n)]
    for u in range(n):
        if keep[u]:
            for i, (v, w) in enumerate(adj[u]):
                if keep[v] and i != tree_edge[u]:
                    side[u].append((w + d[v] - d[u], i))  # @line L2
            heapq.heapify(side[u])
    # H[v]: persistent heap of the best sidetracks of v and of its tree ancestors
    hg: list[_Node | None] = [None] * n
    kids: list[list[int]] = [[] for _ in range(n)]
    for v in range(n):
        if nxt[v] != -1:
            kids[nxt[v]].append(v)
    order = [t]  # tree order from t: the tree parent comes first
    for v in order:
        order.extend(kids[v])
    for v in order:
        h = hg[nxt[v]] if v != t else None
        if side[v]:
            h = _insert(h, _Node(side[v][0][0], v))  # @line L3
        hg[v] = h
    result = [(d[s], _walk(adj, nxt, s, t, []))]  # @line L4
    pq: list = []
    tick = 0
    if hg[s] is not None:
        pq.append((d[s] + hg[s].key, tick, ("T", hg[s]), ()))  # @line L5
    while pq and len(result) < k:  # @line L6
        cost, _, item, hist = heapq.heappop(pq)  # @line L7
        key, u, i, children = _expand(item, side)
        seq = hist + ((u, i),)
        result.append((cost, _walk(adj, nxt, s, t, list(seq))))  # @line L8
        for child in children:  # @line L9
            ckey = _expand(child, side)[0]
            tick += 1
            heapq.heappush(pq, (cost - key + ckey, tick, child, hist))
        head = hg[adj[u][i][0]]
        if head is not None:  # @line L10
            tick += 1
            heapq.heappush(pq, (cost + head.key, tick, ("T", head), seq))
    return result  # @line L11


def _expand(item, side):
    """Key, sidetrack (u, edge index) and path-graph heap children of a node."""
    if item[0] == "T":
        node = item[1]
        key, i = side[node.vertex][0]
        children = [("T", c) for c in (node.left, node.right) if c is not None]
        # the rest of the sidetracks of the vertex hang below the T node
        children += [("A", node.vertex, c) for c in (1, 2) if c < len(side[node.vertex])]
        return key, node.vertex, i, children
    _, u, j = item  # position j >= 1 of the heap array of the sidetracks of u
    key, i = side[u][j]
    kids = [("A", u, c) for c in (2 * j + 1, 2 * j + 2) if c < len(side[u])]
    return key, u, i, kids


def _walk(adj, nxt, s: int, t: int, sidetracks: list) -> list[int]:
    """Follow tree edges from s, taking the given sidetracks in order, to t."""
    walk = [s]
    v = s
    for u, i in sidetracks:
        while v != u:
            v = nxt[v]
            walk.append(v)
        v = adj[u][i][0]
        walk.append(v)
    while v != t:
        v = nxt[v]
        walk.append(v)
    return walk
test_eppstein_k_shortest_paths.py 208 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/eppstein-k-shortest-paths/. The authors are listed in prov/WNETNH.jsonl.

import math
import random

import pytest

from eppstein_k_shortest_paths import eppstein

INF = math.inf
# s=0, a=1, b=2, t=3
EXAMPLE = [(0, 1, 1), (0, 2, 2), (1, 2, 0), (1, 3, 3), (2, 3, 1), (3, 0, 4)]


def build(n, edges):
    adj = [[] for _ in range(n)]
    for u, v, w in edges:
        adj[u].append((v, w))
    return adj


def dist_to(adj, t):
    """Independent oracle: Bellman-Ford distances to t, or None on a negative cycle
    that reaches t."""
    n = len(adj)
    d = [INF] * n
    d[t] = 0
    for _ in range(n):
        changed = False
        for u in range(n):
            for v, w in adj[u]:
                if d[v] + w < d[u]:
                    d[u] = d[v] + w
                    changed = True
        if not changed:
            return d
    return None


def brute(adj, s, t, k):
    """Independent oracle: the k smallest s-t walk weights, by counting walks.

    With h = dist(., t), every edge has reduced weight r = w + h[v] - h[u] >= 0, and a
    walk weighs h[s] plus its reduced weight. N_c(v), the number of v-t walks of
    reduced weight c (capped at k), satisfies N_c(v) = [v = t, c = 0] + sum over
    edges (v, x) of N_{c - r}(x); iterating it level by level reaches the fixed point.
    """
    n = len(adj)
    h = dist_to(adj, t)
    if h[s] == INF:
        return []
    live = [h[v] < INF for v in range(n)]
    red = [[(x, w + h[x] - h[v]) for x, w in adj[v] if live[x]] if live[v] else [] for v in range(n)]
    # if the walks from s are finite in number, all are simple paths of reduced weight <= limit
    limit = (n - 1) * max([r for v in range(n) for _, r in red[v]] + [0])
    reach = {s}
    stack = [s]
    while stack:
        u = stack.pop()
        for x, _ in red[u]:
            if x not in reach:
                reach.add(x)
                stack.append(x)
    cyclic = _has_cycle(red, reach)
    levels = []
    out = []
    c = 0
    while len(out) < k and (cyclic or c <= limit):
        base = [1 if (v == t and c == 0) else 0 for v in range(n)]
        for v in range(n):
            for x, r in red[v]:
                if 0 < r <= c:
                    base[v] = min(k, base[v] + levels[c - r][x])
        cur = base
        while True:  # zero reduced-weight edges stay within the level
            nxt = base[:]
            for v in range(n):
                for x, r in red[v]:
                    if r == 0:
                        nxt[v] = min(k, nxt[v] + cur[x])
            if nxt == cur:
                break
            cur = nxt
        levels.append(cur)
        out += [h[s] + c] * cur[s]
        c += 1
    return out[:k]


def _has_cycle(g, verts):
    color = dict.fromkeys(verts, 0)
    for r in verts:
        if color[r]:
            continue
        stack = [(r, iter(g[r]))]
        color[r] = 1
        while stack:
            u, it = stack[-1]
            for x, _ in it:
                if color.get(x) == 1:
                    return True
                if color.get(x) == 0:
                    color[x] = 1
                    stack.append((x, iter(g[x])))
                    break
            else:
                color[u] = 2
                stack.pop()
    return False


def weight(adj, walk):
    total = 0
    for u, v in zip(walk, walk[1:]):
        total += min(w for x, w in adj[u] if x == v)
    return total


def test_example() -> None:
    got = eppstein(build(4, EXAMPLE), 0, 3, 6)
    assert got == [
        (2, [0, 1, 2, 3]),
        (3, [0, 2, 3]),
        (4, [0, 1, 3]),
        (8, [0, 1, 2, 3, 0, 1, 2, 3]),
        (9, [0, 2, 3, 0, 1, 2, 3]),
        (9, [0, 1, 2, 3, 0, 2, 3]),
    ]


def test_k_zero_and_one() -> None:
    adj = build(4, EXAMPLE)
    assert eppstein(adj, 0, 3, 0) == []
    assert eppstein(adj, 0, 3, 1) == [(2, [0, 1, 2, 3])]


def test_unreachable_target() -> None:
    adj = build(4, [(0, 1, 1), (2, 3, 1), (3, 2, -5)])
    assert eppstein(adj, 0, 3, 5) == []


def test_fewer_than_k_walks() -> None:
    adj = build(3, [(0, 1, 2), (1, 2, 2), (0, 2, 5)])
    assert eppstein(adj, 0, 2, 10) == [(4, [0, 1, 2]), (5, [0, 2])]


def test_source_equals_target() -> None:
    adj = build(2, [(0, 1, 1), (1, 0, 2)])
    assert eppstein(adj, 0, 0, 3) == [(0, [0]), (3, [0, 1, 0]), (6, [0, 1, 0, 1, 0])]


def test_negative_cycle_on_a_walk_is_rejected() -> None:
    adj = build(3, [(0, 1, 1), (1, 1, -1), (1, 2, 1)])
    with pytest.raises(ValueError):
        eppstein(adj, 0, 2, 3)


def test_negative_cycle_off_every_walk_is_ignored() -> None:
    # 2 <-> 3 is a negative cycle reachable from s, but it cannot reach t = 1
    adj = build(4, [(0, 1, 3), (0, 2, 1), (2, 3, -2), (3, 2, 1)])
    assert eppstein(adj, 0, 1, 2) == [(3, [0, 1])]


def test_negative_edges_without_negative_cycles() -> None:
    adj = build(3, [(0, 1, 4), (1, 2, -3), (0, 2, 2), (2, 0, 1)])
    got = eppstein(adj, 0, 2, 4)
    assert [c for c, _ in got] == [1, 2, 3, 4]


def test_parallel_edges_give_distinct_walks() -> None:
    adj = build(2, [(0, 1, 1), (0, 1, 1), (0, 1, 2)])
    assert [c for c, _ in eppstein(adj, 0, 1, 5)] == [1, 1, 2]


def test_zero_weight_cycle() -> None:
    adj = build(3, [(0, 1, 1), (1, 0, 0), (1, 2, 1)])
    got = eppstein(adj, 0, 2, 4)
    assert [c for c, _ in got] == [2, 3, 4, 5]


def test_random_against_brute_force() -> None:
    rng = random.Random(1998)
    cases = 0
    while cases < 600:
        n = rng.randint(1, 6)
        edges = set()
        for _ in range(rng.randint(0, 12)):
            u, v = rng.randrange(n), rng.randrange(n)
            edges.add((u, v, rng.randint(-2, 6)))
        best = {}
        for u, v, w in edges:  # no parallel edges, so vertex lists name walks
            best[(u, v)] = min(w, best.get((u, v), w))
        adj = build(n, [(u, v, w) for (u, v), w in best.items()])
        s, t, k = rng.randrange(n), rng.randrange(n), rng.randint(1, 12)
        if dist_to(adj, t) is None:
            continue  # a negative cycle reaches t; rejection is tested above
        try:
            got = eppstein(adj, s, t, k)
        except ValueError:
            continue
        cases += 1
        want = brute(adj, s, t, k)
        assert [c for c, _ in got] == want
        walks = [tuple(w) for _, w in got]
        assert len(set(walks)) == len(walks)
        for c, w in got:
            assert w[0] == s and w[-1] == t and weight(adj, w) == c

What the next level needs

  • the tests did not run in this build

Read the full page about Eppstein's k shortest paths