Thorup-Zwick approximate distance oracle in Python
Preprocesses a graph into a compact structure answering approximate distance queries with stretch 2k-1.
- File
thorup_zwick_oracle.py, 135 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/thorup-zwick-oracle/. The authors are listed in prov/Q2T1JE.jsonl.
import heapq
import math
import random
def _check_weight(w) -> None:
if isinstance(w, bool) or not isinstance(w, (int, float)):
raise ValueError(f"weight {w!r} is not a number")
if not (0 <= w < math.inf):
raise ValueError(f"weight {w!r} is negative or not finite")
class ThorupZwickOracle:
"""Approximate distance oracle of Thorup and Zwick for undirected graphs.
The graph has vertices 0..n-1 and undirected edges (u, v, w) with finite
nonnegative weights. A query returns an estimate e with
d(u, v) <= e <= (2k - 1) d(u, v), and math.inf exactly when u and v are
in different components. With k = 1 the answers are exact.
levels, if given, is the list [A_1, ..., A_{k-1}] of nested vertex sets;
otherwise they are sampled with probability n^(-1/k) using seed.
"""
def __init__(self, n: int, edges, k: int, seed: int = 0, levels=None):
if isinstance(k, bool) or not isinstance(k, int) or k < 1:
raise ValueError("k must be a positive integer")
self.n, self.k = n, k
adj = [[] for _ in range(n)]
for u, v, w in edges:
_check_weight(w)
adj[u].append((v, w))
adj[v].append((u, w))
self._adj = adj
# @line L1
A = [set(range(n))]
if levels is None:
rng = random.Random(seed)
prob = n ** (-1.0 / k) if n > 0 else 0.0
# @line L2
for _ in range(1, k):
A.append({v for v in sorted(A[-1]) if rng.random() < prob})
else:
if len(levels) != k - 1:
raise ValueError("levels must hold k - 1 sets")
for S in levels:
S = set(S)
if not S <= A[-1]:
raise ValueError("levels must be nested")
A.append(S)
A.append(set())
self.levels = A
# @line L3
self.dist_level = [] # dist_level[i][v] = d(A_i, v)
self.pivot = [] # pivot[i][v] = a nearest vertex of A_i, or None
for i in range(k):
d, p = self._nearest(A[i])
self.dist_level.append(d)
self.pivot.append(p)
# @line L4
self.dist_level.append([math.inf] * n)
self.pivot.append([None] * n)
# @line L5
self.bunch = [dict() for _ in range(n)] # bunch[v][w] = d(w, v)
for i in range(k):
bound = self.dist_level[i + 1]
for w in sorted(A[i] - A[i + 1]):
# @line L6
for x, dx in self._grow_cluster(w, bound):
# @line L7
self.bunch[x][w] = dx
# @line L8
def _nearest(self, sources):
"""Multi-source Dijkstra: distance to and a nearest vertex of sources."""
dist = [math.inf] * self.n
piv = [None] * self.n
heap = [(0, s, s) for s in sorted(sources)]
heapq.heapify(heap)
while heap:
d, s, x = heapq.heappop(heap)
if dist[x] <= d:
continue
dist[x], piv[x] = d, s
for y, w in self._adj[x]:
if d + w < dist[y]:
heapq.heappush(heap, (d + w, s, y))
return dist, piv
def _grow_cluster(self, w, bound):
"""Dijkstra from w that admits x with key D only if D < bound[x]."""
done = {}
if not 0 < bound[w]:
return []
heap = [(0, w)]
best = {w: 0}
while heap:
d, x = heapq.heappop(heap)
if x in done:
continue
done[x] = d
for y, wt in self._adj[x]:
nd = d + wt
if y not in done and nd < bound[y] and nd < best.get(y, math.inf):
best[y] = nd
heapq.heappush(heap, (nd, y))
return list(done.items())
def query(self, u: int, v: int) -> float:
"""Return an estimate of d(u, v) with stretch at most 2k - 1."""
# @line L9
w, i = u, 0
# @line L10
while w not in self.bunch[v]:
# @line L11
i += 1
# @line L12
u, v = v, u
# @line L13
if i == self.k or self.pivot[i][u] is None:
return math.inf
# @line L14
w = self.pivot[i][u]
# @line L15
return self.dist_level[i][u] + self.bunch[v][w]
def size(self) -> int:
"""Number of stored bunch entries, the main part of the space."""
return sum(len(b) for b in self.bunch)
test_thorup_zwick_oracle.py 102 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thorup-zwick-oracle/. The authors are listed in prov/Q2T1JE.jsonl.
import heapq
import math
import random
import pytest
from thorup_zwick_oracle import ThorupZwickOracle
A, B, C, D, E, F = range(6)
EXAMPLE = [(A, B, 2), (B, C, 2), (C, D, 1), (D, E, 2), (E, F, 1), (F, A, 3), (B, E, 4)]
def all_pairs(n, edges):
"""Independent oracle: Dijkstra from every vertex."""
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
adj[v].append((u, w))
table = []
for s in range(n):
dist = [math.inf] * n
heap = [(0, s)]
while heap:
d, u = heapq.heappop(heap)
if dist[u] <= d:
continue
dist[u] = d
for v, w in adj[u]:
heapq.heappush(heap, (d + w, v))
table.append(dist)
return table
def test_example():
o = ThorupZwickOracle(6, EXAMPLE, 2, levels=[{C, F}])
assert o.bunch[A] == {A: 0, B: 2, C: 4, F: 3}
assert o.bunch[B] == {B: 0, C: 2, F: 5}
assert o.query(A, B) == 6 # stretch exactly 3 = 2k - 1
assert o.query(B, A) == 2 # the answer depends on the order
assert o.query(C, D) == 1
assert o.size() == 17
def test_k1_is_exact():
o = ThorupZwickOracle(6, EXAMPLE, 1)
d = all_pairs(6, EXAMPLE)
assert all(o.query(u, v) == d[u][v] for u in range(6) for v in range(6))
def test_unreachable_and_single_vertex():
edges = [(0, 1, 1.5), (2, 3, 0)]
for k in (1, 2, 3):
for seed in range(5):
o = ThorupZwickOracle(4, edges, k, seed=seed)
assert o.query(0, 2) == math.inf and o.query(3, 1) == math.inf
assert o.query(2, 3) == 0
assert ThorupZwickOracle(1, [], 3).query(0, 0) == 0
def test_rejects_bad_input():
for bad in (-1, -0.5, math.inf, math.nan, "2", True):
with pytest.raises(ValueError):
ThorupZwickOracle(2, [(0, 1, bad)], 2)
for k in (0, -1, 1.5):
with pytest.raises(ValueError):
ThorupZwickOracle(2, [(0, 1, 1)], k)
with pytest.raises(ValueError):
ThorupZwickOracle(3, [], 3, levels=[{0}, {1}])
def test_random_stretch_and_bunches():
rng = random.Random(20261105)
for case in range(600):
n = rng.randint(1, 14)
m = rng.randint(0, 3 * n)
edges = [(rng.randrange(n), rng.randrange(n), rng.choice([0, 1, 2, 3, 5, 8, 2.5]))
for _ in range(m)]
k = rng.randint(1, 4)
o = ThorupZwickOracle(n, edges, k, seed=case)
d = all_pairs(n, edges)
A = o.levels
for v in range(n):
# the bunch matches its definition, computed from the exact table
expect = {}
for i in range(k):
bound = min((d[a][v] for a in A[i + 1]), default=math.inf)
for w in A[i] - A[i + 1]:
if d[w][v] < bound:
expect[w] = d[w][v]
assert o.bunch[v] == expect
for u in range(n):
for v in range(n):
e = o.query(u, v)
if d[u][v] == math.inf:
assert e == math.inf
else:
assert d[u][v] <= e <= (2 * k - 1) * d[u][v]
if k == 1:
assert e == d[u][v]
What the next level needs
- the tests did not run in this build
Read the full page about Thorup-Zwick approximate distance oracle