Algorithm Search⌘K

Constructing a tree decomposition in Python

Builds a tree decomposition of small width for a graph, or reports that the width exceeds a given bound.

File
tree_decomposition_construction.py, 163 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
tree_decomposition_construction.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/tree-decomposition-construction/. The authors are listed in prov/EC5HWV.jsonl.
"""Tree decomposition of width at most 4k+4, or a proof that treewidth > k.

The graph is simple and undirected, with the vertices 0..n-1, given as an
edge list. It is not weighted. The result is None when the treewidth is
larger than k, and otherwise a pair (bags, tree_edges): bags[i] is a sorted
list of vertices, and tree_edges lists pairs (parent, child) of bag indices.
The method is the separator recursion of Robertson and Seymour, done with
an explicit stack and a unit-capacity flow for the separators.
"""

from collections import deque
from itertools import combinations


def _components(adj, allowed):
    """Connected components of the subgraph induced by the set `allowed`."""
    seen = set()
    comps = []
    for s in sorted(allowed):
        if s in seen:
            continue
        seen.add(s)
        comp = [s]
        queue = deque([s])
        while queue:
            u = queue.popleft()
            for w in adj[u]:
                if w in allowed and w not in seen:
                    seen.add(w)
                    comp.append(w)
                    queue.append(w)
        comps.append(comp)
    return comps


def _separator(adj, inner, side_a, side_b, limit):
    """A set Y of at most `limit` vertices of `inner` that meets every path
    from side_a to side_b inside inner + side_a + side_b, or None.

    Vertex capacities are 1 on `inner` and unbounded on the two sides; the
    flow is found by at most limit + 1 augmenting paths."""
    space = inner | side_a | side_b
    # node (v, 0) is the entry of v, (v, 1) its exit; cap holds residual capacities
    cap = {}
    graph = {}

    def arc(x, y, c):
        graph.setdefault(x, []).append(y)
        graph.setdefault(y, []).append(x)
        cap[(x, y)] = cap.get((x, y), 0) + c
        cap.setdefault((y, x), 0)

    big = len(space) + 1
    for v in space:
        arc((v, 0), (v, 1), 1 if v in inner else big)
        for w in adj[v]:
            if w in space:
                arc((v, 1), (w, 0), big)
    src, snk = ("s", 0), ("t", 0)
    for v in side_a:
        arc(src, (v, 0), big)
    for v in side_b:
        arc((v, 1), snk, big)
    flow = 0
    while True:
        prev = {src: None}
        queue = deque([src])
        while queue and snk not in prev:
            x = queue.popleft()
            for y in graph.get(x, ()):
                if y not in prev and cap[(x, y)] > 0:
                    prev[y] = x
                    queue.append(y)
        if snk not in prev:
            break
        flow += 1
        if flow > limit:
            return None
        y = snk
        while prev[y] is not None:
            x = prev[y]
            cap[(x, y)] -= 1
            cap[(y, x)] += 1
            y = x
    # the cut: inner vertices whose entry is reachable and exit is not
    return {v for v in inner if (v, 0) in prev and (v, 1) not in prev}


def _balanced_split(adj, comp, s, k):
    """Try every split of s into (A, X, B) with |X| <= k+1 and |A|, |B| <= 2k+2,
    and return X together with a separator inside comp, or None."""
    s = sorted(s)
    inner = set(comp)
    for size in range(k + 2):
        for sx in combinations(s, size):
            rest = [v for v in s if v not in sx]
            r = len(rest)
            for mask in range(1 << r):
                a = {rest[i] for i in range(r) if mask >> i & 1}
                if not a or len(a) > 2 * k + 2 or r - len(a) > 2 * k + 2:
                    continue
                if rest[0] not in a:  # (A, B) and (B, A) are the same split
                    continue
                b = set(rest) - a
                y = _separator(adj, inner, a, b, k + 1 - size)
                if y is not None:
                    return set(sx) | y
    return None


def tree_decomposition(n, edges, k):
    adj = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    if len(edges) > k * n:  # @line L1
        return None
    bags = []
    tree_edges = []
    stack = [(comp, set(), -1) for comp in _components(adj, set(range(n)))]  # @line L2
    while stack:  # @line L3
        comp, s, parent = stack.pop()  # @line L4
        if len(s) < 3 * k + 4:  # @line L5
            bag = s | {comp[0]}
        else:
            x = _balanced_split(adj, comp, s, k)  # @line L6
            if x is None:  # @line L7
                return None
            bag = s | x  # @line L8
        node = len(bags)  # @line L9
        bags.append(sorted(bag))
        if parent >= 0:
            tree_edges.append((parent, node))
        rest = set(comp) - bag
        for d in _components(adj, rest):  # @line L10
            nd = {w for u in d for w in adj[u] if w not in rest}
            stack.append((d, nd, node))  # @line L11
    return bags, tree_edges  # @line L12


def is_tree_decomposition(n, edges, bags, tree_edges):
    """Check the three conditions and that the bags form a forest."""
    m = len(bags)
    if n > 0 and m == 0:
        return False
    tadj = [[] for _ in range(m)]
    for a, b in tree_edges:
        tadj[a].append(b)
        tadj[b].append(a)
    if len(tree_edges) != m - len(_components(tadj, set(range(m)))):
        return False  # a cycle among the bags
    where = [set() for _ in range(n)]
    for i, bag in enumerate(bags):
        for v in bag:
            where[v].add(i)
    if any(not w for w in where):
        return False
    for u, v in edges:
        if not (where[u] & where[v]):
            return False
    return all(len(_components(tadj, w)) == 1 for w in where)
