Algorithm Search⌘K

Land-Doig algorithm in Python

Original branch-and-bound for integer linear programs, using LP relaxations as bounds and branching on fractional variables.

File
land_doig_algorithm.py, 164 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
land_doig_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/land-doig-algorithm/. The authors are listed in prov/XVSXFR.jsonl.

import heapq
import itertools
import math
from collections.abc import Sequence
from fractions import Fraction

Num = int | Fraction


def solve_lp(c: Sequence[Num], a: Sequence[Sequence[Num]], b: Sequence[Num]) -> tuple[str, list[Fraction], Fraction]:
    """Maximise c x subject to a x <= b and x >= 0, in exact arithmetic.

    Two-phase tableau simplex with Bland's rule, so it always stops.
    Returns (status, x, value) with status "optimal", "infeasible" or
    "unbounded"; x and value are only meaningful when it is "optimal".
    """
    m, n = len(a), len(c)
    # Columns: x (n), slacks (m), artificials (one per row with b < 0).
    neg = [i for i in range(m) if b[i] < 0]
    width = n + m + len(neg)
    rows: list[list[Fraction]] = []
    basis: list[int] = []
    for i in range(m):
        sign = -1 if b[i] < 0 else 1
        row = [Fraction(sign * a[i][j]) for j in range(n)] + [Fraction(0)] * (m + len(neg)) + [Fraction(sign * b[i])]
        row[n + i] = Fraction(sign)
        if sign < 0:
            k = n + m + neg.index(i)
            row[k] = Fraction(1)
            basis.append(k)
        else:
            basis.append(n + i)
        rows.append(row)

    def pivot(r: int, col: int) -> None:
        p = rows[r][col]
        rows[r] = [v / p for v in rows[r]]
        for i in range(m):
            if i != r and rows[i][col] != 0:
                f = rows[i][col]
                rows[i] = [vi - f * vr for vi, vr in zip(rows[i], rows[r])]
        basis[r] = col

    def run(cost: list[Fraction], allowed: int) -> bool:
        # Maximise cost over the current basis; False if unbounded.
        while True:
            red = [cost[j] - sum(cost[basis[i]] * rows[i][j] for i in range(m)) for j in range(allowed)]
            enter = next((j for j in range(allowed) if red[j] > 0 and j not in basis), None)
            if enter is None:
                return True
            best: tuple[Fraction, int, int] | None = None
            for i in range(m):
                if rows[i][enter] > 0:
                    key = (rows[i][-1] / rows[i][enter], basis[i], i)
                    if best is None or key < best:
                        best = key
            if best is None:
                return False
            pivot(best[2], enter)

    if neg:
        phase1 = [Fraction(0)] * (n + m) + [Fraction(-1)] * len(neg)
        run(phase1, width)
        if any(basis[i] >= n + m and rows[i][-1] != 0 for i in range(m)):
            return "infeasible", [], Fraction(0)
        for i in range(m):
            if basis[i] >= n + m:
                col = next((j for j in range(n + m) if rows[i][j] != 0), None)
                if col is not None:
                    pivot(i, col)
    cost = [Fraction(v) for v in c] + [Fraction(0)] * (m + len(neg))
    if not run(cost, n + m):
        return "unbounded", [], Fraction(0)
    x = [Fraction(0)] * n
    for i in range(m):
        if basis[i] < n:
            x[basis[i]] = rows[i][-1]
    return "optimal", x, sum((Fraction(c[j]) * x[j] for j in range(n)), Fraction(0))


Fixing = tuple[tuple[int, int], ...]


