1. Optimality Pruning via Admissible Relaxations
While backtracking targets feasibility (finding one or all valid configurations), Branch and Bound (B&B) targets combinatorial optimization: $\min_{x \in \mathcal{F}} f(x)$ (or $\max$). B&B maintains two quantities:
- The Incumbent ($z^*$): The objective value of the best feasible complete solution found so far (initialized to $+\infty$ for minimization, or seeded via a fast greedy heuristic).
- The Admissible Bound ($B(u)$): For any partial state (subproblem) $u$, we solve a relaxed problem $\mathcal{R}(u) \supseteq \mathcal{F}(u)$ that drops hard integrality or degree constraints so it can be solved in polynomial time.
Core Invariant — Admissibility & Pruning Condition
For a minimization problem, a lower bound function $LB(u)$ is admissible if $LB(u) \le \min_{x \in \mathcal{F}(u)} f(x)$ for every subtree $u$. Whenever $LB(u) \ge z^*$, no completion of $u$ can strictly improve upon the incumbent $z^*$, so $u$'s entire subtree can be pruned without loss of optimality. (For maximization, we use an admissible upper bound $UB(u) \ge \max_{x \in \mathcal{F}(u)} f(x)$ and prune when $UB(u) \le z^*$.)
2. Classic Relaxations & Search Strategies (DFS vs. Best-First)
- 0/1 Knapsack Upper Bound (Dantzig's LP Relaxation / Fractional Knapsack): Sort items by value-to-weight density $v_i / w_i$ descending. At node $u$ (having decided items $0..i-1$ with remaining capacity $C_{rem}$ and current value $V_{curr}$), greedily pack whole items $i, i+1, \dots$ until an item $k$ does not fit, then add the fractional slice $C_{rem} \cdot (v_k / w_k)$. This takes $O(\log N)$ time with prefix sums + binary search (or $O(N)$ linearly) and gives the exact Linear Programming relaxation upper bound $UB(u)$.
- Metric / General TSP Lower Bound:
- Row/Column Matrix Reduction: Subtract the minimum of each uncovered row and column from the cost matrix; the sum of subtracted minimums is a valid lower bound on completing the tour.
- Held-Karp 1-Tree Relaxation: Remove vertex $1$, compute a Minimum Spanning Tree (MST) on vertices $\{2..n\}$ plus the two cheapest edges incident to vertex $1$. Every Hamiltonian cycle is a 1-tree, so $\text{cost}(\text{1-Tree}) \le \text{OPT}_{\text{TSP}}$.
- Best-First (Least-Cost) vs. Depth-First B&B:
- Best-First B&B: Expands the active node with the best bound $B(u)$ using a priority queue. Expands the minimum number of nodes with $B(u) < \text{OPT}$, but requires $O(|\text{Tree}|)$ heap memory and only finds complete solutions near the end.
- DFS + Greedy Seeding: Uses $O(d)$ stack memory, updates the incumbent $z^*$ rapidly by exploring the greedy branch first, and avoids priority queue allocations.
Incumbent z* = 235 (Greedy)"] --> L["Include Item 0 (w=10, v=100)
W=10, V=100, UB=265"] R --> R1["Exclude Item 0
W=0, V=0, UB=220"] R1 -. "PRUNED: UB (220) <= z* (235)" .-> Dead["Subtree Skipped"] L --> LL["Include Item 1...
Updates Incumbent z* = 250"]
Hardware Reality — Why Priority-Queue Best-First B&B Runs Out of RAM Before CPU
In textbook Best-First B&B (like $A^*$ on combinatorial trees), every unpruned frontier node must be stored simultaneously on the binary heap along with its state vector. At 10 million nodes/sec, a priority queue fills 16 GB of RAM in under 15 seconds and suffers severe TLB and L3 cache misses on every push/pop. Production MIP solvers (Gurobi, CPLEX) and tournament solvers therefore use Plunging DFS (or IDA*) seeded with a greedy initial incumbent: stack space stays $O(N)$ inside L1 cache, and with prefix-sum + std::upper_bound fractional relaxation, each node's LP bound is computed in $O(\log N)$ time.
3. Complexity Matrix
Step through O(1) bitmask attack propagation (cols, ld, rd) for N-Queens, Knuth Algorithm X circular doubly-linked cover/uncover, and Meet-in-the-Middle subset search.
Invariant & State
| B&B Strategy / Relaxation | Bound Evaluation Cost | Worst-Case Time | Auxiliary Memory | Primary Strength |
|---|---|---|---|---|
| 0/1 Knapsack DFS + Prefix-Sum Fractional UB | O(log N) | O(2^N) | O(N) | Fits in L1 cache; solves random $N=1000$ in ms |
| 0/1 Knapsack Best-First (Priority Queue) | O(log N) | O(2^N log(2^N)) | O(2^N) | Minimizes number of expanded nodes |
| TSP Row/Col Reduced Matrix B&B | O(N^2) | O(N! * N^2) | O(N^3) (DFS) | Works on asymmetric directed graphs (ATSP) |
| TSP 1-Tree MST Relaxation B&B | O(N^2) | O(N! * N^2) | O(N^2) (DFS) | Much tighter lower bound for symmetric TSP |
4. Reference C++20 Implementation
#include <algorithm>
#include <cstdint>
#include <vector>
struct KnapsackItem {
long long weight;
long long value;
};
// Exact 0/1 Knapsack via DFS Branch-and-Bound with O(log N) Fractional LP Upper Bound
long long knapsack_branch_and_bound(std::vector<KnapsackItem> items, long long capacity) {
// 1. Sort items by value/weight ratio descending (cross-multiply to avoid float imprecision)
std::sort(items.begin(), items.end(), [](const KnapsackItem& a, const KnapsackItem& b) {
return static_cast<__int128>(a.value) * b.weight > static_cast<__int128>(b.value) * a.weight;
});
const int n = static_cast<int>(items.size());
std::vector<long long> pref_w(n + 1, 0), pref_v(n + 1, 0);
for (int i = 0; i < n; ++i) {
pref_w[i + 1] = pref_w[i] + items[i].weight;
pref_v[i + 1] = pref_v[i] + items[i].value;
}
// O(log N) Dantzig Fractional Knapsack Upper Bound from index idx
auto fractional_upper_bound = [&](int idx, long long curr_w, long long curr_v) -> double {
long long rem_w = capacity - curr_w;
long long target_pref_w = pref_w[idx] + rem_w;
auto it = std::upper_bound(pref_w.begin() + idx, pref_w.end(), target_pref_w);
int split = static_cast<int>(it - pref_w.begin()) - 1;
double bound = static_cast<double>(curr_v + (pref_v[split] - pref_v[idx]));
long long leftover_w = rem_w - (pref_w[split] - pref_w[idx]);
if (split < n && leftover_w > 0) {
bound += static_cast<double>(leftover_w) * items[split].value / items[split].weight;
}
return bound;
};
long long best_val = 0;
auto dfs = [&](auto& self, int idx, long long curr_w, long long curr_v) -> void {
if (curr_v > best_val) best_val = curr_v;
if (idx == n) return;
// Admissible Pruning: Since all values are integers, floor(UB) <= best_val cannot beat best_val
if (static_cast<long long>(fractional_upper_bound(idx, curr_w, curr_v)) <= best_val) {
return;
}
// Branch 1 (Greedy-First): Include items[idx] if it fits to raise best_val early
if (curr_w + items[idx].weight <= capacity) {
self(self, idx + 1, curr_w + items[idx].weight, curr_v + items[idx].value);
}
// Branch 2: Exclude items[idx] (re-check bound since best_val may have increased in Branch 1!)
if (static_cast<long long>(fractional_upper_bound(idx + 1, curr_w, curr_v)) > best_val) {
self(self, idx + 1, curr_w, curr_v);
}
};
dfs(dfs, 0, 0, 0);
return best_val;
}
Production Pitfall — Non-Admissible Bounds & Floating-Point Truncation
If your bound is even slightly inadmissible (e.g., underestimating the true upper bound in maximization due to float rounding or integer division v / w before multiplying), Branch and Bound will silently prune the subtree containing the true global optimum! When objectives are integers, compute the fractional slack in IEEE-754 double (53-bit mantissa) plus a small $\epsilon = 10^{-9}$ before taking floor(), or cross-multiply in 64/128-bit integers: $\text{leftover\_w} \cdot v_{\text{split}} \le (z^* - V_{\text{base}}) \cdot w_{\text{split}}$.