Loading content...
In backtracking, making a choice is only half the story. Equally important is the ability to undo that choice — to restore the state to exactly what it was before, enabling exploration of alternative paths. This is the Unchoose phase: the strategic retreat that gives backtracking its name.
Without Unchoose, we'd have no way to try different candidates. We'd be stuck with our first choice at every decision point, exploring a single path through the solution space. The power of backtracking lies precisely in its ability to venture down one path, see where it leads, and then backtrack to try another.
In this page, we'll explore Unchoose in depth: the mechanics of state restoration, the critical symmetry with Choose, common bugs that arise from improper restoration, and advanced patterns for complex state management.
By the end of this page, you will understand:
• Why state restoration is essential to backtracking correctness • The symmetry principle: every Choose action needs an equal-and-opposite Unchoose • How to track and restore complex state involving multiple data structures • Common bugs in Unchoose and how to avoid them • When Unchoose becomes unnecessary (immutable patterns)
This forms the third and final pillar of the universal backtracking template.
Consider what happens without proper Unchoose. Imagine generating permutations of [1, 2, 3]:
The result: We find only one permutation instead of all six. The state pollution from previous choices prevents exploration of alternatives.
12345678910111213141516171819202122232425262728293031323334
def broken_permutations(nums): """ BUG: Missing Unchoose phase. Will only find one permutation. """ result = [] path = [] used = [False] * len(nums) def backtrack(): if len(path) == len(nums): result.append(path[:]) return for i in range(len(nums)): if used[i]: continue # CHOOSE path.append(nums[i]) used[i] = True # EXPLORE backtrack() # BUG: No Unchoose! # path.pop() # Missing! # used[i] = False # Missing! backtrack() return result # Result: [[1, 2, 3]] — Only one permutation found!# Expected: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]After completing the loop body for one candidate, the state must be exactly as it was before entering that iteration. This invariant ensures each candidate is tried from the same starting point — a clean slate where only prior decisions (from earlier recursion levels) are reflected, not sibling attempts from the current level.
The key to correct Unchoose implementation is symmetry: every action in Choose must have an exactly opposite action in Unchoose.
| Choose Action | Unchoose Action |
|---|---|
path.append(x) | path.pop() |
used[i] = True | used[i] = False |
visited.add(x) | visited.remove(x) |
count[x] += 1 | count[x] -= 1 |
board[r][c] = val | board[r][c] = 0 (or original value) |
remaining -= x | remaining += x |
| Push to stack | Pop from stack |
The golden rule: Read your Choose code backward to write your Unchoose code.
123456789101112131415161718192021222324252627282930313233343536373839
def solve_n_queens(n): """ N-Queens with explicit symmetric Choose/Unchoose. Three sets track attacked columns and diagonals. """ result = [] queens = [] # Queens placed so far (as column indices) cols = set() # Columns under attack diag1 = set() # Diagonals (row - col) diag2 = set() # Anti-diagonals (row + col) def backtrack(row): if row == n: result.append(queens[:]) return for col in range(n): # Skip attacked positions if col in cols or (row - col) in diag1 or (row + col) in diag2: continue # ═══════════ CHOOSE ═══════════ queens.append(col) # Action 1: Add queen cols.add(col) # Action 2: Mark column attacked diag1.add(row - col) # Action 3: Mark diagonal attacked diag2.add(row + col) # Action 4: Mark anti-diagonal attacked # ═══════════ EXPLORE ═══════════ backtrack(row + 1) # ═══════════ UNCHOOSE ═══════════ # (Actions in reverse order, though order often doesn't matter) diag2.remove(row + col) # Undo Action 4 diag1.remove(row - col) # Undo Action 3 cols.remove(col) # Undo Action 2 queens.pop() # Undo Action 1 backtrack(0) return resultIn most cases, the order of undo operations doesn't matter mathematically — as long as all actions are reversed. However, reversing in LIFO order (last action undone first) often:
When in doubt, reverse in LIFO order.
Checkpoint Verification: Before the Choose phase, serialize the state (e.g., state_before = (tuple(path), tuple(used))). After Unchoose, serialize again and assert equality: assert state_after == state_before. This catches symmetry violations during development.
Unchoose bugs are among the most common errors in backtracking implementations. Let's examine the patterns so you can recognize and avoid them.
board[r][c] = 0 but the cell's original value wasn't 0 (e.g., it was a pre-filled Sudoku cell). Now you've corrupted the input.path.pop() removes the reference but the object itself may have been modified.1234567891011121314151617181920212223242526
# BUGGY VERSION: Unchoose happens even when Choose didn'tdef buggy_backtrack(state): for choice in candidates: if is_valid(state, choice): # Conditional choose make_choice(state, choice) backtrack(state) undo_choice(state, choice) # BUG: Runs even if Choose didn't! # FIXED VERSION: Unchoose inside the conditiondef correct_backtrack(state): for choice in candidates: if is_valid(state, choice): make_choice(state, choice) backtrack(state) undo_choice(state, choice) # Only undo if we chose # ALTERNATIVE: Continue to skip invaliddef alternative_backtrack(state): for choice in candidates: if not is_valid(state, choice): continue # Skip invalid choices entirely make_choice(state, choice) backtrack(state) undo_choice(state, choice) # Always paired with the choose aboveUsing continue to skip invalid choices is often cleaner than nested conditions. It ensures that every make_choice is immediately followed by undo_choice at the same indentation level, making symmetry visually obvious and easier to verify.
Some problems require tracking state in multiple interconnected structures. The challenge is ensuring all structures are restored consistently.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
def solve_sudoku_complex(board): """ Sudoku solver with complex state tracking. Maintains three constraint sets per row, column, and box. """ rows = [set() for _ in range(9)] # rows[r] = digits used in row r cols = [set() for _ in range(9)] # cols[c] = digits used in col c boxes = [set() for _ in range(9)] # boxes[b] = digits used in box b def box_index(r, c): return (r // 3) * 3 + (c // 3) # Initialize from existing board for r in range(9): for c in range(9): if board[r][c] != 0: digit = board[r][c] rows[r].add(digit) cols[c].add(digit) boxes[box_index(r, c)].add(digit) def find_empty(): for r in range(9): for c in range(9): if board[r][c] == 0: return (r, c) return None def backtrack(): cell = find_empty() if cell is None: return True # Solved! r, c = cell b = box_index(r, c) for digit in range(1, 10): # Check all constraints if digit in rows[r] or digit in cols[c] or digit in boxes[b]: continue # ═══════════ CHOOSE ═══════════ board[r][c] = digit # Modify board rows[r].add(digit) # Update row constraint cols[c].add(digit) # Update column constraint boxes[b].add(digit) # Update box constraint # ═══════════ EXPLORE ═══════════ if backtrack(): return True # ═══════════ UNCHOOSE ═══════════ boxes[b].remove(digit) # Restore box constraint cols[c].remove(digit) # Restore column constraint rows[r].remove(digit) # Restore row constraint board[r][c] = 0 # Restore board cell return False backtrack() return boardWhen state becomes complex, consider bundling Choose and Unchoose into helper functions. This encapsulates the symmetry requirement:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
def solve_sudoku_bundled(board): """ Sudoku solver with bundled state operations. """ rows = [set() for _ in range(9)] cols = [set() for _ in range(9)] boxes = [set() for _ in range(9)] def box_index(r, c): return (r // 3) * 3 + (c // 3) def place_digit(r, c, digit): """CHOOSE: Place a digit with all side effects.""" board[r][c] = digit rows[r].add(digit) cols[c].add(digit) boxes[box_index(r, c)].add(digit) def remove_digit(r, c, digit): """UNCHOOSE: Remove a digit with all side effects.""" boxes[box_index(r, c)].remove(digit) cols[c].remove(digit) rows[r].remove(digit) board[r][c] = 0 def is_valid(r, c, digit): """Check if placing digit at (r,c) is valid.""" return (digit not in rows[r] and digit not in cols[c] and digit not in boxes[box_index(r, c)]) # ... initialize constraint sets ... def backtrack(): cell = find_empty() if cell is None: return True r, c = cell for digit in range(1, 10): if is_valid(r, c, digit): place_digit(r, c, digit) # Bundle Choose if backtrack(): return True remove_digit(r, c, digit) # Bundle Unchoose return False backtrack() return boardBundling state operations:
• Reduces duplication — Each modification appears once
• Enforces symmetry — Easy to verify place and remove are opposites
• Improves readability — Backtrack function focuses on logic, not mechanics
• Enables testing — Helper functions can be unit tested independently
In certain coding patterns, explicit Unchoose becomes unnecessary because state restoration happens automatically. Understanding these patterns can simplify your code.
If you pass state by creating new copies instead of mutating shared structures, the original state is never modified, so there's nothing to undo:
123456789101112131415161718192021222324
def permute_immutable(nums): """ Permutations using immutable state passing. No explicit Unchoose needed — copies preserve original state. """ result = [] def backtrack(path, remaining): if not remaining: result.append(path) # No copy needed — path is already unique return for i, num in enumerate(remaining): # Create NEW copies with the choice applied new_path = path + [num] # New list new_remaining = remaining[:i] + remaining[i+1:] # New list backtrack(new_path, new_remaining) # No Unchoose needed! # 'path' and 'remaining' were never modified backtrack([], list(nums)) return resultWhen progress is tracked through function parameters rather than mutable state, unwinding the stack automatically "restores" state:
1234567891011121314151617181920212223242526
def subsets_parameter_based(nums): """ Subsets using index parameter for progress. Include/exclude decision doesn't require tracking used elements. """ result = [] def backtrack(index, current): if index == len(nums): result.append(current[:]) return # Branch 1: Exclude nums[index] backtrack(index + 1, current) # No modification to current # Branch 2: Include nums[index] current.append(nums[index]) # Choose backtrack(index + 1, current) # Explore current.pop() # Unchoose backtrack(0, []) return result # Note: We still need Unchoose for the 'current' list,# but we don't need a separate 'used' array because# the index parameter tracks progress.Immutable patterns trade memory for simplicity:
• Pro: No Unchoose bugs possible • Pro: Naturally thread-safe / parallelizable • Pro: Cleaner, more functional code
• Con: Higher memory usage (many copies) • Con: Copying overhead can be significant for large state • Con: Garbage collection pressure
For small state or when correctness trumps performance, immutable patterns are excellent.
In some scenarios, you might not need to fully restore state — particularly when you're about to overwrite it anyway.
If the next choice will overwrite the same location, you can skip the explicit restore:
123456789101112131415161718192021222324252627282930313233
def count_paths_with_obstacles(grid): """ Count paths in grid, marking cells as visited. Uses lazy restoration for efficiency. """ rows, cols = len(grid), len(grid[0]) def backtrack(r, c): # Base cases if r < 0 or r >= rows or c < 0 or c >= cols: return 0 if grid[r][c] != 0: # Obstacle or already visited return 0 if r == rows - 1 and c == cols - 1: return 1 # Reached destination # Mark as visited grid[r][c] = -1 # Choose: mark visited # Explore all directions paths = (backtrack(r + 1, c) + backtrack(r - 1, c) + backtrack(r, c + 1) + backtrack(r, c - 1)) # Restore grid[r][c] = 0 # Unchoose: unmark return paths # This is NOT lazy — we still restore.# Lazy would be: if we knew we'd never revisit this cell at this level.# In general, grid path problems DO need restoration for correctness.Lazy restoration is safe when:
Most pure backtracking problems require full restoration. Lazy restoration is more common in specialized algorithms with simpler state.
Lazy restoration is a micro-optimization that rarely matters and often causes bugs. Unless you've profiled and confirmed that restoration is a bottleneck, always perform full symmetric Unchoose. The correctness guarantee is worth the minor overhead.
Unchoose bugs cause subtle symptoms that can be hard to diagnose. Here's how to identify and fix them.
| Symptom | Likely Cause | Diagnostic Step |
|---|---|---|
| Missing solutions | State pollution marks valid candidates as used | Print used/available sets at each decision point |
| Duplicate solutions | State not properly restored, causing re-exploration | Add solution deduplication, then find root cause |
| Wrong solutions | Residual values from previous choices | Snapshot state before Choose, compare after Unchoose |
| Fewer solutions than expected | Early termination due to false constraint violation | Log constraint checks, verify they should fail |
| Stack overflow | Infinite recursion from non-terminating branch | Add depth limit, print recursion depth |
12345678910111213141516171819202122232425262728293031323334353637383940414243
def permute_debug(nums): """Permutations with state verification for debugging.""" result = [] path = [] used = [False] * len(nums) def snapshot(): """Capture current state for comparison.""" return (tuple(path), tuple(used)) def backtrack(): if len(path) == len(nums): result.append(path[:]) return for i in range(len(nums)): if used[i]: continue # === SNAPSHOT BEFORE === state_before = snapshot() # === CHOOSE === path.append(nums[i]) used[i] = True # === EXPLORE === backtrack() # === UNCHOOSE === path.pop() used[i] = False # === VERIFY RESTORATION === state_after = snapshot() if state_before != state_after: print(f"ERROR: State mismatch!") print(f" Before: {state_before}") print(f" After: {state_after}") raise AssertionError("State restoration failed") backtrack() return resultFor complex bugs, add print statements that show:
>>> Entering backtrack: state={...}>>> Trying choice: {...}>>> After Choose: state={...}>>> After Explore: (recursion complete)>>> After Unchoose: state={...}<<< Exiting backtrackThis trace reveals exactly where state diverges from expectations.
With Choose, Explore, and Unchoose understood, let's see how they integrate into the complete backtracking pattern:
1234567891011121314151617181920212223242526272829303132333435363738
def backtracking_template(): """ Universal backtracking template. Annotated with the three phases. """ solutions = [] state = initial_state() def backtrack(): # ═══════════ BASE CASE ═══════════ if is_complete(state): solutions.append(capture_solution(state)) return # Optional: Early pruning before the loop if is_hopeless(state): return # ═══════════ MAIN LOOP ═══════════ for candidate in get_candidates(state): # Lazy filtering: skip invalid candidates if not is_valid(state, candidate): continue # ═══════════ CHOOSE ═══════════ # Apply the candidate to the state apply_choice(state, candidate) # ═══════════ EXPLORE ═══════════ # Recurse to continue building the solution backtrack() # ═══════════ UNCHOOSE ═══════════ # Restore state to try the next candidate undo_choice(state, candidate) backtrack() return solutions| Phase | What It Does | Key Concern |
|---|---|---|
| Choose | Commit to a candidate; update state | Complete, correct state modification |
| Explore | Recurse to find all completions | Correct base cases; proper pruning |
| Unchoose | Restore state for next candidate | Symmetric reversal of Choose |
Each phase depends on the others:
The Unchoose phase completes the backtracking cycle. Without proper state restoration, the algorithm cannot explore alternative paths, rendering it a simple depth-first descent rather than a true search.
What's Next:
With all three phases mastered, we'll synthesize them into The Universal Template Structure. You'll learn how to recognize when a problem is backtracking-shaped, how to map any suitable problem onto the template, and how to customize the template for different problem categories.
You now understand the Unchoose phase of the backtracking template in depth. You know why state restoration is essential, how to implement symmetric undo operations, and how to debug restoration bugs. Combined with Choose and Explore, you have all the building blocks of the universal backtracking template.