Block-cut tree in Python
A tree whose nodes are the blocks and articulation points of a connected graph.
- File
block_cut_tree.py, 204 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/block-cut-tree/. The authors are listed in prov/FD84D6.jsonl.
from bisect import bisect_right
def _blocks(n: int, edges: list[tuple[int, int]]) -> tuple[list[list[int]], list[bool]]:
"""Hopcroft-Tarjan with a stack of edges; loops are ignored.
Returns the vertex sets of the blocks with at least one non-loop edge, in the
order in which the search closes them, and the articulation point marks.
"""
adj: list[list[tuple[int, int]]] = [[] for _ in range(n)]
for i, (u, v) in enumerate(edges):
if u != v:
adj[u].append((v, i))
adj[v].append((u, i))
d = [0] * n
low = [0] * n
t = 0
estack: list[int] = []
is_cut = [False] * n
blocks: list[list[int]] = []
for r in range(n):
if d[r]:
continue
children = 0
t += 1
d[r] = low[r] = t
frames = [(r, -1, 0)]
while frames:
u, p, k = frames[-1]
if k < len(adj[u]):
frames[-1] = (u, p, k + 1)
v, e = adj[u][k]
if e == p:
continue
if d[v] == 0:
estack.append(e)
children += u == r
t += 1
d[v] = low[v] = t
frames.append((v, e, 0))
elif d[v] < d[u]:
estack.append(e)
low[u] = min(low[u], d[v])
continue
frames.pop()
if not frames:
break
w = frames[-1][0]
low[w] = min(low[w], low[u])
if low[u] >= d[w]:
verts: set[int] = set()
while True:
x = estack.pop()
verts.update(edges[x])
if x == p:
break
blocks.append(sorted(verts))
if w != r:
is_cut[w] = True
if children >= 2:
is_cut[r] = True
return blocks, is_cut
class BlockCutTree:
"""The block-cut forest of an undirected graph on the vertices 0 .. n-1.
Precondition: edges[i] = (u, v) with 0 <= u, v < n; parallel edges and loops are
allowed, and loops are ignored. Block nodes are 0 .. b-1 and the cut node of the
articulation point x is b + (its index among the articulation points). A vertex
with no non-loop edge gets a block of its own. Each tree is rooted at a block node.
Build time O(n + m) and space O(n + m); node and same_block take O(1) time, and
separates takes O(log n) time.
"""
def __init__(self, n: int, edges: list[tuple[int, int]]) -> None:
blocks, is_cut = _blocks(n, edges) # @line L1
seen = [False] * n
for b in blocks:
for v in b:
seen[v] = True
for v in range(n):
if not seen[v]:
blocks.append([v])
self.n = n
self.blocks = blocks # @line L2
self.cuts = [v for v in range(n) if is_cut[v]]
nb = len(blocks)
self.num_nodes = nb + len(self.cuts)
cut_node = [-1] * n
for j, x in enumerate(self.cuts):
cut_node[x] = nb + j
self.adj: list[list[int]] = [[] for _ in range(self.num_nodes)]
self.vertex_node = [-1] * n
for i, b in enumerate(blocks):
for x in b:
if is_cut[x]: # @line L3
self.adj[i].append(cut_node[x])
self.adj[cut_node[x]].append(i)
else:
self.vertex_node[x] = i # @line L4
for x in self.cuts:
self.vertex_node[x] = cut_node[x]
# @line L5: root each tree at the last block that the search closed for it.
self.parent = [-1] * self.num_nodes
self.tin = [0] * self.num_nodes
self.tout = [0] * self.num_nodes
self.children: list[list[int]] = [[] for _ in range(self.num_nodes)]
self.child_tin: list[list[int]] = [[] for _ in range(self.num_nodes)]
visited = [False] * self.num_nodes
clock = 0
for root in reversed(range(nb)):
if visited[root]:
continue
visited[root] = True
clock += 1
self.tin[root] = clock
stack = [(root, 0)]
while stack:
a, k = stack[-1]
if k < len(self.adj[a]):
stack[-1] = (a, k + 1)
c = self.adj[a][k]
if not visited[c]:
visited[c] = True
self.parent[c] = a
self.children[a].append(c)
clock += 1
self.tin[c] = clock
self.child_tin[a].append(clock)
stack.append((c, 0))
else:
stack.pop()
self.tout[a] = clock
def is_cut_node(self, a: int) -> bool:
return a >= len(self.blocks)
def node(self, v: int) -> int:
"""The tree node of the vertex v: its cut node, or the one block that holds it."""
return self.vertex_node[v]
def ancestor(self, a: int, c: int) -> bool:
"""Is the node a an ancestor of the node c (or equal to it)?"""
return self.tin[a] <= self.tin[c] and self.tout[c] <= self.tout[a] # @line L6
def child_toward(self, a: int, c: int) -> int:
"""The child of a whose subtree holds c; a must be a proper ancestor of c."""
j = bisect_right(self.child_tin[a], self.tin[c]) - 1 # @line L7
return self.children[a][j]
def separates(self, x: int, u: int, v: int) -> bool:
"""Does every path from u to v pass through x? u, v, x distinct, u, v joined."""
if not self.is_cut_node(self.vertex_node[x]): # @line L8
return False
g = self.vertex_node[x] # @line L9
a, b = self.vertex_node[u], self.vertex_node[v]
p, q = self.ancestor(g, a), self.ancestor(g, b)
if p != q: # @line L10
return True
if not p: # @line L11
return False
return self.child_toward(g, a) != self.child_toward(g, b) # @line L12
def same_block(self, u: int, v: int) -> bool:
"""Do the distinct vertices u and v lie in a common block?"""
a, b = self.vertex_node[u], self.vertex_node[v] # @line L13
ca, cb = self.is_cut_node(a), self.is_cut_node(b)
par = self.parent
if not ca and not cb: # @line L14
return a == b
if ca != cb: # @line L15
if cb:
a, b = b, a
return par[b] == a or par[a] == b
pa, pb = par[a], par[b] # @line L16
return (pa >= 0 and par[pa] == b) or (pb >= 0 and par[pb] == a) or pa == pb
def check_invariant(self) -> None:
"""Assert that the structure is a bipartite forest of blocks and cut nodes."""
nb = len(self.blocks)
edges = sum(len(x) for x in self.adj) // 2
roots = sum(1 for a in range(self.num_nodes) if self.parent[a] == -1)
assert edges == self.num_nodes - roots # a forest
for a in range(self.num_nodes):
for c in self.adj[a]:
assert (a < nb) != (c < nb) # bipartite
if self.parent[a] == -1:
assert a < nb # roots are blocks
else:
assert self.ancestor(self.parent[a], a)
for j, x in enumerate(self.cuts):
assert len(self.adj[nb + j]) >= 2 # a cut vertex lies in two blocks
assert self.vertex_node[x] == nb + j
covered = [0] * self.n
for b in self.blocks:
for x in b:
covered[x] += 1
for x in range(self.n):
assert covered[x] >= 1
assert (covered[x] >= 2) == (self.vertex_node[x] >= nb)
test_block_cut_tree.py 90 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/block-cut-tree/. The authors are listed in prov/FD84D6.jsonl.
import random
from block_cut_tree import BlockCutTree
def joined(n: int, edges: list[tuple[int, int]], u: int, v: int, removed: int = -1) -> bool:
adj: list[list[int]] = [[] for _ in range(n)]
for a, b in edges:
adj[a].append(b)
adj[b].append(a)
seen = {u}
stack = [u]
while stack:
a = stack.pop()
for b in adj[a]:
if b != removed and b not in seen:
seen.add(b)
stack.append(b)
return v in seen
def brute_same_block(n: int, edges: list[tuple[int, int]], u: int, v: int) -> bool:
"""Adjacent, or joined and not separated by any one other vertex (Menger)."""
if any({a, b} == {u, v} for a, b in edges):
return True
if not joined(n, edges, u, v):
return False
return all(joined(n, edges, u, v, x) for x in range(n) if x not in (u, v))
def check(n: int, edges: list[tuple[int, int]]) -> None:
t = BlockCutTree(n, edges)
t.check_invariant()
for u in range(n):
for v in range(n):
if u == v:
continue
assert t.same_block(u, v) == brute_same_block(n, edges, u, v)
if not joined(n, edges, u, v):
continue
for x in range(n):
if x not in (u, v):
assert t.separates(x, u, v) == (not joined(n, edges, u, v, x))
def test_example() -> None:
edges = [(0, 1), (0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 5), (5, 3), (5, 6), (6, 6)]
t = BlockCutTree(7, edges)
t.check_invariant()
assert t.blocks == [[5, 6], [3, 4, 5], [2, 3], [0, 1, 2]]
assert t.cuts == [2, 3, 5]
assert t.num_nodes == 7
assert t.separates(3, 0, 6) and not t.separates(3, 4, 5)
assert t.separates(2, 1, 4) and not t.separates(2, 0, 1)
assert t.same_block(2, 3) and t.same_block(3, 5) and not t.same_block(2, 5)
check(7, edges)
def test_edges() -> None:
t = BlockCutTree(0, [])
t.check_invariant()
assert t.num_nodes == 0
t = BlockCutTree(3, [(1, 1), (1, 1)]) # isolated vertices and loops only
t.check_invariant()
assert t.blocks == [[0], [1], [2]] and t.cuts == []
check(3, [(1, 1)])
check(2, [(0, 1), (0, 1)])
check(4, [(0, 1), (0, 2), (0, 3)]) # a star: one cut node, three blocks
check(5, [(0, 1), (1, 2), (2, 0), (0, 3), (3, 4), (4, 0)]) # a bowtie
check(6, [(0, 1), (2, 3), (3, 4), (4, 2), (5, 5)]) # three components
def test_long_path() -> None:
n = 20000
t = BlockCutTree(n, [(i, i + 1) for i in range(n - 1)])
t.check_invariant()
assert len(t.cuts) == n - 2
assert t.separates(n // 2, 0, n - 1) and not t.same_block(0, 2)
def test_random() -> None:
rng = random.Random(1966)
for _ in range(600):
n = rng.randint(1, 7)
m = rng.randint(0, 10)
edges = [(rng.randrange(n), rng.randrange(n)) for _ in range(m)]
check(n, edges)
What the next level needs
- the tests did not run in this build