Highway hierarchies in Python
Multilevel preprocessing that selects important road-like edges for fast long-distance queries.
- File
highway_hierarchies.py, 125 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/highway-hierarchies/. The authors are listed in prov/B70115.jsonl.
import heapq
import math
INF = math.inf
def _dijkstra(adj, s: int) -> list[float]:
d = [INF] * len(adj)
d[s] = 0
heap = [(0, s)]
while heap:
du, u = heapq.heappop(heap)
if du > d[u]:
continue
for v, w in adj[u]:
if du + w < d[v]:
d[v] = du + w
heapq.heappush(heap, (d[v], v))
return d
def _radius(dist: list[float], h: int) -> float:
"""Distance to the h-th closest vertex (the vertex itself is the first)."""
finite = sorted(x for x in dist if x < INF)
return finite[h - 1] if len(finite) >= h else INF
class HighwayLevel:
"""One level of a highway hierarchy: radii and the set of highway edges."""
def __init__(self, rfwd, rbwd, highway) -> None:
self.rfwd = rfwd # forward neighborhood radius of each vertex
self.rbwd = rbwd # backward neighborhood radius of each vertex
self.highway = highway # set of (u, i): the i-th edge out of u is a highway edge
def hh_preprocess(adj: list[list[tuple[int, float]]], h: int) -> HighwayLevel:
"""Compute the highway network of adj for neighborhood size h >= 1.
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).
An edge (u, v) is a highway edge if it lies on some shortest s-t path with v
outside the forward neighborhood of s and u outside the backward neighborhood
of t. This version tests the definition directly from all-pairs distances,
O(n (m + n log n) + n^2 m) time, which is meant for checking, not for speed.
"""
n = len(adj)
radj: list[list[tuple[int, float]]] = [[] for _ in range(n)]
for u in range(n):
for v, w in adj[u]:
if w < 0:
raise ValueError("highway hierarchies need nonnegative weights")
radj[v].append((u, w))
dist = [_dijkstra(adj, s) for s in range(n)]
rfwd = [_radius(dist[s], h) for s in range(n)] # @line L1
rbwd = [_radius([dist[x][t] for x in range(n)], h) for t in range(n)]
highway = set()
for u in range(n):
for i, (v, w) in enumerate(adj[u]):
for s in range(n):
if dist[s][u] == INF or dist[s][v] <= rfwd[s]:
continue
if any(
dist[s][t] < INF
and dist[s][u] + w + dist[v][t] == dist[s][t]
and dist[u][t] > rbwd[t]
for t in range(n)
):
highway.add((u, i)) # @line L2
break
return HighwayLevel(rfwd, rbwd, highway)
def hh_query(
adj: list[list[tuple[int, float]]], hw: HighwayLevel, s: int, t: int
) -> tuple[float, list[int] | None, int]:
"""Return (dist, path, scans) from s to t, or (math.inf, None, scans)."""
n = len(adj)
if s == t:
return 0, [s], 0
radj: list[list[tuple[int, float, bool]]] = [[] for _ in range(n)]
out: list[list[tuple[int, float, bool]]] = [[] for _ in range(n)]
for u in range(n):
for i, (v, w) in enumerate(adj[u]):
is_hw = (u, i) in hw.highway
out[u].append((v, w, is_hw))
radj[v].append((u, w, is_hw))
graph = [out, radj]
radius = [hw.rfwd[s], hw.rbwd[t]]
dist = [[INF] * n, [INF] * n]
pred: list[list[int | None]] = [[None] * n, [None] * n]
done = [[False] * n, [False] * n]
dist[0][s] = 0
dist[1][t] = 0
heaps = [[(0, s)], [(0, t)]]
mu, meet, scans = INF, None, 0
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 L6
break
du, u = heapq.heappop(heaps[side])
if done[side][u]:
continue
done[side][u] = True
scans += 1
if du + dist[1 - side][u] < mu: # @line L5
mu, meet = du + dist[1 - side][u], u
inside = du <= radius[side] # u is in the neighborhood of the side's root
for v, w, is_hw in graph[side][u]: # @line L3
if (inside or is_hw) and du + w < dist[side][v]: # @line L4
dist[side][v] = du + w
pred[side][v] = u
heapq.heappush(heaps[side], (dist[side][v], v))
if meet is None:
return INF, None, scans
fwd = [meet]
while fwd[-1] != s:
fwd.append(pred[0][fwd[-1]])
bwd = [meet]
while bwd[-1] != t:
bwd.append(pred[1][bwd[-1]])
return mu, fwd[::-1] + bwd[1:], scans # @line L7
test_highway_hierarchies.py 103 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/highway-hierarchies/. The authors are listed in prov/B70115.jsonl.
import math
import random
import pytest
from highway_hierarchies import hh_preprocess, hh_query
INF = math.inf
# main road m0..m6 = 0..6 with 2 km segments, driveway leaves l0..l6 = 7..13 of 1 km
EXAMPLE_EDGES = [(i, i + 1, 2) for i in range(6)] + [(i, 7 + i, 1) for i in range(7)]
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 highway_pairs(adj, hw):
return sorted({(u, adj[u][i][0]) for u, i in hw.highway})
def test_example_highway_edges() -> None:
adj = undirected(14, EXAMPLE_EDGES)
hw = hh_preprocess(adj, 3)
assert hw.rfwd == [2] * 7 + [3] * 7
assert highway_pairs(adj, hw) == sorted(
[(1, 2), (2, 1), (2, 3), (3, 2), (3, 4), (4, 3), (4, 5), (5, 4)]
)
def test_example_query() -> None:
adj = undirected(14, EXAMPLE_EDGES)
d, p, scans = hh_query(adj, hh_preprocess(adj, 3), 7, 13)
assert (d, p) == (14, [7, 0, 1, 2, 3, 4, 5, 6, 13])
# with neighborhoods covering everything, the query is plain bidirectional Dijkstra
assert scans == 16 and hh_query(adj, hh_preprocess(adj, 14), 7, 13)[2] == 26
def test_source_equals_target() -> None:
adj = undirected(14, EXAMPLE_EDGES)
assert hh_query(adj, hh_preprocess(adj, 2), 4, 4) == (0, [4], 0)
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 h in (1, 2, 3, 4):
hw = hh_preprocess(adj, h)
assert hh_query(adj, hw, 0, 3)[:2] == (INF, None)
assert hh_query(adj, hw, 2, 0)[:2] == (INF, None)
assert hh_query(adj, hw, 3, 2)[:2] == (3, [3, 0, 1, 2])
def test_negative_weight_rejected() -> None:
with pytest.raises(ValueError):
hh_preprocess([[(1, -1)], []], 2)
def test_negative_cycle_rejected() -> None:
with pytest.raises(ValueError):
hh_preprocess([[(1, 2)], [(0, -3)]], 2)
def test_random_against_bellman_ford() -> None:
rng = random.Random(2005)
for _ in range(600):
n = rng.randint(1, 8)
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)))
hw = hh_preprocess(adj, rng.randint(1, 4))
for s in range(n):
ref = bellman_ford(adj, s)
for t in range(n):
d, p, _ = hh_query(adj, hw, 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