DSA DSA In Depth
0%
lesson backtrackingpruningconstraint-satisfaction

Backtracking Architecture: Choice, Constraint, Undo & Symmetry-Breaking Pruning

7 min read Module 6.1: Recursion, Backtracking, Pruning & Greedy Exchange Arguments Updated Oct 23, 2026
📄 Cheat Sheet
Backtracking & State-Space Pruning — N-Queens and Constraint Propagation
Abdul Bari Backtracking & State-Space Pruning — N-Queens and Constraint Propagation Watch on YouTube ↗

Illustrates implicit state-space trees, bounding functions, and how early constraint checks prune exponential subtrees.

0:00 / --:--

1. The Choice–Constraint–Undo Architecture & Knuth's Monte Carlo Tree Size Estimator

Every backtracking solver traverses an implicit state-space tree of depth $d$. If the branching factor at depth $k$ is $b_k$, a naive exhaustive search visits $\prod_{k=0}^{d-1} b_k$ leaves. Pruning predicates $P(s)$ cut off invalid partial states at depth $k$, eliminating the entire subtree of size $\prod_{j=k}^{d-1} b_j$ beneath them.

Core Invariant — Exact State Restoration (The Undo Contract)

Let $\Sigma$ be the mutable solver state before branching on choice $c$ at depth $k$. After executing apply(c), invoking dfs(k + 1), and executing undo(c), the bitwise representation of $\Sigma$ must be identical to its pre-branch value. Any side effect that escapes undo(c) (such as a modified helper array or un-cleared bitmask) corrupts sibling subtrees.

How do we know whether a backtracking search will finish in 10 milliseconds or 10 years? Knuth's Random Walk Estimator (1975) samples random root-to-leaf paths: at depth $k$, if there are $d_k$ valid children, pick one uniformly at random. The quantity $X = 1 + d_0 + d_0 d_1 + d_0 d_1 d_2 + \dots$ is an unbiased estimator ($\mathbb{E}[X] = |T|$) of the exact number of nodes in the pruned state-space tree!

2. Variable Ordering (MRV) & Symmetry-Breaking Pruning

Two orthogonal techniques routinely compress a $10^{15}$-node search tree into $10^4$ nodes:

  1. Fail-First / Minimum Remaining Values (MRV): When choosing which variable (e.g., Sudoku cell or graph vertex) to assign next, always pick the unassigned variable with the fewest legal choices remaining. If a variable has $0$ legal choices, we backtrack immediately ($b_k = 0$); if it has $1$ choice, we avoid branching ($b_k = 1$). Minimizing $b_k$ near the root shrinks the multiplier on all deeper levels.
  2. Symmetry Breaking (Canonical Ordering):
    • Duplicate Elements (Subsets II / Permutations II): Sort the array first, and at the same recursion depth skip index $i$ if A[i] == A[i-1] and $i-1$ was not taken in the current step.
    • Identical Bins (Partition into $K$ Equal-Sum Subsets): Never try placing an item into a second empty bin if placing it into the first empty bin failed—all empty bins are symmetric! Furthermore, sort items in descending order so large items fail early.
    • Rotational/Reflective Symmetry (N-Queens): Restrict the first row's queen to columns $0 \dots \lfloor N/2 \rfloor - 1$ and double the count (handling the center column when $N$ is odd).
