Algorithm Search⌘K

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

Read the full page about Branch and reduce