Best-first branch and bound in Python
Branch and bound that always expands the live node with the best bound, held in a priority queue.
- File
best_first_branch_and_bound.py, 89 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/best-first-branch-and-bound/. The authors are listed in prov/0SPYHN.jsonl.
import heapq
import itertools
from collections.abc import Callable, Iterable, Sequence
from typing import TypeVar
N = TypeVar("N")
def best_first_branch_and_bound(
root: N,
children: Callable[[N], Iterable[N]],
upper_bound: Callable[[N], float],
is_leaf: Callable[[N], bool],
value: Callable[[N], float],
stats: dict[str, int] | None = None,
) -> tuple[float, N | None]:
"""Maximise value over the leaves of the tree below root.
children(n) yields the feasible children of an inner node in the order
to try them. upper_bound(n) must be at least value(x) for every leaf x
below n. Returns (best value, best leaf), or (-inf, None) if the tree has
no leaf. Live nodes wait in a binary heap keyed by their bound; among
nodes with equal bounds, the one generated first is taken first.
"""
if stats is None:
stats = {}
stats.update(expanded=0, generated=1, max_live=1)
z = float("-inf") # @line L1
best: N | None = None # @line L1
tick = itertools.count()
heap: list[tuple[float, int, N]] = [(-upper_bound(root), next(tick), root)] # @line L2
while heap: # @line L3
neg_ub, _, node = heapq.heappop(heap) # @line L4
if -neg_ub <= z: # @line L5
break
if is_leaf(node): # @line L6
v = value(node)
if v > z: # @line L7
z, best = v, node
continue # @line L8
stats["expanded"] += 1
for child in children(node): # @line L9
b = upper_bound(child)
if b > z: # @line L10
heapq.heappush(heap, (-b, next(tick), child))
stats["generated"] += 1
stats["max_live"] = max(stats["max_live"], len(heap))
return z, best # @line L11
Item = tuple[float, float] # (value, weight)
KNode = tuple[int, float, float, tuple[int, ...]] # (level, weight, value, taken)
def knapsack(values: Sequence[float], weights: Sequence[float], capacity: float, stats: dict[str, int] | None = None) -> tuple[float, list[int]]:
"""0-1 knapsack by best-first branch and bound with the fractional bound.
Returns the best value and the sorted list of chosen item indices.
Items with weight 0 are allowed; weights and values must be nonnegative.
"""
n = len(values)
order = sorted(range(n), key=lambda i: (-(values[i] / weights[i]) if weights[i] > 0 else float("-inf"), i))
def ub(node: KNode) -> float:
k, w, v, _ = node
room = capacity - w
for j in order[k:]:
if weights[j] <= room:
room -= weights[j]
v += values[j]
else:
return v + values[j] * room / weights[j]
return v
def kids(node: KNode) -> list[KNode]:
k, w, v, taken = node
j = order[k]
out: list[KNode] = []
if w + weights[j] <= capacity:
out.append((k + 1, w + weights[j], v + values[j], taken + (j,)))
out.append((k + 1, w, v, taken))
return out
z, best = best_first_branch_and_bound((0, 0.0, 0.0, ()), kids, ub, lambda x: x[0] == n, lambda x: x[2], stats)
assert best is not None
return z, sorted(best[3])
test_best_first_branch_and_bound.py 87 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/best-first-branch-and-bound/. The authors are listed in prov/0SPYHN.jsonl.
import itertools
import random
from best_first_branch_and_bound import best_first_branch_and_bound, knapsack
def brute_knapsack(values, weights, capacity):
best = 0
for mask in itertools.product([0, 1], repeat=len(values)):
if sum(w for w, m in zip(weights, mask) if m) <= capacity:
best = max(best, sum(v for v, m in zip(values, mask) if m))
return best
def test_example_of_the_page() -> None:
stats: dict[str, int] = {}
assert knapsack([40, 30, 50, 10], [2, 5, 10, 5], 16, stats) == (90, [0, 2])
assert stats == {"expanded": 5, "generated": 9, "max_live": 4}
def test_edges() -> None:
assert knapsack([], [], 10) == (0, [])
assert knapsack([5], [3], 2) == (0, [])
assert knapsack([5], [3], 3) == (5, [0])
assert knapsack([5, 7], [0, 0], 0) == (12, [0, 1])
# a tree without leaves: the root is a dead end
assert best_first_branch_and_bound(0, lambda n: [], lambda n: 0, lambda n: False, lambda n: 0) == (float("-inf"), None)
# the root is the only leaf
assert best_first_branch_and_bound(0, lambda n: [], lambda n: 3, lambda n: True, lambda n: 3) == (3, 0)
def test_deep_tree() -> None:
n = 5000
z, best = best_first_branch_and_bound(
0, lambda k: [k + 1], lambda k: n, lambda k: k == n, lambda k: k
)
assert (z, best) == (n, n)
def test_random_knapsack_against_brute_force() -> None:
rng = random.Random(20240601)
for _ in range(600):
n = rng.randint(0, 9)
values = [rng.randint(0, 30) for _ in range(n)]
weights = [rng.randint(0, 15) for _ in range(n)]
capacity = rng.randint(0, 40)
z, chosen = knapsack(values, weights, capacity)
assert z == brute_knapsack(values, weights, capacity)
assert sum(weights[i] for i in chosen) <= capacity
assert sum(values[i] for i in chosen) == z
def test_random_trees_against_brute_force() -> None:
rng = random.Random(7)
for _ in range(500):
depth = rng.randint(0, 5)
# node = tuple of child indices; leaves at full depth get random values
table: dict[tuple[int, ...], int] = {}
def kids(node: tuple[int, ...]) -> list[tuple[int, ...]]:
return [node + (i,) for i in range(rng_kids[node])]
rng_kids: dict[tuple[int, ...], int] = {}
stack = [()]
leaves = []
while stack:
node = stack.pop()
if len(node) == depth:
table[node] = rng.randint(-20, 20)
leaves.append(node)
continue
rng_kids[node] = rng.randint(0, 3)
stack.extend(node + (i,) for i in range(rng_kids[node]))
def ub(node: tuple[int, ...]) -> float:
below = [table[x] for x in leaves if x[: len(node)] == node]
return max(below) + rng.randint(0, 3) if below else -100
z, best = best_first_branch_and_bound((), kids, ub, lambda x: len(x) == depth, lambda x: table[x])
if leaves:
assert z == max(table.values())
assert best is not None and table[best] == z
else:
assert (z, best) == (float("-inf"), None)
What the next level needs
- the tests did not run in this build