Algorithm Search⌘K

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
block_cut_tree.pyRawGitHub
# 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

Read the full page about Block-cut tree