branch_and_bound/
lib.rs

1//! This library implements generic branch-and-bound and backtracking solver.
2//!
3//! Branch-and-bound (and backtracking, which is its special case) is the method
4//! of solving an optimization problem by recursively breaking a problem down
5//! to subproblems and then solving them. Unlike brute-force, branch-and-bound
6//! will discard a subproblem if it discovers that the best potentially obtainable
7//! solution to this subproblem is not better than the current best solution
8//! (aka incumbent).
9//!
10//! To use the library, one shell implement a type that represents a problem
11//! (subproblem) and implement the [`Subproblem`] trait for it.
12//!
13//! One can then [`solve`] an instance of problem using one of the predefined
14//! methods (DFS, BFS, BeFS, etc) or use [`solve_with_container`], through
15//! which custom strategies can be implemented.
16
17pub mod bnb_aware_containers;
18
19use bnb_aware_containers::BinaryHeapExt;
20pub use bnb_aware_containers::BnbAwareContainer;
21
22/// Represents the set of subproblems of an intermediate problem
23/// or the value of the objective function of a feasible solution (leaf node).
24pub enum SubproblemResolution<Node: ?Sized, Score> {
25    /// Subproblems of an intermediate problem
26    Branched(Box<dyn Iterator<Item = Node>>),
27    /// The value of the objective function of a feasible solution
28    Solved(Score),
29}
30// TODO: Consider an alternative implementation by making the iterator
31// type a generic variable rather than a `dyn`
32
33/// A problem (subproblem) to be solved with branch-and-bound
34pub trait Subproblem {
35    // Higher score is better.
36    type Score: Ord;
37
38    /// Evaluates a problem space.
39    ///
40    /// If the space is to be broken further into subproblems, returns
41    /// a sequence of subproblems (may be empty, which discards
42    /// the current subspace).
43    ///
44    /// If the space consists of just one feasible solution to be solved
45    /// directly, returns the score, which is the value of the objective
46    /// function at the solution.
47    fn branch_or_evaluate(&self) -> SubproblemResolution<Self, Self::Score>;
48
49    /// Value of the boundary function at the problem space.
50    ///
51    /// The boundary function gives an upper-boundary of the best solution
52    /// that could potentially be found in this subproblem space. The value of
53    /// the boundary function must be greater than or equal to every value of
54    /// the objective score of any subproblem reachable through consecutive
55    /// `.branch_or_evaluate` calls.
56    ///
57    /// If at some point in the search process a subproblem's `.bound()` value
58    /// is less than or equal to the current best solution, the subproblem is
59    /// discarded (because no better solution will be found in its subtree).
60    fn bound(&self) -> Self::Score;
61}
62
63/// Solve a problem with branch-and-bound / backtracking using a custom subproblem
64/// container with a custom strategy.
65///
66/// Until the container is empty, every subproblem in the container is evaluated; when
67/// a subproblem is branched, the generated subnodes are put into the container to be
68/// retrieved in the following iterations.
69///
70/// A container is, thus, responsible for the order in which subproblems will be examined,
71/// and can also implement additional features, such as early termination based on
72/// the current best value, early termination based on the number of iterations,
73/// eager or lazy evaluation, etc.
74///
75/// `solve_with_container` should be preferred for advanced use cases (e.g., custom order
76/// or unusual early terination conditions). If you want one of the basic options,
77/// use [`solve`].
78pub fn solve_with_container<Node, Container>(mut container: Container) -> Option<Node>
79where
80    Node: Subproblem,
81    Container: BnbAwareContainer<Node>,
82{
83    // Best candidate: its objective score and the node itself
84    let mut best: Option<(Node::Score, Node)> = None;
85
86    // `container` should initially contain the root node (or even several nodes)
87
88    while let Some(candidate) = container.pop_with_incumbent(best.as_ref().map(|x| &x.0)) {
89        match candidate.branch_or_evaluate() {
90            // Intermediate subproblem
91            SubproblemResolution::Branched(subproblems) => {
92                for node in subproblems {
93                    container.push_with_incumbent(node, best.as_ref().map(|x| &x.0));
94                }
95            }
96
97            // Leaf node
98            SubproblemResolution::Solved(candidate_score) => {
99                best = match best {
100                    None => Some((candidate_score, candidate)),
101                    Some((incumbent_score, incumbent)) => {
102                        if incumbent_score < candidate_score {
103                            // Replace the old (boundary) score with the objective score
104                            Some((candidate_score, candidate))
105                        } else {
106                            Some((incumbent_score, incumbent))
107                        }
108                    }
109                }
110            }
111        }
112    }
113
114    best.map(|(_, incumbent)| incumbent)
115}
116
117type NodeCmp<Node> = dyn Fn(&Node, &Node) -> std::cmp::Ordering;
118
119/// Order of traversing the subproblem tree with `solve`. See variants' docs for details.
120pub enum TraverseMethod<Node> {
121    /// Depth-first search (DFS): descends into every subtree until reaches the leaf node
122    /// (or determines that a subtree is not worth descending into because the boundary
123    /// value is not better than the incumbent's objective score).
124    ///
125    /// TODO: stabilize and specify the order in which siblings of a certain node are processed,
126    /// so that the user may return nodes in the order of desired processing.
127    ///
128    /// For typical boundary functions, uses significantly less memory compared to best-first
129    /// and breadth-first search.
130    DepthFirst,
131
132    /// Breadth-first search (BFS): Traverses the subproblem tree layer by layer.
133    /// The processing order among nodes on the same layer is unspecified.
134    ///
135    /// For typical boundary functions, behaves similar to best-first search but uses
136    /// a simpler internal data structure to store subproblems to be processed.
137    BreadthFirst,
138
139    /// Best-first search (BeFS): traverses the tree in many directions simultaneously,
140    /// on every iteration selects and evaluates the subproblem with the best value of
141    /// the boundary function. All its children become candidates for the next selection
142    /// (as long as their boundary value is better than the incumbent's objective score).
143    ///
144    /// The processing order among subproblems with the same boundary value is unspecified.
145    ///
146    /// For typical boundary functions, behaves similar to breadth-first search but selects
147    /// subproblems more optimally.
148    BestFirst,
149
150    /// Like best-first search but selects subproblems in the custom order, based on the
151    /// given comparator `.cmp`.
152    ///
153    /// Processes subproblems in the order specified by `.cmp`: subproblems that compare
154    /// *greater* are processed *first*! The processing order among subproblems that
155    /// compare equal is unspecified.
156    ///
157    /// Set `.cmp_superceeds_bound` to `true` only if `.cmp` guarantees that
158    ///
159    /// if `cmp(subproblem_a, subproblem_b) == Ordering::Less`
160    ///
161    /// then `subproblem_a.bound() < subproblem_b.bound()`
162    ///
163    /// (in other words, the order defined by `.cmp` is a specialized order / super-order
164    /// with respect to the order defined by `Subproblem::bound`).
165    ///
166    /// If `.cmp_superceeds_bound` is set, the search will terminate as soon as the candidate
167    /// that is best according to `.cmp` has the boundary value less (i.e., worse) than that of the
168    /// current incumbent.
169    Custom {
170        cmp: Box<NodeCmp<Node>>,
171        cmp_superceeds_bound: bool,
172    },
173}
174
175/// Solve a problem with branch-and-bound / backtracking, using one of the default strategies.
176///
177/// Walks the subproblem tree (`initial` is the root) according to the method specified by `method`.
178///
179/// `solve` should be preferred for simple scenareous (i.e., a single initial node,
180/// one of the default search strategy implementations). For more advanced use cases, use
181/// [`solve_with_container`].
182#[inline]
183pub fn solve<Node: Subproblem>(initial: Node, method: TraverseMethod<Node>) -> Option<Node> {
184    use TraverseMethod::*;
185
186    match method {
187        BestFirst => {
188            let pqueue = BinaryHeapExt {
189                heap: binary_heap_plus::BinaryHeap::from_vec_cmp(
190                    vec![initial],
191                    |n1: &Node, n2: &Node| n1.bound().cmp(&n2.bound()),
192                ),
193                stop_early: true,
194            };
195            solve_with_container(pqueue)
196        }
197
198        Custom {
199            cmp,
200            cmp_superceeds_bound: stop_early,
201        } => {
202            let pqueue = BinaryHeapExt {
203                heap: binary_heap_plus::BinaryHeap::from_vec_cmp(vec![initial], cmp),
204                stop_early,
205            };
206            solve_with_container(pqueue)
207        }
208
209        BreadthFirst => {
210            let queue = std::collections::VecDeque::from_iter([initial]);
211            solve_with_container(queue)
212        }
213
214        DepthFirst => {
215            let stack = vec![initial];
216            solve_with_container(stack)
217        }
218    }
219}