Algorithm Search⌘K

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
contraction_hierarchies.pyRawGitHub
# 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

Read the full page about Contraction hierarchies