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
# 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