Algorithm Search⌘K

Little's algorithm in Python

Branch and bound for the TSP that branches on including or excluding an arc and bounds with a reduced cost matrix.

File
little_algorithm.py, 155 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
little_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/little-algorithm/. The authors are listed in prov/10HFCT.jsonl.
"""Little's branch and bound for the asymmetric traveling salesman problem.

A node is a reduced cost matrix together with the arcs that are already in the
tour. Its lower bound is the sum of all reductions made so far. The search
branches on a zero entry (i, j) of largest penalty: one child excludes the arc
(i, j) and the other includes it.
"""

from __future__ import annotations

import heapq
import math
from typing import Optional, Sequence

INF = math.inf
Matrix = list[list[float]]


def reduce_rows(m: Matrix, which: Sequence[int], cols: Sequence[int]) -> float:
    """Subtract from each row in which its minimum over cols, in place. Return the total."""
    total = 0.0
    for i in which:
        h = min(m[i][j] for j in cols)
        if h == INF:
            return INF
        if h > 0:
            for j in cols:
                m[i][j] -= h
            total += h
    return total


def reduce_cols(m: Matrix, rows: Sequence[int], which: Sequence[int]) -> float:
    """Subtract from each column in which its minimum over rows, in place. Return the total."""
    total = 0.0
    for j in which:
        h = min(m[i][j] for i in rows)
        if h == INF:
            return INF
        if h > 0:
            for i in rows:
                m[i][j] -= h
            total += h
    return total


def reduce_matrix(m: Matrix, rows: Sequence[int], cols: Sequence[int]) -> float:
    """Reduce all rows, then all columns, in place. Return the total."""
    h = reduce_rows(m, rows, cols)
    return h if h == INF else h + reduce_cols(m, rows, cols)


def penalty_choice(m: Matrix, rows: Sequence[int], cols: Sequence[int]) -> tuple[int, int, float]:
    """Return the zero entry (i, j) with the largest penalty and the penalty.

    The penalty of a zero (i, j) is the smallest other entry of row i plus the
    smallest other entry of column j. Ties go to the smallest (i, j).
    """
    best: Optional[tuple[int, int, float]] = None
    for i in rows:
        for j in cols:
            if m[i][j] != 0:
                continue
            a = min((m[i][k] for k in cols if k != j), default=INF)
            b = min((m[k][j] for k in rows if k != i), default=INF)
            theta = a + b
            if best is None or theta > best[2]:
                best = (i, j, theta)
    assert best is not None
    return best


def little(
    cost: Sequence[Sequence[float]],
    stats: Optional[dict[str, int]] = None,
    trace: Optional[list[tuple[str, float]]] = None,
) -> tuple[float, Optional[list[int]]]:
    """Solve the TSP on the n x n matrix cost; cost[i][j] = inf forbids arc i -> j.

    Return (length, tour), where tour lists the cities from city 0, or
    (inf, None) if there is no tour. The diagonal is ignored.
    """
    n = len(cost)
    if stats is not None:
        stats.update(nodes=0, expanded=0)
    if n == 0:
        return 0.0, []
    if n == 1:
        return 0.0, [0]
    m0: Matrix = [[INF if i == j else float(cost[i][j]) for j in range(n)] for i in range(n)]
    lb0 = reduce_matrix(m0, range(n), range(n))  # @line L1
    z = INF  # @line L2
    best: Optional[dict[int, int]] = None
    heap: list = []
    tick = 0
    if lb0 < z:
        heapq.heappush(heap, (lb0, tick, m0, list(range(n)), list(range(n)), {}))
        if stats is not None:
            stats["nodes"] += 1
    while heap:  # @line L3
        lb, _, m, rows, cols, succ = heapq.heappop(heap)  # @line L4
        if trace is not None:
            trace.append(("pop", lb))
        if lb >= z:  # @line L5
            break
        if len(succ) == n:  # @line L6
            z, best = lb, succ  # @line L7
            continue
        if stats is not None:
            stats["expanded"] += 1
        i, j, theta = penalty_choice(m, rows, cols)  # @line L8
        # Exclude child: forbid i -> j and reduce row i and column j again.
        if lb + theta < z:  # @line L9
            mx = [row[:] for row in m]
            mx[i][j] = INF
            extra = reduce_rows(mx, [i], cols) + reduce_cols(mx, rows, [j])
            tick += 1
            heapq.heappush(heap, (lb + extra, tick, mx, rows, cols, succ))  # @line L10
            if stats is not None:
                stats["nodes"] += 1
        # Include child: take i -> j, delete row i and column j, block the subtour.
        mi = [row[:] for row in m]  # @line L11
        rows2 = [r for r in rows if r != i]
        cols2 = [c for c in cols if c != j]
        succ2 = dict(succ)
        succ2[i] = j
        if len(succ2) < n - 1:  # @line L12
            pred = {v: u for u, v in succ2.items()}
            s = i
            while s in pred:
                s = pred[s]
            e = j
            while e in succ2:
                e = succ2[e]
            mi[e][s] = INF
        extra = reduce_matrix(mi, rows2, cols2) if rows2 else 0.0  # @line L13
        if lb + extra < z:  # @line L14
            tick += 1
            heapq.heappush(heap, (lb + extra, tick, mi, rows2, cols2, succ2))
            if stats is not None:
                stats["nodes"] += 1
    if best is None:  # @line L15
        return INF, None
    tour = [0]
    while len(tour) < n:
        tour.append(best[tour[-1]])
    return z, tour


