Branch and reduce in Python
Exact branching algorithm that applies reduction rules at each node before branching, giving fast exponential bounds.
- File
branch_and_reduce.py, 111 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/branch-and-reduce/. The authors are listed in prov/HSNMTD.jsonl.
"""Branch and reduce for maximum independent set.
Reduction rules: take a vertex of degree 0 or 1; delete a vertex u that has a
neighbor v with N[v] a subset of N[u]. When every vertex has degree 2, the graph
is a union of cycles and is solved directly. Otherwise branch on a vertex of
maximum degree: leave it out, or take it and delete its closed neighborhood.
The search uses an explicit stack, so deep trees do not hit the recursion limit.
"""
from __future__ import annotations
from typing import Iterable, Optional
Graph = dict[int, set[int]]
def delete(g: Graph, vs: Iterable[int]) -> None:
"""Delete the vertices vs from g, in place."""
for v in list(vs):
if v in g:
for u in g.pop(v):
if u in g:
g[u].discard(v)
def reduce_graph(g: Graph, chosen: list[int], stats: Optional[dict[str, int]] = None) -> None:
"""Apply the reduction rules until none applies. Taken vertices go to chosen."""
while True:
v = next((x for x in g if len(g[x]) <= 1), None)
if v is not None:
if not g[v]: # @line L5
chosen.append(v)
delete(g, [v])
else: # @line L6
chosen.append(v)
delete(g, [v, *g[v]])
if stats is not None:
stats["reductions"] += 1
continue
hit = None
for v in g: # @line L7
closed = g[v] | {v}
for u in g[v]:
if closed <= g[u] | {u}:
hit = u
break
if hit is not None:
break
if hit is None:
return
delete(g, [hit])
if stats is not None:
stats["reductions"] += 1
def cycles_mis(g: Graph) -> list[int]:
"""Maximum independent set of a graph in which every vertex has degree 2."""
out: list[int] = []
seen: set[int] = set()
for start in sorted(g):
if start in seen:
continue
cycle = [start]
seen.add(start)
prev, cur = None, start
while True:
nxt = min(u for u in g[cur] if u != prev) if prev is not None else min(g[cur])
if nxt == start:
break
cycle.append(nxt)
seen.add(nxt)
prev, cur = cur, nxt
out.extend(cycle[0:2 * (len(cycle) // 2):2])
return out
def max_independent_set(
n: int, edges: Iterable[tuple[int, int]], stats: Optional[dict[str, int]] = None
) -> tuple[int, list[int]]:
"""Return (size, vertices) of a maximum independent set of the graph on 0..n-1."""
if stats is not None:
stats.update(nodes=0, leaves=0, reductions=0)
g0: Graph = {v: set() for v in range(n)}
for u, v in edges:
if u != v:
g0[u].add(v)
g0[v].add(u)
best: list[int] = [] # @line L1
stack: list[tuple[Graph, list[int]]] = [(g0, [])]
while stack: # @line L2
g, chosen = stack.pop() # @line L3
if stats is not None:
stats["nodes"] += 1
reduce_graph(g, chosen, stats) # @line L4
if all(len(g[v]) <= 2 for v in g): # @line L8
chosen = chosen + cycles_mis(g) # @line L9
if stats is not None:
stats["leaves"] += 1
if len(chosen) > len(best):
best = chosen
continue
v = max(g, key=lambda x: (len(g[x]), -x)) # @line L10
g_out = {x: set(nb) for x, nb in g.items()}
delete(g_out, [v])
g_in = {x: set(nb) for x, nb in g.items()}
delete(g_in, [v, *g[v]])
stack.append((g_out, chosen)) # @line L11
stack.append((g_in, chosen + [v])) # @line L12
return len(best), sorted(best) # @line L13
test_branch_and_reduce.py 81 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/branch-and-reduce/. The authors are listed in prov/HSNMTD.jsonl.
import random
from branch_and_reduce import cycles_mis, max_independent_set
CUBE = [(u, u ^ (1 << b)) for u in range(8) for b in range(3) if u < u ^ (1 << b)]
def brute(n, edges):
adj = [0] * n
for u, v in edges:
if u != v:
adj[u] |= 1 << v
adj[v] |= 1 << u
best = 0
for mask in range(1 << n):
if all(not (mask >> v & 1) or not (adj[v] & mask) for v in range(n)):
best = max(best, bin(mask).count("1"))
return best
def is_independent(vs, edges):
s = set(vs)
return len(s) == len(vs) and all(not (u in s and v in s) for u, v in edges if u != v)
def test_example_of_the_page() -> None:
stats: dict[str, int] = {}
size, vs = max_independent_set(8, CUBE, stats)
assert size == 4 and vs == [0, 3, 5, 6]
assert stats == {"nodes": 5, "leaves": 3, "reductions": 9}
def test_edge_cases() -> None:
assert max_independent_set(0, []) == (0, [])
assert max_independent_set(1, []) == (1, [0])
assert max_independent_set(3, []) == (3, [0, 1, 2])
assert max_independent_set(2, [(0, 1)])[0] == 1
assert max_independent_set(2, [(0, 0)]) == (2, [0, 1])
k5 = [(u, v) for u in range(5) for v in range(u + 1, 5)]
assert max_independent_set(5, k5)[0] == 1
def test_cycles() -> None:
for k in range(3, 12):
edges = [(i, (i + 1) % k) for i in range(k)]
size, vs = max_independent_set(k, edges)
assert size == k // 2 and is_independent(vs, edges)
g = {i: {(i - 1) % k, (i + 1) % k} for i in range(k)}
assert len(cycles_mis(g)) == k // 2
def test_petersen() -> None:
edges = [(i, (i + 1) % 5) for i in range(5)] + [(i, i + 5) for i in range(5)]
edges += [(5 + i, 5 + (i + 2) % 5) for i in range(5)]
size, vs = max_independent_set(10, edges)
assert size == 4 and is_independent(vs, edges)
def test_long_path_and_grid() -> None:
n = 3000
size, vs = max_independent_set(n, [(i, i + 1) for i in range(n - 1)])
assert size == n // 2 + n % 2
w = 6
grid = [(r * w + c, r * w + c + 1) for r in range(w) for c in range(w - 1)]
grid += [(r * w + c, (r + 1) * w + c) for r in range(w - 1) for c in range(w)]
size, vs = max_independent_set(w * w, grid)
assert size == w * w // 2 and is_independent(vs, grid)
def test_random_against_brute_force() -> None:
rng = random.Random(1977)
for _ in range(700):
n = rng.randint(0, 13)
p = rng.random()
edges = [(u, v) for u in range(n) for v in range(u + 1, n) if rng.random() < p]
size, vs = max_independent_set(n, edges)
assert size == brute(n, edges) == len(vs)
assert is_independent(vs, edges)
What the next level needs
- the tests did not run in this build