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