Algorithm Search⌘K

Ramalingam-Reps algorithm in Python

Incrementally repairs shortest-path distances after edge changes, affecting only the changed region.

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

"""Ramalingam-Reps fully dynamic single-source shortest paths.

The graph is directed with positive edge weights. The structure keeps the
distance d(v) from a source s and, for every vertex, the number of tight
in-edges (u, v) with d(u) + w(u, v) = d(v). An insertion or a decrease of a
weight runs Dijkstra's algorithm from the head of the edge, only over the
vertices whose distance drops. A deletion or an increase first finds the
affected vertices, those that lose all tight in-edges, and then runs
Dijkstra's algorithm over them only. Each update takes O(A log A) time,
where A counts the vertices whose distance changes plus their incident
edges. Fully dynamic, deterministic, no amortization.
"""

import heapq

INF = float("inf")


class RamalingamReps:
    def __init__(self, n: int, source: int,
                 edges: list[tuple[int, int, int]] = ()) -> None:
        self.n, self.s = n, source
        self.out: list[dict[int, int]] = [dict() for _ in range(n)]
        self.inn: list[dict[int, int]] = [dict() for _ in range(n)]
        for u, v, w in edges:
            self._check(u, v, w)
            self.out[u][v] = w
            self.inn[v][u] = w
        self.d = self._dijkstra()  # @line L1
        self.cnt = [0] * n
        for v in range(n):
            self._recount(v)

    @staticmethod
    def _check(u: int, v: int, w: int) -> None:
        if w <= 0:
            raise ValueError("weights must be positive")
        if u == v:
            raise ValueError("loops are not allowed")

    def _dijkstra(self) -> list[float]:
        d = [INF] * self.n
        d[self.s] = 0
        pq = [(0, self.s)]
        while pq:
            dx, x = heapq.heappop(pq)
            if dx > d[x]:
                continue
            for z, w in self.out[x].items():
                if dx + w < d[z]:
                    d[z] = dx + w
                    heapq.heappush(pq, (d[z], z))
        return d

    def _recount(self, v: int) -> None:
        dv = self.d[v]
        if v == self.s or dv == INF:
            self.cnt[v] = 0
            return
        self.cnt[v] = sum(1 for u, w in self.inn[v].items() if self.d[u] + w == dv)

    def distance(self, v: int) -> float:
        return self.d[v]  # @line L11

    def set_edge(self, u: int, v: int, w: int | None) -> None:
        """Insert, delete (w is None) or change the weight of the edge (u, v)."""
        old = self.out[u].get(v)
        if w is not None:
            self._check(u, v, w)
        if old is not None and (w is None or w > old):
            self._increase(u, v, old, w)
        elif w is not None and (old is None or w < old):
            self._decrease(u, v, w)

    def insert(self, u: int, v: int, w: int) -> None:
        self.set_edge(u, v, w)

    def delete(self, u: int, v: int) -> None:
        self.set_edge(u, v, None)

    def _decrease(self, u: int, v: int, w: int) -> None:
        self.out[u][v] = w
        self.inn[v][u] = w
        cand = self.d[u] + w  # @line L2
        if cand > self.d[v] or v == self.s or cand == INF:
            return  # the edge was not tight before and is not tight now
        if cand == self.d[v]:
            self.cnt[v] += 1  # a new tight edge; no distance changes
            return
        old = {v: self.d[v]}
        self.d[v] = cand  # @line L3
        pq = [(cand, v)]
        changed = {v}
        while pq:  # @line L4
            dx, x = heapq.heappop(pq)
            if dx > self.d[x]:
                continue
            for z, wz in self.out[x].items():
                if dx + wz < self.d[z]:
                    old.setdefault(z, self.d[z])
                    self.d[z] = dx + wz
                    heapq.heappush(pq, (self.d[z], z))
        self._recount_around(old, True)  # @line L5

    def _increase(self, u: int, v: int, old: int, w: int | None) -> None:
        was_tight = v != self.s and self.d[u] + old == self.d[v] < INF
        if w is None:
            del self.out[u][v], self.inn[v][u]
        else:
            self.out[u][v] = w
            self.inn[v][u] = w
        if not was_tight:
            return
        self.cnt[v] -= 1  # @line L6
        if self.cnt[v] > 0:
            return
        # Phase 1: the affected vertices lose all tight in-edges.
        affected = {v: self.d[v]}
        work = [v]
        while work:  # @line L7
            x = work.pop()
            for z, wz in self.out[x].items():
                if z != self.s and z not in affected and self.d[x] + wz == self.d[z]:
                    self.cnt[z] -= 1
                    if self.cnt[z] == 0:
                        affected[z] = self.d[z]
                        work.append(z)
        # Phase 2: Dijkstra over the affected vertices only.
        pq = []
        for x in affected:  # @line L8
            best = INF
            for y, wy in self.inn[x].items():
                if y not in affected and self.d[y] + wy < best:
                    best = self.d[y] + wy
            self.d[x] = best
            if best < INF:
                pq.append((best, x))
        heapq.heapify(pq)
        while pq:  # @line L9
            dx, x = heapq.heappop(pq)
            if dx > self.d[x]:
                continue
            for z, wz in self.out[x].items():
                if z in affected and dx + wz < self.d[z]:
                    self.d[z] = dx + wz
                    heapq.heappush(pq, (self.d[z], z))
        self._recount_around(affected, False)  # @line L10

    def _recount_around(self, old: dict[int, float], undo_old: bool) -> None:
        """Fix the counts after the vertices in old changed their distance.

        A changed vertex is recounted from its in-edges. For an unchanged
        out-neighbor z of a changed vertex x, only the edge (x, z) can change
        its tightness; its old tightness was already removed from the count
        by phase 1 of a deletion, or is removed here after an insertion.
        """
        for x in old:
            self._recount(x)
        for x, dold in old.items():
            dx = self.d[x]
            for z, w in self.out[x].items():
                if z in old or z == self.s:
                    continue
                dz = self.d[z]
                if dz == INF:
                    continue
                if undo_old and dold + w == dz:
                    self.cnt[z] -= 1
                if dx + w == dz:
                    self.cnt[z] += 1