N-Queens Bitmask Pruning & Knuth Dancing Links (DLX)
O(1) Bitmask / Exact Cover

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.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Row 0 Queen at c=1 sets cols=0100₂, ld=1000₂, rd=0010₂. Row 1 avail = ~(cols|ld|rd) & 1111₂ = 0001₂ (only col 3 safe).
♛✕✕✕
cols Mask
0100₂
ld / rd Masks
1000₂ / 0010₂
Next Row Avail
0001₂ (c=3)
[Step 1] Placed Queen at (0, 1); bitwise OR prunes 3 of 4 columns on Row 1 in O(1).
flowchart TD Root["Root: Pick Empty Bin for Item w=7
Bins: [0, 0, 0]"] --> B0["Try Bin 0: [7, 0, 0]
Explore Subtree... (Fails)"] Root -. "PRUNED by Symmetry:
Bin 1 == Bin 0 == 0" .-> B1["Try Bin 1: [0, 7, 0]
Identical State!"] Root -. "PRUNED by Symmetry:
Bin 2 == Bin 0 == 0" .-> B2["Try Bin 2: [0, 0, 7]
Identical State!"]

Hardware Reality — Bitmask State Representation for Zero-Undo Overhead

In the classic $N$-Queens problem ($N \le 32$), storing board attacks in a 2D boolean array requires $O(N)$ memory reads per conflict check. Instead, pass three 32-bit integers by value down the recursion stack: cols, diag1 (shifted left each row), and diag2 (shifted right each row). Available safe columns in the current row are computed in 1 cycle via avail = ((1U << n) - 1) & ~(cols | diag1 | diag2), iterated via bit = avail & -avail, and because the masks are passed in CPU registers by value, undo costs 0 instructions upon function return!

3. Complexity Matrix

Problem & Strategy Unpruned Search Space Pruned Worst-Case Time State Stack Space Conflict Check Cost
$N$-Queens (Naive $N^2$ Cell Placement) O(C(N^2, N)) O(N!) O(N^2) O(N)
$N$-Queens (Row-by-Row Bitmask Backtracking) O(N^N) O(N!) O(N) O(1)
$K$-Partition Equal Subsets (With Empty-Bin Symmetry) O(K^N) O(K^N / K!) O(N + K) O(1)
Sudoku $9 \times 9$ (Bitmask + MRV Cell Selection) O(9^81) ~10^2 - 10^4 nodes O(81) O(1)

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <numeric>
#include <vector>

// 1. Ultra-Fast Bitmask N-Queens Solver (Zero-allocation, O(1) conflict & undo)
long long solve_n_queens_bitmask(int n) {
    const std::uint32_t all_cols = (1U << n) - 1U;
    auto dfs = [&](auto& self, std::uint32_t cols, std::uint32_t diag1, std::uint32_t diag2) -> long long {
        if (cols == all_cols) return 1LL; // All N rows placed
        long long count = 0;
        std::uint32_t avail = all_cols & ~(cols | diag1 | diag2);
        while (avail) {
            std::uint32_t bit = avail & -avail; // Isolate lowest available column
            avail -= bit;                       // Clear lowest bit (BLSR)
            count += self(self, cols | bit, (diag1 | bit) << 1, (diag2 | bit) >> 1);
        }
        return count;
    };
    return dfs(dfs, 0U, 0U, 0U);
}

// 2. Partition Array into K Equal-Sum Subsets with Symmetry Breaking + Fail-First Sorting
bool can_partition_k_subsets(std::vector<int> nums, int k) {
    long long total = std::accumulate(nums.begin(), nums.end(), 0LL);
    if (total % k != 0) return false;
    const int target = static_cast<int>(total / k);

    // Fail-first heuristic: sort descending so large items trigger pruning at the root
    std::sort(nums.begin(), nums.end(), std::greater<int>());
    if (nums[0] > target) return false;

    std::vector<int> bins(k, 0);
    const int n = static_cast<int>(nums.size());

    auto dfs = [&](auto& self, int idx) -> bool {
        if (idx == n) return true;
        const int val = nums[idx];
        for (int b = 0; b < k; ++b) {
            if (bins[b] + val > target) continue;
            // Symmetry Breaking 1: Skip duplicate bin loads at the same level
            if (b > 0 && bins[b] == bins[b - 1]) continue;

            bins[b] += val;                  // CHOICE
            if (self(self, idx + 1)) return true;
            bins[b] -= val;                  // UNDO

            // Symmetry Breaking 2: If placing val into an empty bin failed,
            // it can never succeed in any other empty bin.
            if (bins[b] == 0) break;
        }
        return false;
    };
    return dfs(dfs, 0);
}

Production Pitfall — Pruning Inside the Base Case Instead of Before Recursing

Checking validity at the entry of dfs(depth + 1) instead of inside the loop before calling dfs multiplies the number of function call frames by the branching factor $b$—often up to $9\times$ or $N\times$ more leaf invocations. Always test feasibility before pushing a stack frame, and when using MRV (dynamic variable ordering), remember to restore the chosen cell's state before returning if no choice succeeded.

Check yourself Question 1 of 4
Concept Check Minimum Remaining Values (MRV) Heuristic recall

Why does the Minimum Remaining Values (MRV / Fail-First) variable-ordering heuristic dramatically speed up backtracking search on constraint satisfaction problems?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

In the register-based bitmask $N$-Queens solver `dfs(cols, diag1, diag2)`, when transitioning from row $r$ to row $r+1$ after placing a queen at column bit `bit`, we pass `(diag1 | bit) << 1` and `(diag2 | bit) >> 1`. Why does this eliminate all $O(N)$ diagonal array updates and undo steps?

Key Takeaways

Backtracking mutates a single shared state via Choice -> Feasibility Check -> Recurse -> Undo, requiring O(depth) stack memory instead of O(b^d) heap nodes.
Minimum Remaining Values (MRV) dynamically selects the most constrained variable next, minimizing the branching factor near the root of the search tree.
Symmetry breaking (skipping identical sibling values or identical empty bins, and sorting items descending) prevents exploring isomorphic subtrees.
Passing bitmasks by value in registers eliminates explicit Undo operations and reduces conflict checking to O(1) bitwise instructions.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue