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