Algorithm Search⌘K

Italiano's dynamic transitive closure algorithm in Python

Maintains reachability of a directed graph under edge insertions and deletions.

File
italiano_dynamic_transitive_closure.py, 161 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
italiano_dynamic_transitive_closure.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/italiano-dynamic-transitive-closure/. The authors are listed in prov/08JB9Z.jsonl.

"""Italiano's dynamic transitive closure.

For every vertex x the structure keeps a spanning tree desc(x) of the
vertices reachable from x, stored as a parent array. A reachability query is
one array lookup, and a path is read by following parents.

IncrementalClosure supports edge insertions in any directed graph, in
O(n) amortized time per insertion (O(mn + n^2) in total). DecrementalDAGClosure
supports edge deletions in a directed acyclic graph; with a heap keyed by
topological number this implementation takes O(mn log n) time in total.
Both are deterministic. Neither supports both kinds of update.
"""

import heapq

NONE = -2  # not reachable
ROOT = -1  # the root of its own tree


class IncrementalClosure:
    def __init__(self, n: int) -> None:
        self.n = n
        self.par = [[NONE] * n for _ in range(n)]
        self.children: list[list[list[int]]] = [[[] for _ in range(n)] for _ in range(n)]
        for x in range(n):
            self.par[x][x] = ROOT

    def insert(self, i: int, j: int) -> None:
        # @line L1: the edge itself is not stored; the trees hold all we need.
        cj = self.children[j]
        pj = self.par[j]
        for x in range(self.n):  # @line L2
            px = self.par[x]
            if px[i] == NONE or px[j] != NONE:
                continue
            cx = self.children[x]
            stack = [(j, i)]
            while stack:  # @line L3
                w, p = stack.pop()
                if px[w] != NONE:
                    continue  # w and its subtree in desc(j) are already in desc(x)
                px[w] = p  # @line L4
                cx[p].append(w)
                for c in cj[w]:
                    if pj[c] == w:
                        stack.append((c, w))

    def reachable(self, x: int, y: int) -> bool:
        return self.par[x][y] != NONE  # @line L5

    def path(self, x: int, y: int) -> list[int] | None:
        if self.par[x][y] == NONE:
            return None
        out = [y]
        while out[-1] != x:  # @line L6
            out.append(self.par[x][out[-1]])
        out.reverse()
        return out


class DecrementalDAGClosure:
    def __init__(self, n: int, edges: list[tuple[int, int]]) -> None:
        self.n = n
        self.out: list[list[int]] = [[] for _ in range(n)]
        self.inn: list[list[int]] = [[] for _ in range(n)]
        self.alive: dict[tuple[int, int], int] = {}
        for u, v in edges:
            if (u, v) not in self.alive:
                self.out[u].append(v)
                self.inn[v].append(u)
            self.alive[(u, v)] = self.alive.get((u, v), 0) + 1
        self.topo = self._topological_numbers()
        self.par = [[NONE] * n for _ in range(n)]
        self.ptr = [[0] * n for _ in range(n)]
        self.children: list[list[set[int]]] = [[set() for _ in range(n)] for _ in range(n)]
        for x in range(n):
            self._build(x)

    def _topological_numbers(self) -> list[int]:
        indeg = [len(self.inn[v]) for v in range(self.n)]
        order = [v for v in range(self.n) if indeg[v] == 0]
        k = 0
        while k < len(order):
            u = order[k]
            k += 1
            for v in self.out[u]:
                indeg[v] -= 1
                if indeg[v] == 0:
                    order.append(v)
        if len(order) != self.n:
            raise ValueError("the graph has a cycle")
        num = [0] * self.n
        for i, v in enumerate(order):
            num[v] = i
        return num

    def _build(self, x: int) -> None:
        px = self.par[x]
        px[x] = ROOT
        stack = [x]
        seen = {x}
        while stack:
            u = stack.pop()
            for v in self.out[u]:
                if v not in seen:
                    seen.add(v)
                    stack.append(v)
        for v in seen:
            if v != x:
                px[v] = x  # marks v as reachable before the real parents are chosen
        for v in seen:
            if v != x:
                self._hook(x, v)

    def _hook(self, x: int, w: int) -> bool:
        """Advance the pointer of w in desc(x) to an in-neighbor in desc(x)."""
        px, lst = self.par[x], self.inn[w]
        k = self.ptr[x][w]
        while k < len(lst) and (px[lst[k]] == NONE or self.alive.get((lst[k], w), 0) == 0):
            k += 1  # @line L9
        self.ptr[x][w] = k
        if k == len(lst):
            return False
        px[w] = lst[k]
        self.children[x][lst[k]].add(w)
        return True

    def delete(self, u: int, v: int) -> None:
        self.alive[(u, v)] -= 1  # @line L7
        if self.alive[(u, v)] > 0:
            return
        for x in range(self.n):
            if self.par[x][v] != u:  # @line L8
                continue
            px, cx = self.par[x], self.children[x]
            cx[u].discard(v)
            px[v] = NONE  # v is an orphan until it is hooked again
            heap = [(self.topo[v], v)]
            while heap:
                _, w = heapq.heappop(heap)
                if self._hook(x, w):  # @line L10
                    continue
                for c in cx[w]:
                    px[c] = NONE
                    heapq.heappush(heap, (self.topo[c], c))
                cx[w].clear()

    def reachable(self, x: int, y: int) -> bool:
        return self.par[x][y] != NONE

    def path(self, x: int, y: int) -> list[int] | None:
        if self.par[x][y] == NONE:
            return None
        out = [y]
        while out[-1] != x:
            out.append(self.par[x][out[-1]])
        out.reverse()
        return out
