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