Generic backtracking algorithm in Python
The recursive template that extends candidates, checks feasibility, records complete solutions and undoes each extension.
- File
generic_backtracking_algorithm.py, 84 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/generic-backtracking-algorithm/. The authors are listed in prov/BD5KA9.jsonl.
from collections.abc import Callable, Iterable, Iterator
from typing import TypeVar
T = TypeVar("T")
_DONE = object()
def backtrack(
n: int,
candidates: Callable[[tuple[T, ...]], Iterable[T]],
accept: Callable[[tuple[T, ...]], bool],
) -> Iterator[tuple[T, ...]]:
"""Yield each sequence x_1 .. x_n that passes the test on every prefix.
candidates(prefix) gives the ordered candidates for the next decision.
accept(prefix) is the test P_k on a nonempty prefix. The sequences come
in lexicographic order of the candidate orders. The recursion of the
pseudocode is run with an explicit stack, so a large n does not overflow
the Python call stack. Time: O(T d) calls of accept, where T is the number
of prefixes that pass and d bounds the number of candidates. Extra space:
O(n) besides the candidate iterators.
"""
if n < 0:
raise ValueError("n must be non-negative")
x: list[T] = []
# One frame per active call of BACKTRACK(x, k), with k = len(x).
# A frame is None before the call has run line L1, else the candidate iterator.
stack: list[Iterator[T] | None] = [None]
while stack:
frame = stack[-1]
if frame is None:
if len(x) == n: # @line L1
yield tuple(x) # @line L2
stack.pop() # @line L3
if x:
x.pop() # @line L8
continue
stack[-1] = iter(candidates(tuple(x)))
continue
c = next(frame, _DONE) # @line L4
if c is _DONE:
stack.pop()
if stack:
x.pop() # @line L8
continue
x.append(c) # @line L5
if accept(tuple(x)): # @line L6
stack.append(None) # @line L7
else:
x.pop() # @line L8
def count_nodes(
n: int,
candidates: Callable[[tuple[T, ...]], Iterable[T]],
accept: Callable[[tuple[T, ...]], bool],
) -> list[int]:
"""Return the number of reachable prefixes of each length 0 .. n."""
counts = [0] * (n + 1)
def counting_accept(prefix: tuple[T, ...]) -> bool:
ok = accept(prefix)
if ok:
counts[len(prefix)] += 1
return ok
counts[0] = 1
for _ in backtrack(n, candidates, counting_accept):
pass
return counts
def n_queens(n: int) -> list[tuple[int, ...]]:
"""All placements of n non-attacking queens, as tuples of columns 1 .. n by row."""
def no_attack(p: tuple[int, ...]) -> bool:
k = len(p) - 1
return all(p[i] != p[k] and abs(p[i] - p[k]) != k - i for i in range(k))
return list(backtrack(n, lambda p: range(1, n + 1), no_attack))
test_generic_backtracking_algorithm.py 72 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/generic-backtracking-algorithm/. The authors are listed in prov/BD5KA9.jsonl.
import itertools
import random
from generic_backtracking_algorithm import backtrack, count_nodes, n_queens
def no_attack(p: tuple[int, ...]) -> bool:
k = len(p) - 1
return all(p[i] != p[k] and abs(p[i] - p[k]) != k - i for i in range(k))
def test_four_queens_example() -> None:
assert n_queens(4) == [(2, 4, 1, 3), (3, 1, 4, 2)]
assert count_nodes(4, lambda p: range(1, 5), no_attack) == [1, 4, 6, 4, 2]
def test_queens_counts() -> None:
assert [len(n_queens(n)) for n in range(1, 9)] == [1, 0, 0, 2, 10, 4, 40, 92]
assert sum(count_nodes(8, lambda p: range(1, 9), no_attack)) == 2057
def test_edges() -> None:
assert list(backtrack(0, lambda p: [1, 2], lambda p: True)) == [()]
assert list(backtrack(2, lambda p: [], lambda p: True)) == []
assert list(backtrack(1, lambda p: [5], lambda p: False)) == []
assert list(backtrack(2, lambda p: [0, 1], lambda p: True)) == [(0, 0), (0, 1), (1, 0), (1, 1)]
def test_permutations() -> None:
for n in range(6):
got = list(backtrack(n, lambda p: range(n), lambda p: p[-1] not in p[:-1]))
assert got == list(itertools.permutations(range(n)))
def test_subset_sum_prefix_closed() -> None:
a = [3, 1, 4, 1, 5]
target = 6
def ok(p: tuple[int, ...]) -> bool:
s = sum(b * v for b, v in zip(p, a))
return s <= target and (len(p) < len(a) or s == target)
got = list(backtrack(len(a), lambda p: (0, 1), ok))
want = [p for p in itertools.product((0, 1), repeat=len(a)) if sum(b * v for b, v in zip(p, a)) == target]
assert got == want
def test_random_against_brute_force() -> None:
rng = random.Random(20240)
for _ in range(600):
n = rng.randrange(0, 5)
m = rng.randrange(1, 4)
bad = {tuple(rng.randrange(m) for _ in range(rng.randrange(1, n + 1))) for _ in range(rng.randrange(0, 6))} if n else set()
def ok(p: tuple[int, ...]) -> bool:
return p not in bad
got = list(backtrack(n, lambda p: range(m), ok))
want = [
q
for q in itertools.product(range(m), repeat=n)
if all(q[:j] not in bad for j in range(1, n + 1))
]
assert got == want
def test_output_is_a_copy() -> None:
sols = list(backtrack(2, lambda p: (0, 1), lambda p: True))
assert sols[0] == (0, 0) and sols[-1] == (1, 1)
What the next level needs
- the tests did not run in this build