Algorithm Search⌘K

Thorup's linear-time undirected SSSP in Python

Solves SSSP on undirected graphs with positive integer weights in O(m) time, beating Dijkstra without sorting.

File
thorup_undirected_sssp.py, 100 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
thorup_undirected_sssp.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thorup-undirected-sssp/. The authors are listed in prov/J10R6J.jsonl.

"""Thorup's SSSP for undirected graphs with positive integer weights.

The component hierarchy and the bucketed visiting order follow Thorup (1999).
To stay short, this version finds the least tentative distance in a component
with a lazy binary heap per hierarchy node instead of Thorup's split-findmin
structure and bucket arrays, so it runs in O(m r log n) time for r levels, not
O(m). The order in which it settles vertices is one that Thorup's rule allows.
"""

import heapq
import math


def _find(parent: list[int], x: int) -> int:
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x


def thorup_undirected_sssp(n: int, edges: list[tuple[int, int, int]], s: int, trace: list | None = None) -> list[int | None]:
    """Distances from s in an undirected graph with positive integer weights.

    Vertices are 0 .. n-1; edges lists (u, v, w), each usable both ways. Returns
    dist with None for vertices not reachable from s. Raises ValueError on a
    weight that is not a positive integer (a negative edge u-v would give the
    negative closed walk u, v, u). If trace is a list, each call
    (level, lowest vertex of the component, bound) is appended to it.
    """
    adj: list[list[tuple[int, int]]] = [[] for _ in range(n)]
    big = 1
    for u, v, w in edges:
        if not isinstance(w, int) or isinstance(w, bool) or w < 1:
            raise ValueError(f"weight {w!r} is not a positive integer")
        adj[u].append((v, w))
        adj[v].append((u, w))
        big = max(big, w)
    # @line L1
    r = big.bit_length()  # every weight is < 2^r
    # anc[i][v] = id of the level-i component of v; level i joins edges of weight < 2^i
    uf = list(range(n))
    by_weight = sorted(edges, key=lambda e: e[2])
    anc: list[list[int]] = []
    j = 0
    for i in range(r + 1):
        while j < len(by_weight) and by_weight[j][2] < (1 << i):
            a, b = _find(uf, by_weight[j][0]), _find(uf, by_weight[j][1])
            if a != b:
                uf[a] = b
            j += 1
        anc.append([_find(uf, v) for v in range(n)])
    heaps = [dict() for _ in range(r + 1)]  # heaps[i][component id] = list of (D, v)
    dist = [math.inf] * n
    done = [False] * n

    def push(v: int) -> None:
        for i in range(r + 1):
            heapq.heappush(heaps[i].setdefault(anc[i][v], []), (dist[v], v))

    def lowest(i: int, comp: int):
        h = heaps[i].get(comp, [])
        while h and (done[h[0][1]] or h[0][0] != dist[h[0][1]]):
            heapq.heappop(h)
        return h[0] if h else None

    def visit(i: int, comp: int, hi: float) -> None:
        if trace is not None:
            trace.append((i, min(v for v in range(n) if anc[i][v] == comp), hi))
        # @line L5
        if i == 0:
            # @line L6
            v = comp
            done[v] = True
            for x, w in adj[v]:
                # @line L7
                if not done[x] and dist[v] + w < dist[x]:
                    dist[x] = dist[v] + w
                    push(x)
            return
        while True:
            # @line L8
            top = lowest(i, comp)
            if top is None or top[0] >= hi:
                return
            # @line L9
            d, v = top
            b = d >> (i - 1)
            # @line L10
            visit(i - 1, anc[i - 1][v], (b + 1) << (i - 1))

    # @line L2
    dist[s] = 0
    push(s)
    # @line L3
    visit(r, anc[r][s], math.inf)
    # @line L4
    return [None if d == math.inf else d for d in dist]
test_thorup_undirected_sssp.py 72 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/thorup-undirected-sssp/. The authors are listed in prov/J10R6J.jsonl.

import heapq
import math
import random

import pytest

from thorup_undirected_sssp import thorup_undirected_sssp

S, A, B, C, D = range(5)
EXAMPLE = [(S, A, 1), (A, B, 2), (S, C, 5), (B, C, 1), (C, D, 3), (B, D, 6)]


def dijkstra(n, edges, s):
    """Independent oracle: Dijkstra with a binary heap on the undirected graph."""
    adj = [[] for _ in range(n)]
    for u, v, w in edges:
        adj[u].append((v, w))
        adj[v].append((u, w))
    dist = [None] * n
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if dist[u] is not None:
            continue
        dist[u] = d
        for v, w in adj[u]:
            if dist[v] is None:
                heapq.heappush(heap, (d + w, v))
    return dist


def test_example_and_trace():
    trace = []
    assert thorup_undirected_sssp(5, EXAMPLE, S, trace) == [0, 1, 3, 4, 7]
    assert trace[0] == (3, S, math.inf)
    assert (1, B, 4) in trace and (1, B, 6) in trace
    assert sum(1 for level, _, _ in trace if level == 0) == 5


def test_unreachable_vertices():
    assert thorup_undirected_sssp(4, [(0, 1, 3), (2, 3, 1)], 0) == [0, 3, None, None]
    assert thorup_undirected_sssp(1, [], 0) == [0]


def test_rejects_negative_zero_and_non_integer_weights():
    for bad in (-2, 0, 1.5, True):
        with pytest.raises(ValueError):
            thorup_undirected_sssp(2, [(0, 1, bad)], 0)


def test_parallel_edges_and_self_loops():
    edges = [(0, 1, 9), (0, 1, 4), (1, 1, 1), (1, 2, 4), (0, 2, 9)]
    assert thorup_undirected_sssp(3, edges, 0) == [0, 4, 8]


def test_large_weights():
    edges = [(0, 1, 2**40), (1, 2, 1), (0, 2, 2**40 + 2)]
    assert thorup_undirected_sssp(3, edges, 0) == [0, 2**40, 2**40 + 1]


def test_random_against_dijkstra():
    rng = random.Random(20261105)
    for _ in range(1500):
        n = rng.randint(1, 12)
        m = rng.randint(0, 3 * n)
        weights = rng.choice([[1, 2, 3], [1, 2, 4, 8, 16, 32], list(range(1, 100))])
        edges = [(rng.randrange(n), rng.randrange(n), rng.choice(weights)) for _ in range(m)]
        s = rng.randrange(n)
        assert thorup_undirected_sssp(n, edges, s) == dijkstra(n, edges, s)

What the next level needs

  • the tests did not run in this build

Read the full page about Thorup's linear-time undirected SSSP