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