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
# 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