Algorithm Search⌘K

Balas additive algorithm in Python

Implicit enumeration for 0-1 integer programs using additive bounds and dominance tests, without LP.

File
balas_additive_algorithm.py, 67 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
balas_additive_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/balas-additive-algorithm/. The authors are listed in prov/MXCW04.jsonl.
"""Balas's additive algorithm (implicit enumeration) for 0-1 linear programs.

Minimize c x subject to A x <= b and x in {0, 1}^n, with every c_j >= 0.
The search is depth-first with an explicit stack. It uses only additions,
subtractions and comparisons of the data.
"""

from __future__ import annotations

from typing import Optional, Sequence


def balas(
    c: Sequence[int],
    a: Sequence[Sequence[int]],
    b: Sequence[int],
    stats: Optional[dict[str, int]] = None,
    trace: Optional[list[tuple[tuple[int, ...], str]]] = None,
) -> tuple[Optional[int], Optional[list[int]]]:
    """Return (z, x) with x an optimal 0-1 vector and z = c x, or (None, None).

    a has one row per constraint; row i reads sum_j a[i][j] x_j <= b[i].
    """
    n, m = len(c), len(b)
    if any(cj < 0 for cj in c):
        raise ValueError("all costs must be nonnegative; complement x_j to 1 - x_j first")
    if stats is not None:
        stats.update(nodes=0)
    z: Optional[int] = None  # @line L1
    best: Optional[tuple[int, ...]] = None
    stack: list[tuple[tuple[int, ...], int, tuple[int, ...], tuple[int, ...]]] = [((), 0, tuple(b), tuple(range(n)))]
    while stack:  # @line L2
        ones, v, s, free = stack.pop()  # @line L3
        if stats is not None:
            stats["nodes"] += 1
        if all(si >= 0 for si in s):  # @line L4
            if z is None or v < z:  # @line L5
                z, best = v, ones
            if trace is not None:
                trace.append((ones, "feasible"))
            continue  # @line L6
        bad = [i for i in range(m) if s[i] < 0]
        useful = [  # @line L7
            j for j in free
            if (z is None or v + c[j] < z) and any(a[i][j] < 0 for i in bad)
        ]
        if not useful or any(  # @line L8
            s[i] - sum(a[i][j] for j in useful if a[i][j] < 0) < 0 for i in bad
        ):
            if trace is not None:
                trace.append((ones, "fathomed"))
            continue
        # Balas's choice: the variable that leaves the least total infeasibility.
        j = max(useful, key=lambda j: (sum(min(0, s[i] - a[i][j]) for i in range(m)), -j))  # @line L9
        if trace is not None:
            trace.append((ones, "branch %d" % j))
        rest = tuple(k for k in free if k != j)
        stack.append((ones, v, s, rest))  # @line L10
        stack.append((ones + (j,), v + c[j], tuple(s[i] - a[i][j] for i in range(m)), rest))  # @line L11
    if best is None:  # @line L12
        return None, None
    x = [0] * n
    for j in best:
        x[j] = 1
    return z, x
test_balas_additive_algorithm.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/balas-additive-algorithm/. The authors are listed in prov/MXCW04.jsonl.

import itertools
import random

import pytest

from balas_additive_algorithm import balas

C = [3, 5, 2, 6, 3]
A = [[-5, 1, 1, -4, -4], [-3, 0, 2, 4, 2], [1, -2, -2, 0, 5]]
B = [-1, -1, 0]


def brute(c, a, b):
    n = len(c)
    best = None
    for x in itertools.product([0, 1], repeat=n):
        if all(sum(r[j] * x[j] for j in range(n)) <= bi for r, bi in zip(a, b)):
            v = sum(cj * xj for cj, xj in zip(c, x))
            if best is None or v < best:
                best = v
    return best


def test_example_of_the_page() -> None:
    stats: dict[str, int] = {}
    trace: list = []
    z, x = balas(C, A, B, stats, trace)
    assert z == 5 and x == [1, 0, 1, 0, 0]
    assert stats == {"nodes": 7}
    assert trace == [
        ((), "branch 0"),
        ((0,), "branch 1"),
        ((0, 1), "feasible"),
        ((0,), "branch 2"),
        ((0, 2), "feasible"),
        ((0,), "fathomed"),
        ((), "fathomed"),
    ]


def test_edge_cases() -> None:
    assert balas([], [], []) == (0, [])
    assert balas([], [[]], [-1]) == (None, None)
    assert balas([4, 1], [], []) == (0, [0, 0])
    assert balas([4, 1], [[-1, -1]], [-1]) == (1, [0, 1])
    assert balas([4, 1], [[-1, -1]], [-3]) == (None, None)
    assert balas([2, 2], [[-1, -1]], [-2]) == (4, [1, 1])


def test_negative_cost_rejected() -> None:
    with pytest.raises(ValueError):
        balas([-1], [], [])


def test_jeroslow_like_parity() -> None:
    # 2 x_1 + ... + 2 x_n = n with n odd, written as two inequalities: no solution.
    n = 7
    a = [[2] * n, [-2] * n]
    assert balas([1] * n, a, [n, -n]) == (None, None)


def test_random_against_brute_force() -> None:
    rng = random.Random(1965)
    for _ in range(800):
        n = rng.randint(0, 10)
        m = rng.randint(0, 4)
        c = [rng.randint(0, 9) for _ in range(n)]
        a = [[rng.randint(-5, 5) for _ in range(n)] for _ in range(m)]
        b = [rng.randint(-8, 4) for _ in range(m)]
        z, x = balas(c, a, b)
        assert z == brute(c, a, b)
        if x is not None:
            assert sum(cj * xj for cj, xj in zip(c, x)) == z
            assert all(sum(r[j] * x[j] for j in range(n)) <= bi for r, bi in zip(a, b))


def test_set_cover() -> None:
    # Cover {1, 2, 3, 4} with sets {1, 2}, {2, 3}, {3, 4}, {1, 4}, {1, 2, 3}: each element row reads -sum x <= -1.
    sets = [{1, 2}, {2, 3}, {3, 4}, {1, 4}, {1, 2, 3}]
    a = [[-1 if e in s else 0 for s in sets] for e in range(1, 5)]
    z, x = balas([1] * 5, a, [-1] * 4)
    assert z == 2 and z == brute([1] * 5, a, [-1] * 4)

What the next level needs

  • the tests did not run in this build

Read the full page about Balas additive algorithm