def land_doig(
    c: Sequence[Num],
    a: Sequence[Sequence[Num]],
    b: Sequence[Num],
    integer: Sequence[int] | None = None,
    stats: dict[str, int] | None = None,
    trace: list[tuple[Fixing, Fraction]] | None = None,
) -> tuple[Fraction | None, list[Fraction] | None]:
    """Maximise c x subject to a x <= b, x >= 0, x_j integer for j in integer.

    Land and Doig's scheme: each node fixes some integer variables to integer
    values; its bound is the optimum of its linear program. A node with a
    fractional x_j gets two children, x_j = floor and x_j = ceil. When the
    child with x_j = v is taken from the queue, its sibling with the next
    value away from the LP value (v - 1 or v + 1) is made. The LP value is
    concave in the fixed value, so the sibling's bound is at most the child's.
    The feasible set {x >= 0 : a x <= b} must be bounded, for example because
    the constraints bound every variable. Returns (value, x), or (None, None) if
    there is no integer solution. Selection is best-first.
    """
    n = len(c)
    integer = list(range(n)) if integer is None else list(integer)
    if stats is None:
        stats = {}
    stats.update(lps=0, expanded=0)
    tick = itertools.count()

    def lp(fix: Fixing) -> tuple[list[Fraction], Fraction] | None:
        rows = [list(r) for r in a]
        rhs = list(b)
        for j, v in fix:
            e = [0] * n
            e[j] = 1
            rows.append(e)
            rhs.append(v)
            rows.append([-t for t in e])
            rhs.append(-v)
        stats["lps"] += 1
        status, x, val = solve_lp(c, rows, rhs)
        if status == "unbounded":
            raise ValueError("the linear relaxation is unbounded")
        return (x, val) if status == "optimal" else None

    Node = tuple[Fixing, list[Fraction], Fraction, tuple[Fixing, int, int, int] | None]
    heap: list[tuple[Fraction, int, Node]] = []

    def insert(fix: Fixing, sib: tuple[Fixing, int, int, int] | None, z: Fraction | None) -> None:
        r = lp(fix)
        if r is None:
            return  # infeasible: so is every value further away
        x, val = r
        if z is not None and val <= z:
            return  # fathomed: further values are no better
        heapq.heappush(heap, (-val, next(tick), (fix, x, val, sib)))

    z: Fraction | None = None  # @line L1
    best: list[Fraction] | None = None  # @line L1
    insert((), None, None)  # @line L2
    while heap:  # @line L3
        _, _, (fix, x, val, sib) = heapq.heappop(heap)  # @line L4
        if z is not None and val <= z:  # @line L5
            break
        if trace is not None:
            trace.append((fix, val))
        if sib is not None:  # @line L6
            parent, j, v, step = sib
            insert(parent + ((j, v),), (parent, j, v + step, step), z)  # @line L7
        frac = [j for j in integer if x[j].denominator != 1]  # @line L8
        if not frac:  # @line L9
            if z is None or val > z:
                z, best = val, x
            continue
        stats["expanded"] += 1
        j = frac[0]  # @line L10
        lo = math.floor(x[j])
        insert(fix + ((j, lo),), (fix, j, lo - 1, -1), z)  # @line L11
        insert(fix + ((j, lo + 1),), (fix, j, lo + 2, 1), z)  # @line L11
    return z, best  # @line L12
test_land_doig_algorithm.py 66 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/land-doig-algorithm/. The authors are listed in prov/XVSXFR.jsonl.

import itertools
import random
from fractions import Fraction

from land_doig_algorithm import land_doig, solve_lp


def brute(c, a, b, box):
    best = None
    for x in itertools.product(*(range(u + 1) for u in box)):
        if all(sum(r[j] * x[j] for j in range(len(x))) <= 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 = land_doig([5, 4], [[6, 4], [1, 2]], [24, 6], stats=stats, trace=trace)
    assert z == 20 and x == [4, 0]
    assert stats == {"lps": 7, "expanded": 2}
    assert [v for _, v in trace] == [21, Fraction(62, 3), 20]


def test_lp_solver() -> None:
    assert solve_lp([5, 4], [[6, 4], [1, 2]], [24, 6]) == ("optimal", [3, Fraction(3, 2)], 21)
    assert solve_lp([1], [[1], [-1]], [1, -2])[0] == "infeasible"
    assert solve_lp([1, 1], [[1, -1]], [1])[0] == "unbounded"
    assert solve_lp([-1], [[-1]], [-2]) == ("optimal", [2], -2)


def test_edges() -> None:
    # integer LP optimum: the root is solved by its LP
    assert land_doig([1, 1], [[1, 0], [0, 1]], [2, 3]) == (5, [2, 3])
    # no integer point: 0.2 <= x <= 0.8
    assert land_doig([1], [[1], [-1]], [Fraction(4, 5), Fraction(-1, 5)]) == (None, None)
    # infeasible LP
    assert land_doig([1], [[1], [-1]], [1, -2]) == (None, None)
    # mixed: only x0 integer
    z, x = land_doig([1, 1], [[2, 0], [0, 2], [1, 1]], [3, 3, 5], integer=[0])
    assert z == Fraction(5, 2) and x == [1, Fraction(3, 2)]


def test_random_against_brute_force() -> None:
    rng = random.Random(1910129)
    for _ in range(500):
        n = rng.randint(1, 3)
        box = [rng.randint(0, 4) for _ in range(n)]
        a = [[1 if i == j else 0 for j in range(n)] for i in range(n)]
        b = list(box)
        for _ in range(rng.randint(1, 3)):
            a.append([rng.randint(-3, 6) for _ in range(n)])
            b.append(rng.randint(-2, 12))
        c = [rng.randint(-5, 9) for _ in range(n)]
        z, x = land_doig(c, a, b)
        expect = brute(c, a, b, box)
        assert z == expect
        if x is not None:
            assert all(v.denominator == 1 for v in x)
            assert all(sum(r[j] * x[j] for j in range(n)) <= bi for r, bi in zip(a, b))
            assert sum(cj * xj for cj, xj in zip(c, x)) == z

What the next level needs

  • the tests did not run in this build

Read the full page about Land-Doig algorithm