Contraction hierarchies in Python
Preprocesses a graph by contracting vertices in an importance order, enabling fast bidirectional point-to-point queries.
- File
contraction_hierarchies.py, 166 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/contraction-hierarchies/. The authors are listed in prov/K2PFVE.jsonl.
import heapq
import math
INF = math.inf
class CH:
"""A contraction hierarchy: ranks, upward and downward edges, shortcut middles."""
def __init__(self, n: int) -> None:
self.rank = [0] * n
self.up: list[list[tuple[int, float]]] = [[] for _ in range(n)]
self.down: list[list[tuple[int, float]]] = [[] for _ in range(n)]
self.mid: dict[tuple[int, int], int | None] = {}
self.shortcuts = 0
def _witness(out, u: int, x: int, skip: int, limit: float) -> bool:
"""True if the remaining graph without skip has a u-x path of weight <= limit."""
d = {u: 0}
heap = [(0, u)]
while heap:
du, a = heapq.heappop(heap)
if du > limit:
return False
if du > d[a]:
continue
if a == x:
return True
for b, (w, _) in out[a].items():
if b != skip and du + w < d.get(b, INF):
d[b] = du + w
heapq.heappush(heap, (d[b], b))
return False
def _needed(out, inn, v: int) -> list[tuple[int, int, float]]:
"""The shortcuts that contracting v would add: (u, x, weight)."""
res = []
for u, (wu, _) in inn[v].items():
for x, (wx, _) in out[v].items(): # @line L2
if u != x and not _witness(out, u, x, v, wu + wx): # @line L3
res.append((u, x, wu + wx))
return res
def ch_preprocess(
adj: list[list[tuple[int, float]]], order: list[int] | None = None
) -> CH:
"""Build a contraction hierarchy of the directed graph adj.
The graph has the vertices 0 .. n-1; adj[u] lists the edges (v, w) out of u.
Precondition: every weight is nonnegative (a ValueError is raised otherwise).
If order is given, vertices are contracted in that order; otherwise by the
edge-difference heuristic with lazy updates. Witness searches are exact.
"""
n = len(adj)
out: list[dict[int, tuple[float, int | None]]] = [{} for _ in range(n)]
inn: list[dict[int, tuple[float, int | None]]] = [{} for _ in range(n)]
for u in range(n):
for v, w in adj[u]:
if w < 0:
raise ValueError("contraction hierarchies need nonnegative weights")
if u != v and w < out[u].get(v, (INF, None))[0]:
out[u][v] = (w, None)
inn[v][u] = (w, None)
ch = CH(n)
def priority(v: int) -> int:
return len(_needed(out, inn, v)) - len(out[v]) - len(inn[v])
if order is None:
heap = [(priority(v), v) for v in range(n)]
heapq.heapify(heap)
seq = []
done = [False] * n
while heap:
p, v = heapq.heappop(heap)
if done[v]:
continue
q = priority(v)
if heap and q > heap[0][0]:
heapq.heappush(heap, (q, v))
continue
done[v] = True
seq.append(v)
_contract(ch, out, inn, v, len(seq) - 1)
return ch
for i, v in enumerate(order): # @line L1
_contract(ch, out, inn, v, i)
return ch
def _contract(ch: CH, out, inn, v: int, r: int) -> None:
ch.rank[v] = r
for u, x, w in _needed(out, inn, v):
if w < out[u].get(x, (INF, None))[0]:
out[u][x] = (w, v) # @line L4
inn[x][u] = (w, v)
ch.shortcuts += 1
for x, (w, m) in out[v].items(): # @line L5
ch.up[v].append((x, w))
ch.mid[(v, x)] = m
del inn[x][v]
for u, (w, m) in inn[v].items():
ch.down[v].append((u, w))
ch.mid[(u, v)] = m
del out[u][v]
out[v].clear()
inn[v].clear()
def _unpack(ch: CH, u: int, v: int, acc: list[int]) -> None:
m = ch.mid[(u, v)]
if m is None:
acc.append(v)
else:
_unpack(ch, u, m, acc)
_unpack(ch, m, v, acc)
def ch_query(ch: CH, s: int, t: int) -> tuple[float, list[int] | None]:
"""Return (dist, path) from s to t, or (math.inf, None) if t is unreachable."""
if s == t:
return 0, [s]
n = len(ch.rank)
dist = [[INF] * n, [INF] * n]
pred: list[list[int | None]] = [[None] * n, [None] * n]
done = [[False] * n, [False] * n]
graph = [ch.up, ch.down]
dist[0][s] = 0 # @line L6
dist[1][t] = 0
heaps = [[(0, s)], [(0, t)]]
mu, meet = INF, None
while True:
side = min((0, 1), key=lambda i: heaps[i][0][0] if heaps[i] else INF)
if not heaps[side] or heaps[side][0][0] >= mu: # @line L7
break
du, u = heapq.heappop(heaps[side])
if done[side][u]:
continue
done[side][u] = True
if du + dist[1 - side][u] < mu: # @line L8
mu, meet = du + dist[1 - side][u], u
for v, w in graph[side][u]:
if du + w < dist[side][v]:
dist[side][v] = du + w
pred[side][v] = u
heapq.heappush(heaps[side], (dist[side][v], v))
if meet is None: # @line L9
return INF, None
fwd = [meet]
while fwd[-1] != s:
fwd.append(pred[0][fwd[-1]])
fwd.reverse()
bwd = [meet]
while bwd[-1] != t:
bwd.append(pred[1][bwd[-1]])
up_path = fwd + bwd[1:]
path = [s]
for a, b in zip(up_path, up_path[1:]):
_unpack(ch, a, b, path) # @line L10
return mu, path
test_contraction_hierarchies.py 111 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/contraction-hierarchies/. The authors are listed in prov/K2PFVE.jsonl.
import math
import random
import pytest
from contraction_hierarchies import ch_preprocess, ch_query
INF = math.inf
# a=0, b=1, c=2, d=3, e=4; every edge goes both ways
EXAMPLE_EDGES = [(0, 1, 1), (1, 2, 1), (2, 3, 1), (0, 4, 3), (4, 3, 1)]
EXAMPLE_ORDER = [1, 4, 2, 0, 3]
def undirected(n, edges):
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
adj[v].append((u, w))
return adj
def bellman_ford(adj, s):
"""Independent oracle: n - 1 rounds of relaxing every edge."""
n = len(adj)
d = [INF] * n
d[s] = 0
for _ in range(n - 1):
for u in range(n):
for v, w in adj[u]:
if d[u] + w < d[v]:
d[v] = d[u] + w
return d
def path_weight(adj, p):
return sum(min(w for v, w in adj[a] if v == b) for a, b in zip(p, p[1:]))
def test_example_shortcuts() -> None:
ch = ch_preprocess(undirected(5, EXAMPLE_EDGES), EXAMPLE_ORDER)
# a-c through b, and a-d through c, each in both directions; none for e
assert ch.shortcuts == 4
assert ch.mid[(0, 2)] == 1 and ch.mid[(0, 3)] == 2
assert sorted(ch.up[0]) == [(3, 3)]
def test_example_queries() -> None:
ch = ch_preprocess(undirected(5, EXAMPLE_EDGES), EXAMPLE_ORDER)
assert ch_query(ch, 1, 4) == (3, [1, 2, 3, 4])
assert ch_query(ch, 0, 3) == (3, [0, 1, 2, 3])
assert ch_query(ch, 2, 2) == (0, [2])
def test_unreachable() -> None:
# 0 -> 1 -> 2 and 3 -> 0; nothing reaches 3, and 2 reaches nothing
adj = [[(1, 1)], [(2, 1)], [], [(0, 1)]]
for order in (None, [0, 1, 2, 3], [3, 2, 1, 0], [1, 3, 0, 2]):
ch = ch_preprocess(adj, order)
assert ch_query(ch, 0, 3) == (INF, None)
assert ch_query(ch, 2, 0) == (INF, None)
assert ch_query(ch, 3, 2) == (3, [3, 0, 1, 2])
def test_isolated_vertices() -> None:
ch = ch_preprocess([[], [], []])
assert ch_query(ch, 0, 2) == (INF, None)
def test_negative_weight_rejected() -> None:
with pytest.raises(ValueError):
ch_preprocess([[(1, -1)], []])
def test_negative_cycle_rejected() -> None:
with pytest.raises(ValueError):
ch_preprocess([[(1, 2)], [(0, -3)]])
def test_zero_weights_parallel_edges_self_loops() -> None:
adj = [[(1, 0), (1, 4), (0, 0)], [(2, 0)], [(0, 5)]]
ch = ch_preprocess(adj, [1, 0, 2])
assert ch_query(ch, 0, 2) == (0, [0, 1, 2])
assert ch_query(ch, 2, 1) == (5, [2, 0, 1])
def test_random_against_bellman_ford() -> None:
rng = random.Random(2008)
for trial in range(600):
n = rng.randint(1, 9)
adj = [[] for _ in range(n)]
for _ in range(rng.randint(0, 3 * n)):
adj[rng.randrange(n)].append((rng.randrange(n), rng.randint(0, 9)))
if trial % 2:
order = list(range(n))
rng.shuffle(order)
else:
order = None
ch = ch_preprocess(adj, order)
assert sorted(ch.rank) == list(range(n))
for s in range(n):
ref = bellman_ford(adj, s)
for t in range(n):
d, p = ch_query(ch, s, t)
assert d == ref[t]
if d == INF:
assert p is None
else:
assert p[0] == s and p[-1] == t and path_weight(adj, p) == d
What the next level needs
- the tests did not run in this build