842: Branch and Bound — TSP Optimisation
Difficulty: 4 Level: Advanced Explore a solution tree while pruning branches that cannot beat the current best — turning exponential search into something tractable in practice.The Problem This Solves
Branch and bound is a general exact optimisation framework: explore all possible solutions (branching) but discard entire subtrees when a lower bound on their cost proves they can't improve on the best solution found so far (bounding). It's the standard approach for NP-hard combinatorial optimisation when you need an exact answer. This example applies branch and bound to the Travelling Salesman Problem (TSP): find the shortest tour visiting all n cities exactly once. TSP is NP-hard — no polynomial algorithm is known — but branch and bound with a good lower bound (here: a simple reduced cost matrix bound) can solve instances of 15-20 cities quickly and 30-40 cities in seconds. Branch and bound underpins industrial solvers: CPLEX and Gurobi use LP-relaxation bounds to solve integer programming problems with millions of variables. Understanding the framework is essential for implementing custom solvers when general-purpose tools aren't available or are too slow.The Intuition
Build a search tree where each level adds one city to the partial tour. At each node:- Branch: try each unvisited city as the next stop — creating child nodes.
- Bound: compute a lower bound on the cost of completing the tour from this partial path. If `current_cost + lower_bound >= best_known`, prune — this subtree can't improve the answer.
How It Works in Rust
struct TSP {
n: usize,
dist: Vec<Vec<f64>>, // distance matrix
}
impl TSP {
fn solve(&self) -> (f64, Vec<usize>) {
let mut best_cost = f64::INFINITY;
let mut best_path = vec![];
let mut path = vec![0usize]; // start at city 0
let mut visited = vec![false; self.n];
visited[0] = true;
self.branch(
&mut path, &mut visited,
0.0, &mut best_cost, &mut best_path
);
(best_cost, best_path)
}
fn branch(
&self,
path: &mut Vec<usize>,
visited: &mut Vec<bool>,
cost: f64,
best_cost: &mut f64,
best_path: &mut Vec<usize>,
) {
// Complete tour: close the loop back to start
if path.len() == self.n {
let last = *path.last().unwrap();
let total = cost + self.dist[last][0];
if total < *best_cost {
*best_cost = total;
*best_path = path.clone();
}
return;
}
// Lower bound: cost so far + minimum edge from each unvisited city
let bound = cost + self.lower_bound(visited, *path.last().unwrap());
if bound >= *best_cost { return; } // prune
// Branch: try each unvisited city as next stop
// Sort candidates by distance to get a good bound quickly (greedy heuristic order)
let current = *path.last().unwrap();
let mut candidates: Vec<usize> = (0..self.n)
.filter(|&c| !visited[c])
.collect();
candidates.sort_by(|&a, &b| {
self.dist[current][a].partial_cmp(&self.dist[current][b]).unwrap()
});
for next in candidates {
visited[next] = true;
path.push(next);
self.branch(
path, visited,
cost + self.dist[current][next],
best_cost, best_path
);
path.pop();
visited[next] = false;
}
}
fn lower_bound(&self, visited: &[bool], current: usize) -> f64 {
// Sum of minimum outgoing edge from current + each unvisited city
let mut bound = 0.0;
// Min edge from current city to any unvisited city
let min_from_current = (0..self.n)
.filter(|&c| !visited[c])
.map(|c| self.dist[current][c])
.fold(f64::INFINITY, f64::min);
bound += min_from_current;
// Min outgoing edge from each unvisited city
for city in 0..self.n {
if !visited[city] {
let min_edge = (0..self.n)
.filter(|&c| c != city)
.map(|c| self.dist[city][c])
.fold(f64::INFINITY, f64::min);
bound += min_edge;
}
}
bound / 2.0 // each edge counted twice (once from each endpoint)
}
}
Sorting candidates by distance (`greedy heuristic order`) is key: by trying cheap options first, you find a good tour early and update `best_cost` to a tight value — making subsequent pruning more aggressive. This is the "best-first" strategy within DFS.
The bound divided by 2 comes from counting each edge from both endpoints — a standard trick in TSP lower bounds to avoid double-counting.
What This Unlocks
- Exact TSP for small instances: branch and bound solves 20-city instances in milliseconds, 30-city in seconds — practical for delivery route optimisation at small scale.
- Integer programming: B&B is the engine inside CPLEX/Gurobi — they branch on fractional LP variables and use LP relaxations as bounds.
- Scheduling and assignment: job-shop scheduling, nurse rostering, and exam timetabling are all solved by B&B with domain-specific bounds.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Mutable path/visited | Functional threading or `ref` cells | `&mut Vec<usize>` and `&mut Vec<bool>` — explicit mutable borrows |
| Backtracking | Immutable state (no undo needed) | Explicit `push`/`pop`, `visited[x] = true/false` |
| Best cost tracking | `ref float` threaded through | `&mut f64` — shared mutable reference |
| Greedy candidate sort | `List.sort` | `sort_by` with `partial_cmp` for f64 |
| Lower bound | Same algorithm | `.fold(f64::INFINITY, f64::min)` — idiomatic min over iterator |
/// Branch and Bound: TSP Optimisation.
///
/// Explores the space of tours, pruning branches where current cost
/// already exceeds the best known solution. Simple bound: current cost + min edges.
const INF: u64 = u64::MAX / 2;
struct Tsp {
dist: Vec<Vec<u64>>,
n: usize,
}
impl Tsp {
fn new(dist: Vec<Vec<u64>>) -> Self {
let n = dist.len();
Self { dist, n }
}
/// Solve TSP with branch and bound.
/// Returns (optimal_cost, optimal_tour).
fn solve(&self) -> (u64, Vec<usize>) {
let mut best_cost = INF;
let mut best_tour = Vec::new();
let mut path = vec![0usize];
let mut visited = vec![false; self.n];
visited[0] = true;
self.bnb(&mut path, &mut visited, 0, &mut best_cost, &mut best_tour);
(best_cost, best_tour)
}
fn bnb(
&self,
path: &mut Vec<usize>,
visited: &mut Vec<bool>,
cost: u64,
best_cost: &mut u64,
best_tour: &mut Vec<usize>,
) {
if path.len() == self.n {
// Complete tour: add return to start
let return_cost = self.dist[*path.last().unwrap()][path[0]];
if return_cost < INF {
let total = cost + return_cost;
if total < *best_cost {
*best_cost = total;
*best_tour = path.clone();
}
}
return;
}
// Lower bound: current cost + cheapest unvisited edge from current node
let cur = *path.last().unwrap();
let min_forward = (0..self.n)
.filter(|&v| !visited[v])
.map(|v| self.dist[cur][v])
.min()
.unwrap_or(0);
if cost + min_forward >= *best_cost {
return; // Prune: cannot improve
}
// Sort candidates by distance for better pruning (greedy order)
let mut candidates: Vec<usize> = (0..self.n).filter(|&v| !visited[v]).collect();
candidates.sort_by_key(|&v| self.dist[cur][v]);
for v in candidates {
let edge = self.dist[cur][v];
if edge >= INF { continue; }
let new_cost = cost + edge;
if new_cost < *best_cost {
visited[v] = true;
path.push(v);
self.bnb(path, visited, new_cost, best_cost, best_tour);
path.pop();
visited[v] = false;
}
}
}
}
fn main() {
// 4-city example
let dist4 = vec![
vec![0, 10, 15, 20],
vec![10, 0, 35, 25],
vec![15, 35, 0, 30],
vec![20, 25, 30, 0],
];
let tsp4 = Tsp::new(dist4);
let (cost, tour) = tsp4.solve();
println!("4-city TSP:");
println!(" Optimal cost: {cost}");
let tour_str: Vec<String> = tour.iter().map(|v| v.to_string()).collect();
println!(" Tour: {} -> 0", tour_str.join(" -> "));
// 5-city example
let dist5 = vec![
vec![0, 2, 9, 10, 4],
vec![1, 0, 6, 4, 3],
vec![15, 7, 0, 8, 12],
vec![6, 3, 12, 0, 5],
vec![10, 4, 8, 5, 0],
];
let tsp5 = Tsp::new(dist5);
let (cost5, tour5) = tsp5.solve();
println!("\n5-city TSP:");
println!(" Optimal cost: {cost5}");
let t5: Vec<String> = tour5.iter().map(|v| v.to_string()).collect();
println!(" Tour: {} -> 0", t5.join(" -> "));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_4city() {
let dist = vec![
vec![0, 10, 15, 20],
vec![10, 0, 35, 25],
vec![15, 35, 0, 30],
vec![20, 25, 30, 0],
];
let (cost, tour) = Tsp::new(dist.clone()).solve();
// Verify tour visits all cities and returns to 0
let mut visited = vec![false; 4];
for &v in &tour { visited[v] = true; }
assert!(visited.iter().all(|&v| v), "not all cities visited");
// Verify cost is correct
let mut computed = 0u64;
for i in 0..tour.len() {
let from = tour[i];
let to = tour[(i + 1) % tour.len()];
computed += dist[from][to];
}
assert_eq!(computed, cost);
}
#[test]
fn test_symmetric_triangle_inequality() {
// For a 3-city problem, brute force: 2 tours
let dist = vec![
vec![0, 1, 2],
vec![1, 0, 3],
vec![2, 3, 0],
];
let (cost, _) = Tsp::new(dist).solve();
// Tours: 0->1->2->0 = 1+3+2=6, 0->2->1->0 = 2+3+1=6
assert_eq!(cost, 6);
}
#[test]
fn test_2_cities() {
let dist = vec![vec![0, 5], vec![5, 0]];
let (cost, _) = Tsp::new(dist).solve();
assert_eq!(cost, 10); // 0->1->0
}
}
(* Branch and Bound — TSP in OCaml *)
(* Uses a simple lower bound: sum of two cheapest outgoing edges per node *)
let inf = max_int / 2
(* Simple lower bound for TSP: for each unvisited node, add half the sum
of its two cheapest edges to any other unvisited node or path endpoints *)
let lower_bound (dist : int array array) (path : int list) (n : int) : int =
let visited = Array.make n false in
List.iter (fun v -> visited.(v) <- true) path;
(* Current path length *)
let path_cost = ref 0 in
let rec sum_path = function
| [] | [_] -> ()
| a :: (b :: _ as rest) -> path_cost := !path_cost + dist.(a).(b); sum_path rest
in
sum_path (List.rev path);
(* For unvisited nodes + endpoints: add min edge cost *)
let bound = ref !path_cost in
for v = 0 to n - 1 do
if not visited.(v) || v = List.hd path || v = List.hd (List.rev path) then begin
let edges = Array.init n (fun u ->
if u = v then inf
else if visited.(u) && u <> List.hd path && u <> List.hd (List.rev path) then inf
else dist.(v).(u)
) in
Array.sort compare edges;
let e1 = edges.(0) and e2 = if Array.length edges > 1 then edges.(1) else 0 in
if e1 < inf then bound := !bound + (e1 + e2) / 2
end
done;
!bound
(* Branch and Bound TSP solver *)
let tsp_bnb (dist : int array array) : int * int list =
let n = Array.length dist in
let best_cost = ref inf in
let best_path = ref [] in
let rec bnb path visited cost =
let path_len = List.length path in
if path_len = n then begin
(* Complete tour: add return to start *)
let total = cost + dist.(List.hd path).(List.hd (List.rev path)) in
if total < !best_cost then begin
best_cost := total;
best_path := List.rev path
end
end else begin
for v = 0 to n - 1 do
if not visited.(v) then begin
let new_cost = cost + dist.(List.hd path).(v) in
(* Simple pruning: current cost already exceeds best *)
if new_cost < !best_cost then begin
visited.(v) <- true;
bnb (v :: path) visited new_cost;
visited.(v) <- false (* backtrack *)
end
end
done
end
in
let visited = Array.make n false in
visited.(0) <- true;
bnb [0] visited 0;
(!best_cost, !best_path)
let () =
(* Small 4-city example *)
let dist = [|
[| 0; 10; 15; 20 |];
[| 10; 0; 35; 25 |];
[| 15; 35; 0; 30 |];
[| 20; 25; 30; 0 |];
|] in
let (cost, path) = tsp_bnb dist in
Printf.printf "TSP optimal cost: %d\n" cost;
Printf.printf "Tour: %s\n"
(String.concat " -> " (List.map string_of_int path) ^ " -> 0")