test_italiano_dynamic_transitive_closure.py 106 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/italiano-dynamic-transitive-closure/. The authors are listed in prov/08JB9Z.jsonl.

import random

import pytest

from italiano_dynamic_transitive_closure import DecrementalDAGClosure, IncrementalClosure


def _closure(n: int, edges) -> list[set[int]]:
    adj: list[list[int]] = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
    out = []
    for s in range(n):
        seen = {s}
        st = [s]
        while st:
            x = st.pop()
            for y in adj[x]:
                if y not in seen:
                    seen.add(y)
                    st.append(y)
        out.append(seen)
    return out


def _check(g, n: int, edges) -> None:
    cl = _closure(n, edges)
    es = set(edges)
    for x in range(n):
        for y in range(n):
            assert g.reachable(x, y) == (y in cl[x])
            p = g.path(x, y)
            if y in cl[x]:
                assert p[0] == x and p[-1] == y
                assert all((p[k], p[k + 1]) in es for k in range(len(p) - 1))
            else:
                assert p is None


def test_example() -> None:
    g = IncrementalClosure(5)
    for e in [(0, 1), (2, 3), (3, 4), (1, 2), (4, 0)]:
        g.insert(*e)
    assert g.reachable(3, 2)
    assert g.path(0, 4) == [0, 1, 2, 3, 4]
    d = DecrementalDAGClosure(5, [(0, 1), (0, 2), (1, 3), (2, 3), (3, 4)])
    d.delete(1, 3)
    assert d.reachable(0, 4) and d.path(0, 4) == [0, 2, 3, 4]
    d.delete(2, 3)
    assert not d.reachable(0, 4) and d.reachable(3, 4)


def test_rejects_cycle() -> None:
    with pytest.raises(ValueError):
        DecrementalDAGClosure(2, [(0, 1), (1, 0)])


def test_random_incremental() -> None:
    rng = random.Random(3)
    for _ in range(300):
        n = rng.randint(1, 8)
        g = IncrementalClosure(n)
        edges = []
        for _ in range(rng.randint(0, 25)):
            e = (rng.randrange(n), rng.randrange(n))
            g.insert(*e)
            edges.append(e)
            _check(g, n, edges)


def test_random_decremental_dag() -> None:
    rng = random.Random(5)
    for _ in range(300):
        n = rng.randint(1, 8)
        perm = list(range(n))
        rng.shuffle(perm)
        edges = []
        for _ in range(rng.randint(0, 25)):
            a, b = rng.randrange(n), rng.randrange(n)
            if a != b:
                if perm[a] > perm[b]:
                    a, b = b, a
                edges.append((a, b))
        g = DecrementalDAGClosure(n, edges)
        _check(g, n, edges)
        rng.shuffle(edges)
        while edges:
            e = edges.pop()
            g.delete(*e)
            _check(g, n, edges)


def test_long_chain() -> None:
    n = 400
    g = IncrementalClosure(n)
    for i in range(n - 1, 0, -1):
        g.insert(i - 1, i)
    assert g.reachable(0, n - 1) and not g.reachable(n - 1, 0)
    g.insert(n - 1, 0)
    assert g.reachable(n - 1, 5)
    d = DecrementalDAGClosure(n, [(i, i + 1) for i in range(n - 1)])
    d.delete(n // 2, n // 2 + 1)
    assert not d.reachable(0, n - 1) and d.reachable(0, n // 2)

What the next level needs

  • the tests did not run in this build

Read the full page about Italiano's dynamic transitive closure algorithm