🦀 Functional Rust

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: The quality of the bound determines efficiency: a tight bound prunes more branches. Here, the bound adds the minimum outgoing edge from each unvisited city — simple to compute but often effective. Best case: O(n²) with perfect pruning. Worst case: O(n!) with a terrible bound (same as brute force). In practice, a good bound + DFS with best-first ordering (try cheapest paths first to get a good initial upper bound quickly) makes many instances tractable.

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

Key Differences

ConceptOCamlRust
Mutable path/visitedFunctional threading or `ref` cells`&mut Vec<usize>` and `&mut Vec<bool>` — explicit mutable borrows
BacktrackingImmutable 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 boundSame 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")