test_tree_decomposition_construction.py 150 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/tree-decomposition-construction/. The authors are listed in prov/EC5HWV.jsonl.
import random
import unittest
from functools import lru_cache

from tree_decomposition_construction import is_tree_decomposition, tree_decomposition


def exact_treewidth(n, edges):
    """Oracle: dynamic programming over vertex subsets and elimination orders."""
    if n == 0:
        return -1
    adj = [0] * n
    for u, v in edges:
        adj[u] |= 1 << v
        adj[v] |= 1 << u

    def q(s, v):
        # vertices outside s + v reachable from v through vertices of s
        seen = 1 << v
        frontier = [v]
        out = 0
        while frontier:
            u = frontier.pop()
            nb = adj[u] & ~seen
            seen |= nb
            out |= nb & ~s
            inside = nb & s
            while inside:
                low = inside & -inside
                frontier.append(low.bit_length() - 1)
                inside ^= low
        return bin(out).count("1")

    @lru_cache(maxsize=None)
    def tw(s):
        if s == 0:
            return -1
        best = n
        rest = s
        while rest:
            low = rest & -rest
            v = low.bit_length() - 1
            rest ^= low
            best = min(best, max(tw(s ^ low), q(s ^ low, v)))
        return best

    return tw((1 << n) - 1)


def width(bags):
    return max(len(b) for b in bags) - 1


def random_graph(n, rng):
    p = rng.choice([0.15, 0.3, 0.5])
    return [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < p]


def partial_k_tree(n, k, rng):
    """A random subgraph of a random k-tree: treewidth at most k."""
    base = list(range(min(n, k + 1)))
    edges = {(u, v) for u in base for v in base if u < v}
    cliques = [[w for w in base if w != d] for d in base]
    for v in range(k + 1, n):
        c = rng.choice(cliques)
        edges.update((u, v) for u in c)
        cliques.extend([w for w in c if w != d] + [v] for d in c)
    return [e for e in sorted(edges) if rng.random() < 0.8]


class TestTreeDecomposition(unittest.TestCase):
    def check(self, n, edges, k):
        res = tree_decomposition(n, edges, k)
        if res is not None:
            bags, tree_edges = res
            self.assertTrue(is_tree_decomposition(n, edges, bags, tree_edges))
            self.assertLessEqual(width(bags), 4 * k + 4)
        return res

    def test_page_example(self):
        # the triangle strip: treewidth 2
        edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (3, 5), (4, 5)]
        self.assertIsNotNone(self.check(6, edges, 2))
        self.assertIsNone(tree_decomposition(6, edges, 0))
        self.assertEqual(exact_treewidth(6, edges), 2)

    def test_small(self):
        self.assertEqual(tree_decomposition(1, [], 0), ([[0]], []))
        self.assertIsNotNone(self.check(2, [(0, 1)], 1))
        self.assertIsNone(tree_decomposition(2, [(0, 1)], 0))
        k5 = [(u, v) for u in range(5) for v in range(u + 1, 5)]
        self.assertIsNone(tree_decomposition(5, k5, 1))
        self.assertIsNotNone(self.check(5, k5, 4))

    def test_star(self):
        n = 50
        self.assertIsNotNone(self.check(n, [(0, v) for v in range(1, n)], 1))

    def test_long_path(self):
        n = 3000  # deeper than the default recursion limit
        edges = [(v, v + 1) for v in range(n - 1)]
        bags, tree_edges = tree_decomposition(n, edges, 1)
        self.assertTrue(is_tree_decomposition(n, edges, bags, tree_edges))

    def test_grid_fails(self):
        # the 4 by 4 grid has treewidth 4
        idx = lambda r, c: 4 * r + c
        edges = [(idx(r, c), idx(r, c + 1)) for r in range(4) for c in range(3)]
        edges += [(idx(r, c), idx(r + 1, c)) for r in range(3) for c in range(4)]
        self.assertIsNone(tree_decomposition(16, edges, 0))
        self.assertIsNotNone(self.check(16, edges, 4))

    def test_spider_uses_separators(self):
        # tips 0..8 are taken first, so a bag boundary of size 3k+4 = 7 appears
        legs = 9
        n = 2 * legs + 1
        edges = [(i, legs + i) for i in range(legs)] + [(legs + i, n - 1) for i in range(legs)]
        self.assertIsNotNone(self.check(n, edges, 1))

    def test_bounded_treewidth_never_fails(self):
        rng = random.Random(1187)
        for _ in range(120):
            k = rng.choice([1, 2])
            n = rng.randint(10, 35)
            edges = partial_k_tree(n, k, rng)
            perm = list(range(n))
            rng.shuffle(perm)
            edges = [(perm[u], perm[v]) for u, v in edges]
            self.assertIsNotNone(self.check(n, edges, k))
        self.check(16, [(4 * r + c, 4 * r + c + 1) for r in range(4) for c in range(3)]
                   + [(4 * r + c, 4 * r + c + 4) for r in range(3) for c in range(4)], 1)

    def test_random_against_oracle(self):
        rng = random.Random(7041)
        for _ in range(600):
            n = rng.randint(1, 9)
            edges = random_graph(n, rng)
            tw = exact_treewidth(n, edges)
            k = rng.randint(0, 3)
            res = self.check(n, edges, k)
            if tw <= k:
                self.assertIsNotNone(res)
            if res is None:
                self.assertGreater(tw, k)


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Constructing a tree decomposition