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