test_ramalingam_reps.py 88 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ramalingam-reps/. The authors are listed in prov/Q56GAS.jsonl.

import heapq
import random

import pytest

from ramalingam_reps import INF, RamalingamReps


def _static(n: int, s: int, edges: dict[tuple[int, int], int]) -> list[float]:
    adj: list[list[tuple[int, int]]] = [[] for _ in range(n)]
    for (u, v), w in edges.items():
        adj[u].append((v, w))
    d = [INF] * n
    d[s] = 0
    pq = [(0, s)]
    while pq:
        dx, x = heapq.heappop(pq)
        if dx > d[x]:
            continue
        for z, w in adj[x]:
            if dx + w < d[z]:
                d[z] = dx + w
                heapq.heappush(pq, (d[z], z))
    return d


def test_example() -> None:
    g = RamalingamReps(5, 0, [(0, 1, 2), (0, 2, 5), (1, 2, 1), (1, 3, 6), (2, 3, 2), (3, 4, 1)])
    assert g.d == [0, 2, 3, 5, 6]
    g.delete(1, 2)
    assert g.d == [0, 2, 5, 7, 8]
    g.insert(4, 1, 1)
    assert g.d == [0, 2, 5, 7, 8]
    g.set_edge(0, 3, 3)
    assert g.d == [0, 2, 5, 3, 4]
    g.delete(0, 1)
    assert g.d == [0, 5, 5, 3, 4]


def test_rejects_bad_weights() -> None:
    g = RamalingamReps(2, 0)
    with pytest.raises(ValueError):
        g.insert(0, 1, 0)


def test_random_against_dijkstra() -> None:
    rng = random.Random(11)
    for _ in range(600):
        n = rng.randint(1, 8)
        s = rng.randrange(n)
        edges: dict[tuple[int, int], int] = {}
        for _ in range(rng.randint(0, 2 * n)):
            u, v = rng.randrange(n), rng.randrange(n)
            if u != v:
                edges[(u, v)] = rng.randint(1, 6)
        g = RamalingamReps(n, s, [(u, v, w) for (u, v), w in edges.items()])
        assert g.d == _static(n, s, edges)
        for _ in range(rng.randint(1, 30)):
            u, v = rng.randrange(n), rng.randrange(n)
            if u == v:
                continue
            if (u, v) in edges and rng.random() < 0.4:
                del edges[(u, v)]
                g.delete(u, v)
            else:
                w = rng.randint(1, 6)
                edges[(u, v)] = w
                g.set_edge(u, v, w)
            assert g.d == _static(n, s, edges)
            for x in range(n):
                cnt = g.cnt[x]
                g._recount(x)
                assert cnt == g.cnt[x]


def test_long_chain() -> None:
    n = 3000
    g = RamalingamReps(n, 0, [(i, i + 1, 1) for i in range(n - 1)])
    assert g.distance(n - 1) == n - 1
    g.insert(0, n // 2, 1)
    assert g.distance(n - 1) == n - n // 2
    g.delete(0, n // 2)
    assert g.distance(n - 1) == n - 1
    g.delete(10, 11)
    assert g.distance(n - 1) == INF

What the next level needs

  • the tests did not run in this build

Read the full page about Ramalingam-Reps algorithm