def tour_length(cost: Sequence[Sequence[float]], tour: Sequence[int]) -> float:
    """Length of the closed tour that visits the cities in this order."""
    n = len(tour)
    return sum(cost[tour[k]][tour[(k + 1) % n]] for k in range(n)) if n > 1 else 0.0
test_little_algorithm.py 86 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/little-algorithm/. The authors are listed in prov/10HFCT.jsonl.

import itertools
import random

from little_algorithm import INF, little, penalty_choice, reduce_matrix, tour_length

EXAMPLE = [
    [0, 13, 14, 2, 9],
    [17, 0, 16, 13, 10],
    [16, 12, 0, 19, 7],
    [17, 5, 10, 0, 5],
    [4, 20, 9, 18, 0],
]


def brute(cost):
    n = len(cost)
    best = INF
    for p in itertools.permutations(range(1, n)):
        best = min(best, tour_length(cost, (0,) + p))
    return best


def test_example_of_the_page() -> None:
    stats: dict[str, int] = {}
    trace: list = []
    z, tour = little(EXAMPLE, stats, trace)
    assert z == 34 and tour == [0, 3, 1, 2, 4]
    assert stats == {"nodes": 9, "expanded": 5}
    assert [v for _, v in trace] == [33, 33, 33, 34, 34, 34, 38]


def test_root_reduction_and_choice() -> None:
    n = len(EXAMPLE)
    m = [[INF if i == j else float(EXAMPLE[i][j]) for j in range(n)] for i in range(n)]
    assert reduce_matrix(m, range(n), range(n)) == 33
    assert penalty_choice(m, range(n), range(n)) == (0, 3, 10)


def test_small_sizes() -> None:
    assert little([]) == (0.0, [])
    assert little([[5]]) == (0.0, [0])
    assert little([[0, 3], [4, 0]]) == (7, [0, 1])
    assert little([[0, INF], [4, 0]]) == (INF, None)


def test_no_tour() -> None:
    # City 2 has no way out.
    c = [[0, 1, 1], [1, 0, 1], [INF, INF, 0]]
    assert little(c) == (INF, None)


def test_symmetric_square() -> None:
    # Four corners of a unit square, with distances 1 on the sides and 2 on the diagonals.
    c = [[0, 1, 2, 1], [1, 0, 1, 2], [2, 1, 0, 1], [1, 2, 1, 0]]
    z, tour = little(c)
    assert z == 4 and tour_length(c, tour) == 4


def test_zero_costs() -> None:
    z, tour = little([[0] * 6 for _ in range(6)])
    assert z == 0 and sorted(tour) == list(range(6))


def test_random_against_brute_force() -> None:
    rng = random.Random(20240611)
    for _ in range(700):
        n = rng.randint(2, 7)
        p = rng.choice([0.0, 0.1, 0.3])
        hi = rng.choice([3, 30, 1000])
        c = [[INF if rng.random() < p else rng.randint(0, hi) for _ in range(n)] for _ in range(n)]
        z, tour = little(c)
        assert z == brute(c)
        if tour is not None:
            assert sorted(tour) == list(range(n)) and tour[0] == 0
            assert tour_length(c, tour) == z


def test_larger_instance() -> None:
    rng = random.Random(7)
    n = 9
    c = [[rng.randint(1, 100) for _ in range(n)] for _ in range(n)]
    z, tour = little(c)
    assert z == brute(c) and tour_length(c, tour) == z

What the next level needs

  • the tests did not run in this build

Read the full page about Little's algorithm