Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Backtracking & State Search

Conceptual Foundation & Intuitive Analogy

🧭 Conceptual Foundation & Pattern Intuition

Backtracking is a systematic method for exploring large, combinatorial decision spaces. Where simple iteration scans a flat list, backtracking explores a tree of potential choices where every node represents a decision frame. The foundational triad of the pattern is Choose, Explore, Un-Choose:

  1. Choose: Select a candidate element and add it to the active path.
  2. Explore: Recurse down the branch to evaluate all consequences of that choice.
  3. Un-Choose: Symmetrically remove the candidate from the path before returning to the parent, leaving zero trace of the experiment. For the power set (Subsets), we use a forward-advancing start index: each recursive step decides which remaining elements to include, naturally generating all 2N2^N2N unique subsets without generating duplicate permutations.
🏟️ The Analogy: Packing a Backpack for a Weekend Hike

Imagine packing a small backpack from a table of gear: a flashlight, a compass, and a camera. You start with an empty pack (Subset 1: []). You add the flashlight (Subset 2: ["flashlight"]). Keeping the flashlight in, you add the compass (Subset 3: ["flashlight", "compass"]). Keeping both, you add the camera (Subset 4: ["flashlight", "compass", "camera"]). You have reached the end of the table. You remove the camera (un-choose), remove the compass (un-choose), and try adding just the camera (Subset 5: ["flashlight", "camera"]). You unpack and repack one item at a time until every possible gear combination has been cataloged.

🔑 Why It Works

Notice results.append(path[:]). If you write results.append(path), Python stores a reference to the single mutable list. As backtracking unwinds, path.pop() empties that list, leaving results filled with empty arrays [[], [], ...]. Taking a snapshot path[:] freezes the state in amber.

Visual Blueprint & State Transition Model
Backtracking & State Search Mental Model Diagram
Figure 1 • Backtracking & State Search Core Invariant & State Lifecycle
Core Invariant Specification

When a recursive call returns, the shared state is byte-for-byte what it was before the call. Every branch starts clean.

Boundary Model: State Space Tree: Choose -> Explore -> Un-choose

Branch exploration via in-place mutation followed by immediate backtrack restoration. Sibling deduplication prunes duplicate subtrees.

Loop Invariant Termination

Recursive base case: if len(path) == target: record copy; for i in range(start, n): choose, recurse, un-choose.

The 2 Mental Models of Backtracking

Distinguish whether the decision tree snapshots every node, leaves only, or permutational arrangements.

In the realm of backtracking, combinatorial generation generally splits into three distinct families based on how you navigate the state space:

  • Subsets (Power Sets): Generate all possible group sizes. → Subsets
  • Combinations: Generate groups of a specific size or matching a specific constraint (like summing to a target), where order does not matter. → Combinations
  • Permutations: Generate arrangements where order does matter. → Permutations

All three share the same choose → explore → un-choose loop. Only two decisions change: when you record path, and where the next call starts.

FamilyRecord pathNext call
SubsetsEvery nodei + 1
Combinationslen(path) == k or remain == 0i + 1, or i to reuse
Permutationslen(path) == len(nums)loop from 0, skip used[i]

For nums = [1,2,3]: 8 subsets (sizes 0 to 3), 3 combinations of size 2 ([1,2], [1,3], [2,3]), and 6 permutations, because [1,2] ≠ [2,1].

One Choice per Slot, String Partitioning and Constraint Backtracking reuse the same loop to fill fixed slots, to cut a string into valid pieces and to fill a board. Meet in the Middle splits the input in half when 2^n choices are too many.

1Subsets
elements ➔ every decision tree node is valid ➔ snapshot deep copy

Subsets (Power Set)

Spot it when: You must list all subsets, subsequences or on/off choices of the input.
Mode 1 · Subsets
Python
1from typing import List
2 
3def subsets(nums: List[int]) -> List[List[int]]:
4 """Subsets (LC 78): every subset of nums, saved once at the node of the decision tree that builds it.
5 N = len(nums) and all values are different.
6 Time: O(N * 2^N) | Space: O(N) path + recursion stack, output not counted
7 """
8 results: List[List[int]] = []
9 path: List[int] = [] # the choices from the root to the current node, in order
10 
11 def backtrack(start: int) -> None:
12 # 1. Save This Node:
13 # Every node of the decision tree is a valid subset, so copy path into results
14 # as soon as the call starts, before trying any further choice.
15 results.append(list(path))
16 
17 for i in range(start, len(nums)):
18 # 2. Choose, Explore, Un-choose:
19 # Add nums[i] to path, recurse forward so the next call only sees later
20 # indices, then remove nums[i] so the loop's next i tries its own branch.
21 path.append(nums[i])
22 backtrack(i + 1)
23 path.pop()
24 
25 backtrack(0)
26 return results

Every node of the decision tree is a valid subset, so save a copy of path as soon as you enter backtrack. The loop only picks indices from start onward, so each subset is built in one order and saved once. The trap is saving path itself: path keeps changing with append and pop, so write results.append(list(path)), or every saved subset ends up empty.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path instead of a copy list(path)

    Mistake
    results.append(path)
    Fix
    results.append(list(path))

    What happens: results.append(path) stores the same list object every time. The code keeps changing that one list with append and pop. At the end it is empty, so every saved subset is empty.

    Example: nums = [1, 2]
    Mistake: returns [[], [], [], []]Fix: returns [[], [1], [1, 2], [2]]
  2. 2

    Gotcha 2: Recursing with start + 1 instead of i + 1

    Mistake
    backtrack(start + 1)
    Fix
    backtrack(i + 1)

    What happens: The next call must start after the index that was just picked, which is i. With start + 1, the next call can pick the same index i again, so one value appears twice in a subset.

    Example: nums = [1, 2]
    Mistake: returns [[], [1], [1, 2], [2], [2, 2]]Fix: returns [[], [1], [1, 2], [2]]
  3. 3

    Gotcha 3: Forgetting to remove the choice with path.pop()

    Mistake
    pass
    Fix
    path.pop()

    What happens: path keeps the choice after the call returns. The next value of i then starts from a path that already holds the old choice, so every later subset contains a wrong item.

    Example: nums = [1, 2]
    Mistake: returns [[], [1], [1, 2], [1, 2, 2]]Fix: returns [[], [1], [1, 2], [2]]
  4. 4

    Gotcha 4: Saving a subset only when the loop reaches the end

    Mistake
    if start == len(nums): results.append(list(path))
    Fix
    results.append(list(path))

    What happens: A save at the end happens only when start equals len(nums), which means the last index was just picked. Every subset that does not use the last index is missed, including the empty subset.

    Example: nums = [1, 2]
    Mistake: returns [[1, 2], [2]]Fix: returns [[], [1], [1, 2], [2]]
Canonical LeetCode:Subsets (LC 78)Count Number of Maximum Bitwise-OR Subsets (LC 2044)Generalized Abbreviation (LC 320)Sum of All Subset XOR Totals (LC 1863)
Open the Subsets card
2Meet in the Middle
n up to 40 ➔ every subset sum of each half ➔ sort one half, binary search the other

Meet in the Middle

Spot it when: You must pick a subset (a subsequence, or a split into two groups) whose sum is best or closest to a target, n is up to about 40, and the values are too large for a table over sums: 2^n choices are far too many, but 2^(n/2) for each half are fine.
Mode 2 · Meet in the Middle
Python
1from bisect import bisect_left
2 
3 
4def min_abs_difference(nums: list[int], goal: int) -> int:
5 """Closest Subsequence Sum: meet in the middle on the subset sums of each half.
6 Time: O(2^(N/2) * N) | Space: O(2^(N/2))
7 """
8 def all_sums(part: list[int]) -> list[int]:
9 # 1. Extend Every Existing Sum:
10 # sums starts as [0], the sum of choosing nothing from part; for each number x,
11 # adding x to a copy of every sum already found accounts for both taking x and
12 # not taking it, so sums ends up holding every subset sum of part.
13 sums = [0]
14 for x in part:
15 sums += [s + x for s in sums]
16 return sums
17 
18 half = len(nums) // 2
19 left = all_sums(nums[:half]) # every subset sum achievable from the left half
20 right = sorted(all_sums(nums[half:])) # every subset sum from the right half, sorted for binary search
21 
22 best = abs(goal) # choosing nothing from either half gives the sum 0
23 for s in left:
24 # 2. Find the Closest Right Sum for This Left Sum:
25 # Every subsequence sum is one left sum s plus one right sum; need is the right
26 # sum that would make the total exactly goal, and bisect_left on the sorted
27 # right list finds where need would sit among the right sums.
28 need = goal - s
29 k = bisect_left(right, need)
30 # 3. Check Both Neighbors of the Insertion Point:
31 # The closest right sum to need is whichever of right[k] (the first sum >= need)
32 # and right[k - 1] (the last sum < need) is nearer; only one of the two may exist.
33 if k < len(right):
34 best = min(best, right[k] - need)
35 if k > 0:
36 best = min(best, need - right[k - 1])
37 return best

Split nums into two halves and list every subset sum of each half with all_sums: for 40 numbers that is two lists of about a million sums instead of a trillion subsequences. Every subsequence is one choice from the left half plus one from the right half, so for each left sum s you only need the right sum closest to need = goal - s: sort right once and find it with k = bisect_left(right, need). This is correct because all_sums starts from [0], the empty choice, so a subsequence that takes nothing from one half is still one left sum plus one right sum, and in a sorted list the value closest to need always sits next to the place where need would be inserted. The trap is checking only right[k], the first sum at or above need: the closest one may be just below it, so also check right[k - 1] when k > 0. On [2, 9, 1, 8] with goal = 13, the best sum, 12, is s = 11 plus the right sum 1, just below need = 2; checking only right[k] returns 4 instead of 1.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Checking only right[k] and not right[k - 1]

    Mistake
    if k < len(right):
        best = min(best, right[k] - need)
    Fix
    if k < len(right):
        best = min(best, right[k] - need)
    if k > 0:
        best = min(best, need - right[k - 1])

    What happens: bisect_left returns the place where need would be inserted. right[k] is the smallest sum that is greater than or equal to need. The closest sum can be right[k - 1], just below need. Without that check, the best total is missed.

    Example: nums = [2, 9, 1, 8], goal = 13
    Mistake: returns 4Fix: returns 1
  2. 2

    Gotcha 2: Listing only non-empty choices in all_sums

    Mistake
    sums = []
    for x in part:
        sums += [s + x for s in sums] + [x]
    Fix
    sums = [0]
    for x in part:
        sums += [s + x for s in sums]

    What happens: The sum 0 stands for taking nothing from a half. Without it, every total takes at least one number from each half. A best answer that uses one half alone is then lost. Here the best subsequence is [6], which uses only the right half.

    Example: nums = [-4, 6], goal = 6
    Mistake: returns 4Fix: returns 0
  3. 3

    Gotcha 3: Splitting with nums[half + 1:] for the right half

    Mistake
    right = sorted(all_sums(nums[half + 1:]))
    Fix
    right = sorted(all_sums(nums[half:]))

    What happens: nums[:half] ends just before index half. nums[half + 1:] starts after it, so nums[half] is in neither half. No total can use that number.

    Example: nums = [2, 9, 1, 8], goal = 13
    Mistake: returns 2Fix: returns 1
  4. 4

    Gotcha 4: Searching right with bisect_left before sorting it

    Mistake
    right = all_sums(nums[half:])
    Fix
    right = sorted(all_sums(nums[half:]))

    What happens: bisect_left assumes that right is sorted. all_sums builds its list in the order of the choices, not in sorted order. On an unsorted list, bisect_left returns a wrong index. The two neighbours it checks are then not the closest sums.

    Example: nums = [9, -7], goal = -7
    Mistake: returns 7Fix: returns 0
Canonical LeetCode:Closest Subsequence Sum (LC 1755)Partition Array Into Two Arrays to Minimize Sum Difference (LC 2035)Split Array With Same Average (LC 805)4Sum II (LC 454)
Open the Meet in the Middle card
Built on the mental models above
  • CombinationsCombinations / Combination Sum

    The loop starts at start and the search tracks a remaining target, so it stops a branch when the remainder goes below zero.

  • String PartitioningString Partitioning Backtracking

    The choice is where the next piece ends, and a piece is taken only if it passes the check.

  • One Choice per SlotCartesian Product Backtracking (One Choice per Slot)

    Each position has its own list of options, so the loop reads that list and there is no start and no used array.

  • PermutationsPermutations

    A used array replaces start, so any element not yet in path can come next, and each element is used once.

  • Constraint BacktrackingConstraint Satisfaction Backtracking (N-Queens, Sudoku)

    Each placement is checked against marked sets (columns and diagonals), and the marks are removed when the search backs out.

  • Find One SolutionFind One Solution and Stop (Sudoku Solver, Word Search)

    The search returns True as soon as one full solution exists, and every caller returns at once, so the rest of the tree is skipped.

  • Subsets IISubsets with Duplicates (Sibling Deduplication)

    Sort first, then skip a value that equals its left neighbour unless it is the first choice of its call, so no subset is saved twice.

The Backtracking Decision Rule

When facing an unseen combinatorial search problem, ask in this order:

Step 1.Must you pick a subset whose sum is best or closest to a target, with n up to about 40 and values too large for a table over sums?
➔ Meet in the Middle
Closest Subsequence Sum (LC 1755)Partition Array Into Two Arrays to Minimize Sum Difference (LC 2035)

Split the input into halves, list every subset sum of each, sort one list and binary search it for each sum of the other.

Step 2.Must you build every fixed-length string where each position has its own short list of options and any mix is allowed?
➔ One Choice per Slot
Letter Combinations of a Phone Number (LC 17)Letter Case Permutation (LC 784)

backtrack(index): record path when index == len(digits); otherwise try each option of slot index and recurse on index + 1.

Step 3.Does the problem ask for every subset, of every possible size?
➔ Subsets
Subsets (LC 78)Count Number of Maximum Bitwise-OR Subsets (LC 2044)

Use start pointer to prevent looking backward, pass i + 1, and snapshot at every node.

Step 4.Does the same input have repeated values, and must each different subset appear only once?
➔ Subsets II
Subsets II (LC 90)Non-decreasing Subsequences (LC 491)

Sort first, then skip nums[i] when i > start and nums[i] == nums[i - 1].

Step 5.Does the problem ask for groups of a specific size, or groups that meet a constraint (like summing to a target), where order does NOT matter?
➔ Combinations
Combinations (LC 77)Combination Sum (LC 39)

Use start pointer to prevent looking backward; snapshot only when len(path) == k or remain == 0. Pass i + 1 to use each item once, i to allow reuse.

Step 6.Does the problem ask for all arrangements where order DOES matter?
➔ Permutations
Permutations (LC 46)Permutations II (LC 47)

Loop from 0 to N-1 with used boolean array or in-place swapping.

Step 7.Does the problem partition a string into valid segments (palindromes, dictionary words, IP parts)?
➔ String Partitioning
Palindrome Partitioning (LC 131)Restore IP Addresses (LC 93)

Loop end from start to N-1, validate prefix, and recurse on end + 1.

Step 8.Is one valid arrangement enough, or does the question ask only whether one exists (Sudoku, Word Search)?
➔ Find One Solution
Sudoku Solver (LC 37)Word Search (LC 79)

Make the recursive function return True when every position is filled, return True at once when a call returns True, and undo a placement only after a call returned False.

Step 9.Do you fill positions one at a time, checking each placement against rules (N-Queens, Sudoku, balanced parentheses, word search)?
➔ Constraint Backtracking
N-Queens (LC 51)Sudoku Solver (LC 37)

Maintain O(1) lookup sets (cols, diag1, diag2) and branch row by row; undo every mark on the way back.

Pattern Architecture & Invariant Design Masterclass

Backtracking is how you explore every possibility without ever building them all in memory at once. You keep exactly one partial answer — a single list you keep editing — and you walk a decision tree with three moves that always appear together:

  1. Choose. Add one option to the partial answer.
  2. Explore. Recurse, and let the rest of the decisions be made deeper down.
  3. Un-choose. Remove that option again, so the next sibling branch starts from an identical, uncontaminated state.

Step 3 is the whole pattern and is the step students omit. Without it, choices from one branch leak into the next and every later answer is wrong.

The difference from plain DFS (the DFS pattern) is exactly this: DFS walks a structure that already exists. Backtracking walks a decision tree that exists only while you are standing in it — the tree is the sequence of choices, and un-choosing is what deletes the part you have finished with.

Distinguishing State vs. Path (Mental Model Clarity)

It is important to keep State and Path distinct:

  • State (start, index, remaining / rem): The forward-looking subproblem parameters passed to recursion that determine what future choices are legally available. Because State is path-independent, it serves as the key for memoization (memo[rem]).
  • Path (path = []): The backward-looking mutable history buffer tracking the sequence of decisions from the root. When exploring choices, you do path.append(choice); when backtracking, you do path.pop(). Optimization problems (like Coin Change) only track State and don't even need path.
The 3 Core Moves in Action (Micro-Step Trace)

Consider generating combinations of length 2 from ['A', 'B', 'C']:

  • Step 0 (Clean Root): path = []
  • Step 1 (CHOOSE 'A'): path.append('A') → path = ['A']
  • Step 2 (EXPLORE): backtrack(path) recursive call begins.
  • Step 3 (CHOOSE 'B'): path.append('B') → path = ['A', 'B']
  • Step 4 (RECORD): result.append(list(path)) → Saves frozen copy ['A', 'B'].
  • Step 5 (UN-CHOOSE 'B'): path.pop() → path is cleanly restored to ['A'].
  • Step 6 (CHOOSE 'C'): the loop under 'A' moves on: path.append('C') → path = ['A', 'C'].
  • Step 7 (RECORD): saves the copy ['A', 'C'].
  • Step 8 (UN-CHOOSE 'C'): path.pop() → path = ['A']; nothing is left after 'C'.
  • Step 9 (UN-CHOOSE 'A'): path.pop() → path = [], so the next root sibling 'B' starts clean and builds ['B', 'C'].

Mental Model Context & Problem Statement: The decision tree diagram below traces the backtracking process for Generating Combinations of Length 2 from Elements ['A', 'B', 'C'] (with sibling choice and branch exploration).

Interactive Architecture Diagram
Synthesizing vector architecture diagram...
🔬 Variable Anatomy: The 7 Variables Every Backtracker Uses

To connect abstract concepts with concrete Python syntax, here is the universal code anatomy showing what every variable does:

Code / Blueprint
def backtrackingMasterTemplate(nums: list[int], target: int) -> list[list[int]]:
# =========================================================================
# 1. THE STORAGE POOLS
# =========================================================================
results = [] # 📦 Global output container (stores frozen snapshot copies)
path = [] # 🧵 Single shared mutable buffer (the active path from root)
used = [False] * len(nums) # 🚫 used[i]: nums[i] is in path (ONLY for Permutations, where start is dropped)
memo = {} # 🧠 Memoization cache (for DP/Backtracking subproblems: Word Break, Coin Change)
 
# =========================================================================
# 2. THE RECURSIVE FUNCTION & VARIABLE ROLES
# =========================================================================
def dfs(
start: int, # 🚪 One-Way Door: boundary index (prevents backward duplicates)
remaining: int # 🎯 Dynamic Quota: target quota decremented on each choice
) -> None:
# --- A. BASE CASES / GOAL CHECK ---
if remaining == 0:
# ⚠️ CRITICAL: Must snapshot with list(path) or path[:], NEVER append(path)!
results.append(list(path))
return
if remaining < 0:
return # ✂️ Child Pruning: overshoot infeasibility
 
# --- B. THE CHOICES LOOP ---
for i in range(start, len(nums)):
# 'i' = 🎯 The specific candidate being evaluated right now
candidate = nums[i]
 
# --- C. PRUNING GUARDS ---
# Guard 1: Sibling Deduplication (Subsets II / CS II)
# If sorted and duplicate choice at this same depth, skip!
if i > start and nums[i] == nums[i - 1]:
continue
 
# Guard 2: Early Break (Sorted array exceeds remaining target)
if candidate > remaining:
break
 
# --- D. THE 3 CORE MOVES ---
path.append(candidate) # 1️⃣ CHOOSE: add to mutable buffer
# 2️⃣ EXPLORE: step 1 depth deeper (going down in the depth of the decision tree)
# - For Subsets/Combinations: pass `i + 1` (forward only)
# - For Unbounded Reuse: pass `i` (stay at current index)
# - For Permutations: drop start, loop 0..N, toggle used[i]
dfs(i + 1, remaining - candidate) # 🌲 Going down in the depth of the decision tree
path.pop() # 3️⃣ UN-CHOOSE: restore state byte-for-byte
 
# =========================================================================
# 3. KICK OFF RECURSION
# =========================================================================
nums.sort() # Required for adjacent deduplication and early break pruning
dfs(0, target)
return results
 
Variable Role Reference:
VariableRole in CodeQuestion It Answers❌ What Happens If Omitted or Mismanaged?
pathSingle mutable buffer"What decisions have I made from root to here?"❌ If you don't pop(), past choices leak into sibling branches and corrupt state.
resultsOutput collection"Where do completed valid answers get stored?"❌ Appending path instead of list(path) appends a mutable reference, resulting in [[], [], []].
startOne-Way Door index"Which items am I legally allowed to look at?"❌ Without start, combinations produce backward duplicates like [1,2] and [2,1].
iLoop choice cursor"Which specific candidate am I trying right now?"❌ Passing start + 1 instead of i + 1 lets a number repeat ([2, 2]) and a group appear in two orders ([2, 3] and [3, 2]).
usedOne flag per position"Did I already pick this exact element in this branch?"❌ Without used, Permutations pick the exact same index multiple times (e.g. [1, 1, 1]).
remainingDynamic target quota"How much capacity / target remains to be satisfied?"❌ Without remaining, checking target sums requires expensive repeated sum(path) calls.
memoSubproblem cache"Have I already solved this remaining subproblem?"❌ Without memo, branching explosions re-solve identical subproblems in exponential time.

🧠 The Universal Contract of Memoization & State Arrays (memo / dp):
Every memoization table or array operates on a strict Index →\rightarrow→ Value contract:

  • The Index / Key (i / rem): Represents the exact subproblem state you are currently trying to solve (e.g. in Coin Change: the exact remaining amount of money; in Word Break / Decode Ways: the suffix start position s[i:]).
  • The Value (memo[i]): Represents the optimal answer or viability for that exact subproblem (e.g. in Coin Change: the minimum number of coins required to make that exact amount i; in Decode Ways: total valid decoding ways for suffix from i).

🌟 The 3 Dimensions of Backtracking & The "One-Way Door" Principle

Recursion itself is what controls the depth. The start index does not control depth; it acts as a One-Way Door for your choices.

To make this crystal clear, let's break a for-loop down into the 3 Dimensions of Backtracking:

  1. The Recursion = Depth

    • Every time you call dfs(), you are stepping one level deeper into the tree.
    • Question it answers: "How many items are currently in my path?"
  2. The Loop Variable (i) = The Choices

    • The for i in range(...) loop represents you standing at a specific depth and pointing at the options laid out in front of you.
    • Question it answers: "Which specific item am I picking right now?"
  3. The start Variable (or used set) = The Constraints

    • start is a constraint filter. It determines the boundary of what i is allowed to look at.
    • Question it answers: "Which items am I allowed to pick from?"
How this plays out in actual problems:
  • Scenario A: Subsets / Combinations (Using start): order doesn't matter, so the next call starts after the pick and never looks back (the Subsets and Combinations cards).
  • Scenario B: Permutations (Dropping start): order matters, so every depth loops from 0 and skips positions already in path (the Permutations card).
💡 The Ultimate Takeaway

When you are looking at a new problem, don't ask "what should my start index be?"

Instead, ask: "Am I allowed to look backward in the array?"

  • If NO (Combinations/Subsets): Use a start index and pass dfs(i + 1).
  • If YES (Permutations): Drop the start index, loop from 0 every time, and skip positions whose used[i] flag is set.

🎯 The Backtracking Cards at a Glance

Each shape below has its own card with the full blueprint, trap and worked example:

  • Subsets: save at every node; among equal values skip a sibling repeat (the Subsets card).
  • Combinations: save only a complete group; recurse with i + 1, or i when a number may be reused (the Combinations card).
  • Permutations: loop from 0 and skip positions already used (the Permutations card).

When Memoization Applies: Minimum Number of Coins (Coin Change as Backtracking vs DP)
  • Decision Tree Architecture:
    • At every step, you have the goal of reaching 0 from amount.
    • Choice Continuity: Because coins can be reused without restriction, all coin denominations [c1, c2, c3] remain accessible at every depth.
  • The Top-Down Memoized Decision Tree (O(C × A), where C = len(coins) and A = amount):
Code / Blueprint
def coinChange(coins: list[int], amount: int) -> int:
memo = [-1] * (amount + 1)
 
def dfs(rem: int) -> int:
if rem == 0:
return 0 # 0 coins needed to make 0 amount
if rem < 0:
return float('inf') # Invalid path, we overshot
if memo[rem] != -1:
return memo[rem] # Return previously calculated answer
 
ans = float('inf')
for coin in coins:
result = dfs(rem - coin)
if result != float('inf'):
ans = min(ans, result + 1)
 
memo[rem] = ans
return ans
 
res = dfs(amount)
return res if res != float('inf') else -1
 
  • Why Memoization / DP is Required:
    • Notice that rem is the only state variable needed to evaluate future decisions. The exact path of coins chosen to reach rem = 7 does not change the optimal number of coins needed to clear the remaining 7.
    • Initializing memo = [-1] * (amount + 1) collapses the exponential O(C^A) unpruned decision tree into an optimal O(C × A) linear state table (where C = len(coins) and A = amount)!

🌳 Evaluating Decision Trees, State Management & Option Pools

Building a mental decision tree before writing code establishes the foundation for any backtracking algorithm, helping map out choices, constraints, and goal states.

1. Evaluating Decision Trees and State Management
  • Choice Continuity: When options remain static across recursive calls (such as picking any coin of any denomination in a problem where element reuse is allowed), the full option pool stays accessible, and a position tracker is not strictly required.
  • Option Consumption: When elements can only be used once (like subsets or permutations), the choice space shrinks. Passing an index parameter or maintaining a visited array ensures execution moves forward and avoids redundant selections.
  • The Nuance of Memoization: Standard backtracking explores every valid path to generate configurations or navigate exponential search spaces (O(2n)O(2^n)O(2n) or O(n!)O(n!)O(n!)). Memoization only applies if the problem exhibits overlapping subproblems—meaning the exact same state can be reached via different paths and the remaining future outcomes are identical. If states are strictly path-dependent (such as tracking an ordered sequence or permutation), memoization is ineffective unless the problem is reframed as dynamic programming.
2. Core Backtracking Implementation Tips
  • Master the Standard Template: Structure every backtracking function around four core actions: check the base case (is the goal met?), record/process the valid path, iterate through choices, and undo the choice (backtrack) before moving to the next iteration.
  • Prune Early: If a partial state violates a constraint (e.g., the current running sum exceeds a target), break or continue immediately to cut off entire subtrees of the decision tree.
  • Pass State Efficiently: Instead of slicing arrays or allocating new collections at every recursive frame—which introduces unnecessary O(N)O(N)O(N) or O(N2)O(N^2)O(N2) overhead—modify a single data structure in place and backtrack by popping elements or reverting state variables.

💡 Staff SWE Architectural Invariant:
Decision trees, state progression and shrinking or static option pools describe every backtracking search; memoization helps only when subproblems overlap.

Understanding Invariants: Choose, Explore, Un-choose & Sibling vs Child Pruning

The mutable path state must reflect only the sequence of choices leading from the root to the current node. Upon returning from exploring a child branch, path.pop() restores the exact state before that choice was made.

Recursion itself is what controls the depth (stepping 1 level deeper). The start index acts as a One-Way Door for your choices, locking previous indices behind you in forward-only combinations.

Child Pruning (Infeasibility / Capacity overshoot)
Condition: If adding candidate c exceeds the target (c > remaining in sorted array)
Action: break the loop immediately

Because all subsequent sorted candidates will also overshoot.

Sibling Pruning (Deduplication)
Condition: If candidate at index i equals candidate at i-1 and i > start
Action: continue

To prevent generating duplicate combinations from the same level.

Common Questions
  • "Since path is shared and mutated in place, why do we have to explicitly path.pop() after recursing — couldn't we just pass a new list at each call instead, so there's nothing to undo?"

    You could — and some solutions do — but it trades a cheap operation for an expensive one, and it's worth knowing why the mutate-and-undo version is the default.

    Passing a new list means allocating and copying the whole path at every single call: backtrack(path + [choice]) creates an O(depth) copy on every recursive step, for every node in the decision tree. Across a tree with exponentially many nodes, those copies dominate the runtime — you can turn an already-exponential algorithm asymptotically worse for no algorithmic reason.

    Mutating one shared buffer and undoing the mutation costs O(1) per step: path.append(choice) on the way down, path.pop() on the way back up. The byte-for-byte restoration invariant is what makes this safe — as long as every append is matched by exactly one pop before the function returns, sibling branches never see each other's leftover state, so there's no correctness difference between the two approaches, only a performance one.

    The one place this causes a bug: results.append(path) (appending the reference) instead of results.append(list(path)) (a snapshot). Since path keeps getting mutated after you've stored a reference to it, every entry you thought you saved silently changes too — usually ending up as a list of identical, empty-looking results. Snapshot when you record; mutate-and-undo everywhere else.

Coach discussion points
  1. 1.

    "You said O(2ⁿ). Walk me through what happens on the line where you record a subset, and tell me again." — Surfaces the O(n) snapshot cost hidden inside the recording line. The highest-signal question in this pattern.

  2. 2.

    "Delete the path.pop() line. What is the first input that produces a wrong answer, and what does the wrong answer look like?" — Forces the student to reason about state contamination concretely rather than reciting "you need to backtrack".

  3. 3.

    "Why does the deduplication check use i > start and not i > 0? Give me the input that distinguishes them." — [1,2,2]: i > 0 would also prune the legitimate [1,2,2] branch. This one question separates memorisation from understanding.

  4. 4.

    "You could have used a set to remove duplicate subsets at the end. Why is pruning better, and is there a case where the set is the right call?" — Tests judgement, not just technique; the honest answer includes "when the dedup rule is too complex to express as a branch prune."

  5. 5.

    "In Sudoku Solver, why do we check the row, column, AND box for every candidate digit, instead of just checking whether the digit already appears anywhere on the board?" — Surfaces the understanding that a digit can legally repeat elsewhere on the board (in a different row/column/box) — only the three local neighborhoods matter, which is exactly why the check stays bounded at 27 cells instead of scanning all 81.

  6. 6.

    "Restore IP Addresses has no visible 'pruning' step like N-Queens' diagonal sets. Is it still backtracking? Why is it still fast?" — Tests whether the student recognizes that bounding the branching factor (3) and the depth (4) is itself a form of built-in pruning — the search space is small by construction, not because of an explicit early-exit check.

Complexity analysis

Time for Subsets II (LC 90): O(n · 2ⁿ) — not O(2ⁿ). There are 2ⁿ subsets, and each one is copied into the results list when recorded. A subset averages n/2 elements, so each copy costs O(n). Multiply: O(n · 2ⁿ). Candidates who say O(2ⁿ) have forgotten the snapshot cost, and this is the specific thing a Staff-level interviewer listens for.

Time for Permutations (LC 46): O(n · n!). n! complete orderings, each copied at O(n).

Time for Combination Sum (LC 39): O(n^(T/m)) where T is the target and m the smallest candidate — the tree's depth is at most T/m and its branching factor at most n. Bounds like this one are legitimately loose; saying "exponential in target / min(candidates), and here is why" is a better answer than a confidently wrong tight bound.

Space: O(n) auxiliary, not counting the output. One shared path of at most n elements, plus a recursion stack at most n deep. The output itself is O(n · 2ⁿ) and is conventionally excluded — but say that you are excluding it rather than silently ignoring it.

Why pruning matters even though the bound stays exponential: pruning does not change the worst case; it changes the actual case, often by orders of magnitude. In Subsets II (LC 90), two prunes removed 25% of the tree on a three-element input. That ratio grows with n.

Production Systems Deployment & Motivation

A CI (Continuous Integration) system must decide which subset of 20 optional feature flags to enable for a canary release. Some combinations are forbidden. There is no formula — the only way is to try combinations, and the moment a partial combination already violates a rule, every completion of it also violates it and the entire branch can be abandoned unexplored. That abandonment ("pruning") is why backtracking beats generating all 2²⁰ combinations blindly. Sudoku solvers, regex engines, dependency resolvers, and constraint schedulers are all this same loop.

Mastery Roadmap & Prerequisite Graph

💡 Don't worry if this feels abstract right now! Backtracking is one fundamental 3-move loop applied across different search spaces. As you progress through the Archetype Variants (from Phone Numbers and Subsets II to Combination Sum and N-Queens), each hands-on challenge will cement this intuition until writing path.append() and path.pop() becomes second nature.

  • Appending live path reference instead of shallow copy: results.append(path) stores a reference to the mutable list. When backtracking empties the list with path.pop(), all elements in results become empty lists []. Always append path[:] or list(path).

  • Duplicate subsets from unsorted inputs: If inputs contain duplicates, subsets will generate duplicate combinations unless inputs are sorted and sibling duplicates skipped.

  • Recursion stack depth exceeding limits: For N>20N > 20N>20, 2N2^N2N subsets exceed time and memory bounds. Ensure N≤20N \le 20N≤20.

Staff+ Engineering Perspective

Staff+ Systems Architecture & Hardware Considerations

ARCHITECTURAL SIGNALS & INTERVIEW TRIGGERS

Triggers: "generate all valid combinations / permutations / subsets", "find all solutions satisfying constraints", "Sudoku / N-Queens constraint satisfaction". Systematic state-space tree traversal with early pruning of non-viable branches.

CONSTRAINTS & BOUNDS

Problem search space size NNN is small (N≤20N \le 20N≤20 for power sets O(2N)O(2^N)O(2N), N≤12N \le 12N≤12 for permutations O(N!)O(N!)O(N!)). Without pruning, exponential searches time out; aggressive early pruning drops branch complexity by orders of magnitude.

FAANG PRODUCTION TRAPS & EDGE CASES

Accidentally appending mutable reference res.append(path) instead of deep copy res.append(list(path)); exponential duplicate branch explosion when inputs contain duplicates (mitigate via sibling branch pruning nums[i] == nums[i-1]); inefficient backtracking state cloning.

Theory Context•Backtracking & State Search

Conceptual Foundation & Intuitive Analogy

🧭 Conceptual Foundation & Pattern Intuition

Backtracking is a systematic method for exploring large, combinatorial decision spaces. Where simple iteration scans a flat list, backtracking explores a tree of potential choices where every node represents a decision frame. The foundational triad of the pattern is Choose, Explore, Un-Choose:

  1. Choose: Select a candidate element and add it to the active path.
  2. Explore: Recurse down the branch to evaluate all consequences of that choice.
  3. Un-Choose: Symmetrically remove the candidate from the path before returning to the parent, leaving zero trace of the experiment. For the power set (Subsets), we use a forward-advancing start index: each recursive step decides which remaining elements to include, naturally generating all 2N2^N2N unique subsets without generating duplicate permutations.
🏟️ The Analogy: Packing a Backpack for a Weekend Hike

Imagine packing a small backpack from a table of gear: a flashlight, a compass, and a camera. You start with an empty pack (Subset 1: []). You add the flashlight (Subset 2: ["flashlight"]). Keeping the flashlight in, you add the compass (Subset 3: ["flashlight", "compass"]). Keeping both, you add the camera (Subset 4: ["flashlight", "compass", "camera"]). You have reached the end of the table. You remove the camera (un-choose), remove the compass (un-choose), and try adding just the camera (Subset 5: ["flashlight", "camera"]). You unpack and repack one item at a time until every possible gear combination has been cataloged.

🔑 Why It Works

Notice results.append(path[:]). If you write results.append(path), Python stores a reference to the single mutable list. As backtracking unwinds, path.pop() empties that list, leaving results filled with empty arrays [[], [], ...]. Taking a snapshot path[:] freezes the state in amber.

Visual Blueprint & State Transition Model
Backtracking & State Search Mental Model Diagram
Figure 1 • Backtracking & State Search Core Invariant & State Lifecycle
Core Invariant Specification

When a recursive call returns, the shared state is byte-for-byte what it was before the call. Every branch starts clean.

Boundary Model: State Space Tree: Choose -> Explore -> Un-choose

Branch exploration via in-place mutation followed by immediate backtrack restoration. Sibling deduplication prunes duplicate subtrees.

Loop Invariant Termination

Recursive base case: if len(path) == target: record copy; for i in range(start, n): choose, recurse, un-choose.

The 2 Mental Models of Backtracking

Distinguish whether the decision tree snapshots every node, leaves only, or permutational arrangements.

In the realm of backtracking, combinatorial generation generally splits into three distinct families based on how you navigate the state space:

  • Subsets (Power Sets): Generate all possible group sizes. → Subsets
  • Combinations: Generate groups of a specific size or matching a specific constraint (like summing to a target), where order does not matter. → Combinations
  • Permutations: Generate arrangements where order does matter. → Permutations

All three share the same choose → explore → un-choose loop. Only two decisions change: when you record path, and where the next call starts.

FamilyRecord pathNext call
SubsetsEvery nodei + 1
Combinationslen(path) == k or remain == 0i + 1, or i to reuse
Permutationslen(path) == len(nums)loop from 0, skip used[i]

For nums = [1,2,3]: 8 subsets (sizes 0 to 3), 3 combinations of size 2 ([1,2], [1,3], [2,3]), and 6 permutations, because [1,2] ≠ [2,1].

One Choice per Slot, String Partitioning and Constraint Backtracking reuse the same loop to fill fixed slots, to cut a string into valid pieces and to fill a board. Meet in the Middle splits the input in half when 2^n choices are too many.

1Subsets
elements ➔ every decision tree node is valid ➔ snapshot deep copy

Subsets (Power Set)

Spot it when: You must list all subsets, subsequences or on/off choices of the input.
Mode 1 · Subsets
Python
1from typing import List
2 
3def subsets(nums: List[int]) -> List[List[int]]:
4 """Subsets (LC 78): every subset of nums, saved once at the node of the decision tree that builds it.
5 N = len(nums) and all values are different.
6 Time: O(N * 2^N) | Space: O(N) path + recursion stack, output not counted
7 """
8 results: List[List[int]] = []
9 path: List[int] = [] # the choices from the root to the current node, in order
10 
11 def backtrack(start: int) -> None:
12 # 1. Save This Node:
13 # Every node of the decision tree is a valid subset, so copy path into results
14 # as soon as the call starts, before trying any further choice.
15 results.append(list(path))
16 
17 for i in range(start, len(nums)):
18 # 2. Choose, Explore, Un-choose:
19 # Add nums[i] to path, recurse forward so the next call only sees later
20 # indices, then remove nums[i] so the loop's next i tries its own branch.
21 path.append(nums[i])
22 backtrack(i + 1)
23 path.pop()
24 
25 backtrack(0)
26 return results

Every node of the decision tree is a valid subset, so save a copy of path as soon as you enter backtrack. The loop only picks indices from start onward, so each subset is built in one order and saved once. The trap is saving path itself: path keeps changing with append and pop, so write results.append(list(path)), or every saved subset ends up empty.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Saving path instead of a copy list(path)

    Mistake
    results.append(path)
    Fix
    results.append(list(path))

    What happens: results.append(path) stores the same list object every time. The code keeps changing that one list with append and pop. At the end it is empty, so every saved subset is empty.

    Example: nums = [1, 2]
    Mistake: returns [[], [], [], []]Fix: returns [[], [1], [1, 2], [2]]
  2. 2

    Gotcha 2: Recursing with start + 1 instead of i + 1

    Mistake
    backtrack(start + 1)
    Fix
    backtrack(i + 1)

    What happens: The next call must start after the index that was just picked, which is i. With start + 1, the next call can pick the same index i again, so one value appears twice in a subset.

    Example: nums = [1, 2]
    Mistake: returns [[], [1], [1, 2], [2], [2, 2]]Fix: returns [[], [1], [1, 2], [2]]
  3. 3

    Gotcha 3: Forgetting to remove the choice with path.pop()

    Mistake
    pass
    Fix
    path.pop()

    What happens: path keeps the choice after the call returns. The next value of i then starts from a path that already holds the old choice, so every later subset contains a wrong item.

    Example: nums = [1, 2]
    Mistake: returns [[], [1], [1, 2], [1, 2, 2]]Fix: returns [[], [1], [1, 2], [2]]
  4. 4

    Gotcha 4: Saving a subset only when the loop reaches the end

    Mistake
    if start == len(nums): results.append(list(path))
    Fix
    results.append(list(path))

    What happens: A save at the end happens only when start equals len(nums), which means the last index was just picked. Every subset that does not use the last index is missed, including the empty subset.

    Example: nums = [1, 2]
    Mistake: returns [[1, 2], [2]]Fix: returns [[], [1], [1, 2], [2]]
Canonical LeetCode:Subsets (LC 78)Count Number of Maximum Bitwise-OR Subsets (LC 2044)Generalized Abbreviation (LC 320)Sum of All Subset XOR Totals (LC 1863)
Open the Subsets card
2Meet in the Middle
n up to 40 ➔ every subset sum of each half ➔ sort one half, binary search the other

Meet in the Middle

Spot it when: You must pick a subset (a subsequence, or a split into two groups) whose sum is best or closest to a target, n is up to about 40, and the values are too large for a table over sums: 2^n choices are far too many, but 2^(n/2) for each half are fine.
Mode 2 · Meet in the Middle
Python
1from bisect import bisect_left
2 
3 
4def min_abs_difference(nums: list[int], goal: int) -> int:
5 """Closest Subsequence Sum: meet in the middle on the subset sums of each half.
6 Time: O(2^(N/2) * N) | Space: O(2^(N/2))
7 """
8 def all_sums(part: list[int]) -> list[int]:
9 # 1. Extend Every Existing Sum:
10 # sums starts as [0], the sum of choosing nothing from part; for each number x,
11 # adding x to a copy of every sum already found accounts for both taking x and
12 # not taking it, so sums ends up holding every subset sum of part.
13 sums = [0]
14 for x in part:
15 sums += [s + x for s in sums]
16 return sums
17 
18 half = len(nums) // 2
19 left = all_sums(nums[:half]) # every subset sum achievable from the left half
20 right = sorted(all_sums(nums[half:])) # every subset sum from the right half, sorted for binary search
21 
22 best = abs(goal) # choosing nothing from either half gives the sum 0
23 for s in left:
24 # 2. Find the Closest Right Sum for This Left Sum:
25 # Every subsequence sum is one left sum s plus one right sum; need is the right
26 # sum that would make the total exactly goal, and bisect_left on the sorted
27 # right list finds where need would sit among the right sums.
28 need = goal - s
29 k = bisect_left(right, need)
30 # 3. Check Both Neighbors of the Insertion Point:
31 # The closest right sum to need is whichever of right[k] (the first sum >= need)
32 # and right[k - 1] (the last sum < need) is nearer; only one of the two may exist.
33 if k < len(right):
34 best = min(best, right[k] - need)
35 if k > 0:
36 best = min(best, need - right[k - 1])
37 return best

Split nums into two halves and list every subset sum of each half with all_sums: for 40 numbers that is two lists of about a million sums instead of a trillion subsequences. Every subsequence is one choice from the left half plus one from the right half, so for each left sum s you only need the right sum closest to need = goal - s: sort right once and find it with k = bisect_left(right, need). This is correct because all_sums starts from [0], the empty choice, so a subsequence that takes nothing from one half is still one left sum plus one right sum, and in a sorted list the value closest to need always sits next to the place where need would be inserted. The trap is checking only right[k], the first sum at or above need: the closest one may be just below it, so also check right[k - 1] when k > 0. On [2, 9, 1, 8] with goal = 13, the best sum, 12, is s = 11 plus the right sum 1, just below need = 2; checking only right[k] returns 4 instead of 1.

Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Checking only right[k] and not right[k - 1]

    Mistake
    if k < len(right):
        best = min(best, right[k] - need)
    Fix
    if k < len(right):
        best = min(best, right[k] - need)
    if k > 0:
        best = min(best, need - right[k - 1])

    What happens: bisect_left returns the place where need would be inserted. right[k] is the smallest sum that is greater than or equal to need. The closest sum can be right[k - 1], just below need. Without that check, the best total is missed.

    Example: nums = [2, 9, 1, 8], goal = 13
    Mistake: returns 4Fix: returns 1
  2. 2

    Gotcha 2: Listing only non-empty choices in all_sums

    Mistake
    sums = []
    for x in part:
        sums += [s + x for s in sums] + [x]
    Fix
    sums = [0]
    for x in part:
        sums += [s + x for s in sums]

    What happens: The sum 0 stands for taking nothing from a half. Without it, every total takes at least one number from each half. A best answer that uses one half alone is then lost. Here the best subsequence is [6], which uses only the right half.

    Example: nums = [-4, 6], goal = 6
    Mistake: returns 4Fix: returns 0
  3. 3

    Gotcha 3: Splitting with nums[half + 1:] for the right half

    Mistake
    right = sorted(all_sums(nums[half + 1:]))
    Fix
    right = sorted(all_sums(nums[half:]))

    What happens: nums[:half] ends just before index half. nums[half + 1:] starts after it, so nums[half] is in neither half. No total can use that number.

    Example: nums = [2, 9, 1, 8], goal = 13
    Mistake: returns 2Fix: returns 1
  4. 4

    Gotcha 4: Searching right with bisect_left before sorting it

    Mistake
    right = all_sums(nums[half:])
    Fix
    right = sorted(all_sums(nums[half:]))

    What happens: bisect_left assumes that right is sorted. all_sums builds its list in the order of the choices, not in sorted order. On an unsorted list, bisect_left returns a wrong index. The two neighbours it checks are then not the closest sums.

    Example: nums = [9, -7], goal = -7
    Mistake: returns 7Fix: returns 0
Canonical LeetCode:Closest Subsequence Sum (LC 1755)Partition Array Into Two Arrays to Minimize Sum Difference (LC 2035)Split Array With Same Average (LC 805)4Sum II (LC 454)
Open the Meet in the Middle card
Built on the mental models above
  • CombinationsCombinations / Combination Sum

    The loop starts at start and the search tracks a remaining target, so it stops a branch when the remainder goes below zero.

  • String PartitioningString Partitioning Backtracking

    The choice is where the next piece ends, and a piece is taken only if it passes the check.

  • One Choice per SlotCartesian Product Backtracking (One Choice per Slot)

    Each position has its own list of options, so the loop reads that list and there is no start and no used array.

  • PermutationsPermutations

    A used array replaces start, so any element not yet in path can come next, and each element is used once.

  • Constraint BacktrackingConstraint Satisfaction Backtracking (N-Queens, Sudoku)

    Each placement is checked against marked sets (columns and diagonals), and the marks are removed when the search backs out.

  • Find One SolutionFind One Solution and Stop (Sudoku Solver, Word Search)

    The search returns True as soon as one full solution exists, and every caller returns at once, so the rest of the tree is skipped.

  • Subsets IISubsets with Duplicates (Sibling Deduplication)

    Sort first, then skip a value that equals its left neighbour unless it is the first choice of its call, so no subset is saved twice.

The Backtracking Decision Rule

When facing an unseen combinatorial search problem, ask in this order:

Step 1.Must you pick a subset whose sum is best or closest to a target, with n up to about 40 and values too large for a table over sums?
➔ Meet in the Middle
Closest Subsequence Sum (LC 1755)Partition Array Into Two Arrays to Minimize Sum Difference (LC 2035)

Split the input into halves, list every subset sum of each, sort one list and binary search it for each sum of the other.

Step 2.Must you build every fixed-length string where each position has its own short list of options and any mix is allowed?
➔ One Choice per Slot
Letter Combinations of a Phone Number (LC 17)Letter Case Permutation (LC 784)

backtrack(index): record path when index == len(digits); otherwise try each option of slot index and recurse on index + 1.

Step 3.Does the problem ask for every subset, of every possible size?
➔ Subsets
Subsets (LC 78)Count Number of Maximum Bitwise-OR Subsets (LC 2044)

Use start pointer to prevent looking backward, pass i + 1, and snapshot at every node.

Step 4.Does the same input have repeated values, and must each different subset appear only once?
➔ Subsets II
Subsets II (LC 90)Non-decreasing Subsequences (LC 491)

Sort first, then skip nums[i] when i > start and nums[i] == nums[i - 1].

Step 5.Does the problem ask for groups of a specific size, or groups that meet a constraint (like summing to a target), where order does NOT matter?
➔ Combinations
Combinations (LC 77)Combination Sum (LC 39)

Use start pointer to prevent looking backward; snapshot only when len(path) == k or remain == 0. Pass i + 1 to use each item once, i to allow reuse.

Step 6.Does the problem ask for all arrangements where order DOES matter?
➔ Permutations
Permutations (LC 46)Permutations II (LC 47)

Loop from 0 to N-1 with used boolean array or in-place swapping.

Step 7.Does the problem partition a string into valid segments (palindromes, dictionary words, IP parts)?
➔ String Partitioning
Palindrome Partitioning (LC 131)Restore IP Addresses (LC 93)

Loop end from start to N-1, validate prefix, and recurse on end + 1.

Step 8.Is one valid arrangement enough, or does the question ask only whether one exists (Sudoku, Word Search)?
➔ Find One Solution
Sudoku Solver (LC 37)Word Search (LC 79)

Make the recursive function return True when every position is filled, return True at once when a call returns True, and undo a placement only after a call returned False.

Step 9.Do you fill positions one at a time, checking each placement against rules (N-Queens, Sudoku, balanced parentheses, word search)?
➔ Constraint Backtracking
N-Queens (LC 51)Sudoku Solver (LC 37)

Maintain O(1) lookup sets (cols, diag1, diag2) and branch row by row; undo every mark on the way back.

Pattern Architecture & Invariant Design Masterclass

Backtracking is how you explore every possibility without ever building them all in memory at once. You keep exactly one partial answer — a single list you keep editing — and you walk a decision tree with three moves that always appear together:

  1. Choose. Add one option to the partial answer.
  2. Explore. Recurse, and let the rest of the decisions be made deeper down.
  3. Un-choose. Remove that option again, so the next sibling branch starts from an identical, uncontaminated state.

Step 3 is the whole pattern and is the step students omit. Without it, choices from one branch leak into the next and every later answer is wrong.

The difference from plain DFS (the DFS pattern) is exactly this: DFS walks a structure that already exists. Backtracking walks a decision tree that exists only while you are standing in it — the tree is the sequence of choices, and un-choosing is what deletes the part you have finished with.

Distinguishing State vs. Path (Mental Model Clarity)

It is important to keep State and Path distinct:

  • State (start, index, remaining / rem): The forward-looking subproblem parameters passed to recursion that determine what future choices are legally available. Because State is path-independent, it serves as the key for memoization (memo[rem]).
  • Path (path = []): The backward-looking mutable history buffer tracking the sequence of decisions from the root. When exploring choices, you do path.append(choice); when backtracking, you do path.pop(). Optimization problems (like Coin Change) only track State and don't even need path.
The 3 Core Moves in Action (Micro-Step Trace)

Consider generating combinations of length 2 from ['A', 'B', 'C']:

  • Step 0 (Clean Root): path = []
  • Step 1 (CHOOSE 'A'): path.append('A') → path = ['A']
  • Step 2 (EXPLORE): backtrack(path) recursive call begins.
  • Step 3 (CHOOSE 'B'): path.append('B') → path = ['A', 'B']
  • Step 4 (RECORD): result.append(list(path)) → Saves frozen copy ['A', 'B'].
  • Step 5 (UN-CHOOSE 'B'): path.pop() → path is cleanly restored to ['A'].
  • Step 6 (CHOOSE 'C'): the loop under 'A' moves on: path.append('C') → path = ['A', 'C'].
  • Step 7 (RECORD): saves the copy ['A', 'C'].
  • Step 8 (UN-CHOOSE 'C'): path.pop() → path = ['A']; nothing is left after 'C'.
  • Step 9 (UN-CHOOSE 'A'): path.pop() → path = [], so the next root sibling 'B' starts clean and builds ['B', 'C'].

Mental Model Context & Problem Statement: The decision tree diagram below traces the backtracking process for Generating Combinations of Length 2 from Elements ['A', 'B', 'C'] (with sibling choice and branch exploration).

Interactive Architecture Diagram
Synthesizing vector architecture diagram...
🔬 Variable Anatomy: The 7 Variables Every Backtracker Uses

To connect abstract concepts with concrete Python syntax, here is the universal code anatomy showing what every variable does:

Code / Blueprint
def backtrackingMasterTemplate(nums: list[int], target: int) -> list[list[int]]:
# =========================================================================
# 1. THE STORAGE POOLS
# =========================================================================
results = [] # 📦 Global output container (stores frozen snapshot copies)
path = [] # 🧵 Single shared mutable buffer (the active path from root)
used = [False] * len(nums) # 🚫 used[i]: nums[i] is in path (ONLY for Permutations, where start is dropped)
memo = {} # 🧠 Memoization cache (for DP/Backtracking subproblems: Word Break, Coin Change)
 
# =========================================================================
# 2. THE RECURSIVE FUNCTION & VARIABLE ROLES
# =========================================================================
def dfs(
start: int, # 🚪 One-Way Door: boundary index (prevents backward duplicates)
remaining: int # 🎯 Dynamic Quota: target quota decremented on each choice
) -> None:
# --- A. BASE CASES / GOAL CHECK ---
if remaining == 0:
# ⚠️ CRITICAL: Must snapshot with list(path) or path[:], NEVER append(path)!
results.append(list(path))
return
if remaining < 0:
return # ✂️ Child Pruning: overshoot infeasibility
 
# --- B. THE CHOICES LOOP ---
for i in range(start, len(nums)):
# 'i' = 🎯 The specific candidate being evaluated right now
candidate = nums[i]
 
# --- C. PRUNING GUARDS ---
# Guard 1: Sibling Deduplication (Subsets II / CS II)
# If sorted and duplicate choice at this same depth, skip!
if i > start and nums[i] == nums[i - 1]:
continue
 
# Guard 2: Early Break (Sorted array exceeds remaining target)
if candidate > remaining:
break
 
# --- D. THE 3 CORE MOVES ---
path.append(candidate) # 1️⃣ CHOOSE: add to mutable buffer
# 2️⃣ EXPLORE: step 1 depth deeper (going down in the depth of the decision tree)
# - For Subsets/Combinations: pass `i + 1` (forward only)
# - For Unbounded Reuse: pass `i` (stay at current index)
# - For Permutations: drop start, loop 0..N, toggle used[i]
dfs(i + 1, remaining - candidate) # 🌲 Going down in the depth of the decision tree
path.pop() # 3️⃣ UN-CHOOSE: restore state byte-for-byte
 
# =========================================================================
# 3. KICK OFF RECURSION
# =========================================================================
nums.sort() # Required for adjacent deduplication and early break pruning
dfs(0, target)
return results
 
Variable Role Reference:
VariableRole in CodeQuestion It Answers❌ What Happens If Omitted or Mismanaged?
pathSingle mutable buffer"What decisions have I made from root to here?"❌ If you don't pop(), past choices leak into sibling branches and corrupt state.
resultsOutput collection"Where do completed valid answers get stored?"❌ Appending path instead of list(path) appends a mutable reference, resulting in [[], [], []].
startOne-Way Door index"Which items am I legally allowed to look at?"❌ Without start, combinations produce backward duplicates like [1,2] and [2,1].
iLoop choice cursor"Which specific candidate am I trying right now?"❌ Passing start + 1 instead of i + 1 lets a number repeat ([2, 2]) and a group appear in two orders ([2, 3] and [3, 2]).
usedOne flag per position"Did I already pick this exact element in this branch?"❌ Without used, Permutations pick the exact same index multiple times (e.g. [1, 1, 1]).
remainingDynamic target quota"How much capacity / target remains to be satisfied?"❌ Without remaining, checking target sums requires expensive repeated sum(path) calls.
memoSubproblem cache"Have I already solved this remaining subproblem?"❌ Without memo, branching explosions re-solve identical subproblems in exponential time.

🧠 The Universal Contract of Memoization & State Arrays (memo / dp):
Every memoization table or array operates on a strict Index →\rightarrow→ Value contract:

  • The Index / Key (i / rem): Represents the exact subproblem state you are currently trying to solve (e.g. in Coin Change: the exact remaining amount of money; in Word Break / Decode Ways: the suffix start position s[i:]).
  • The Value (memo[i]): Represents the optimal answer or viability for that exact subproblem (e.g. in Coin Change: the minimum number of coins required to make that exact amount i; in Decode Ways: total valid decoding ways for suffix from i).

🌟 The 3 Dimensions of Backtracking & The "One-Way Door" Principle

Recursion itself is what controls the depth. The start index does not control depth; it acts as a One-Way Door for your choices.

To make this crystal clear, let's break a for-loop down into the 3 Dimensions of Backtracking:

  1. The Recursion = Depth

    • Every time you call dfs(), you are stepping one level deeper into the tree.
    • Question it answers: "How many items are currently in my path?"
  2. The Loop Variable (i) = The Choices

    • The for i in range(...) loop represents you standing at a specific depth and pointing at the options laid out in front of you.
    • Question it answers: "Which specific item am I picking right now?"
  3. The start Variable (or used set) = The Constraints

    • start is a constraint filter. It determines the boundary of what i is allowed to look at.
    • Question it answers: "Which items am I allowed to pick from?"
How this plays out in actual problems:
  • Scenario A: Subsets / Combinations (Using start): order doesn't matter, so the next call starts after the pick and never looks back (the Subsets and Combinations cards).
  • Scenario B: Permutations (Dropping start): order matters, so every depth loops from 0 and skips positions already in path (the Permutations card).
💡 The Ultimate Takeaway

When you are looking at a new problem, don't ask "what should my start index be?"

Instead, ask: "Am I allowed to look backward in the array?"

  • If NO (Combinations/Subsets): Use a start index and pass dfs(i + 1).
  • If YES (Permutations): Drop the start index, loop from 0 every time, and skip positions whose used[i] flag is set.

🎯 The Backtracking Cards at a Glance

Each shape below has its own card with the full blueprint, trap and worked example:

  • Subsets: save at every node; among equal values skip a sibling repeat (the Subsets card).
  • Combinations: save only a complete group; recurse with i + 1, or i when a number may be reused (the Combinations card).
  • Permutations: loop from 0 and skip positions already used (the Permutations card).

When Memoization Applies: Minimum Number of Coins (Coin Change as Backtracking vs DP)
  • Decision Tree Architecture:
    • At every step, you have the goal of reaching 0 from amount.
    • Choice Continuity: Because coins can be reused without restriction, all coin denominations [c1, c2, c3] remain accessible at every depth.
  • The Top-Down Memoized Decision Tree (O(C × A), where C = len(coins) and A = amount):
Code / Blueprint
def coinChange(coins: list[int], amount: int) -> int:
memo = [-1] * (amount + 1)
 
def dfs(rem: int) -> int:
if rem == 0:
return 0 # 0 coins needed to make 0 amount
if rem < 0:
return float('inf') # Invalid path, we overshot
if memo[rem] != -1:
return memo[rem] # Return previously calculated answer
 
ans = float('inf')
for coin in coins:
result = dfs(rem - coin)
if result != float('inf'):
ans = min(ans, result + 1)
 
memo[rem] = ans
return ans
 
res = dfs(amount)
return res if res != float('inf') else -1
 
  • Why Memoization / DP is Required:
    • Notice that rem is the only state variable needed to evaluate future decisions. The exact path of coins chosen to reach rem = 7 does not change the optimal number of coins needed to clear the remaining 7.
    • Initializing memo = [-1] * (amount + 1) collapses the exponential O(C^A) unpruned decision tree into an optimal O(C × A) linear state table (where C = len(coins) and A = amount)!

🌳 Evaluating Decision Trees, State Management & Option Pools

Building a mental decision tree before writing code establishes the foundation for any backtracking algorithm, helping map out choices, constraints, and goal states.

1. Evaluating Decision Trees and State Management
  • Choice Continuity: When options remain static across recursive calls (such as picking any coin of any denomination in a problem where element reuse is allowed), the full option pool stays accessible, and a position tracker is not strictly required.
  • Option Consumption: When elements can only be used once (like subsets or permutations), the choice space shrinks. Passing an index parameter or maintaining a visited array ensures execution moves forward and avoids redundant selections.
  • The Nuance of Memoization: Standard backtracking explores every valid path to generate configurations or navigate exponential search spaces (O(2n)O(2^n)O(2n) or O(n!)O(n!)O(n!)). Memoization only applies if the problem exhibits overlapping subproblems—meaning the exact same state can be reached via different paths and the remaining future outcomes are identical. If states are strictly path-dependent (such as tracking an ordered sequence or permutation), memoization is ineffective unless the problem is reframed as dynamic programming.
2. Core Backtracking Implementation Tips
  • Master the Standard Template: Structure every backtracking function around four core actions: check the base case (is the goal met?), record/process the valid path, iterate through choices, and undo the choice (backtrack) before moving to the next iteration.
  • Prune Early: If a partial state violates a constraint (e.g., the current running sum exceeds a target), break or continue immediately to cut off entire subtrees of the decision tree.
  • Pass State Efficiently: Instead of slicing arrays or allocating new collections at every recursive frame—which introduces unnecessary O(N)O(N)O(N) or O(N2)O(N^2)O(N2) overhead—modify a single data structure in place and backtrack by popping elements or reverting state variables.

💡 Staff SWE Architectural Invariant:
Decision trees, state progression and shrinking or static option pools describe every backtracking search; memoization helps only when subproblems overlap.

Understanding Invariants: Choose, Explore, Un-choose & Sibling vs Child Pruning

The mutable path state must reflect only the sequence of choices leading from the root to the current node. Upon returning from exploring a child branch, path.pop() restores the exact state before that choice was made.

Recursion itself is what controls the depth (stepping 1 level deeper). The start index acts as a One-Way Door for your choices, locking previous indices behind you in forward-only combinations.

Child Pruning (Infeasibility / Capacity overshoot)
Condition: If adding candidate c exceeds the target (c > remaining in sorted array)
Action: break the loop immediately

Because all subsequent sorted candidates will also overshoot.

Sibling Pruning (Deduplication)
Condition: If candidate at index i equals candidate at i-1 and i > start
Action: continue

To prevent generating duplicate combinations from the same level.

Common Questions
  • "Since path is shared and mutated in place, why do we have to explicitly path.pop() after recursing — couldn't we just pass a new list at each call instead, so there's nothing to undo?"

    You could — and some solutions do — but it trades a cheap operation for an expensive one, and it's worth knowing why the mutate-and-undo version is the default.

    Passing a new list means allocating and copying the whole path at every single call: backtrack(path + [choice]) creates an O(depth) copy on every recursive step, for every node in the decision tree. Across a tree with exponentially many nodes, those copies dominate the runtime — you can turn an already-exponential algorithm asymptotically worse for no algorithmic reason.

    Mutating one shared buffer and undoing the mutation costs O(1) per step: path.append(choice) on the way down, path.pop() on the way back up. The byte-for-byte restoration invariant is what makes this safe — as long as every append is matched by exactly one pop before the function returns, sibling branches never see each other's leftover state, so there's no correctness difference between the two approaches, only a performance one.

    The one place this causes a bug: results.append(path) (appending the reference) instead of results.append(list(path)) (a snapshot). Since path keeps getting mutated after you've stored a reference to it, every entry you thought you saved silently changes too — usually ending up as a list of identical, empty-looking results. Snapshot when you record; mutate-and-undo everywhere else.

Coach discussion points
  1. 1.

    "You said O(2ⁿ). Walk me through what happens on the line where you record a subset, and tell me again." — Surfaces the O(n) snapshot cost hidden inside the recording line. The highest-signal question in this pattern.

  2. 2.

    "Delete the path.pop() line. What is the first input that produces a wrong answer, and what does the wrong answer look like?" — Forces the student to reason about state contamination concretely rather than reciting "you need to backtrack".

  3. 3.

    "Why does the deduplication check use i > start and not i > 0? Give me the input that distinguishes them." — [1,2,2]: i > 0 would also prune the legitimate [1,2,2] branch. This one question separates memorisation from understanding.

  4. 4.

    "You could have used a set to remove duplicate subsets at the end. Why is pruning better, and is there a case where the set is the right call?" — Tests judgement, not just technique; the honest answer includes "when the dedup rule is too complex to express as a branch prune."

  5. 5.

    "In Sudoku Solver, why do we check the row, column, AND box for every candidate digit, instead of just checking whether the digit already appears anywhere on the board?" — Surfaces the understanding that a digit can legally repeat elsewhere on the board (in a different row/column/box) — only the three local neighborhoods matter, which is exactly why the check stays bounded at 27 cells instead of scanning all 81.

  6. 6.

    "Restore IP Addresses has no visible 'pruning' step like N-Queens' diagonal sets. Is it still backtracking? Why is it still fast?" — Tests whether the student recognizes that bounding the branching factor (3) and the depth (4) is itself a form of built-in pruning — the search space is small by construction, not because of an explicit early-exit check.

Complexity analysis

Time for Subsets II (LC 90): O(n · 2ⁿ) — not O(2ⁿ). There are 2ⁿ subsets, and each one is copied into the results list when recorded. A subset averages n/2 elements, so each copy costs O(n). Multiply: O(n · 2ⁿ). Candidates who say O(2ⁿ) have forgotten the snapshot cost, and this is the specific thing a Staff-level interviewer listens for.

Time for Permutations (LC 46): O(n · n!). n! complete orderings, each copied at O(n).

Time for Combination Sum (LC 39): O(n^(T/m)) where T is the target and m the smallest candidate — the tree's depth is at most T/m and its branching factor at most n. Bounds like this one are legitimately loose; saying "exponential in target / min(candidates), and here is why" is a better answer than a confidently wrong tight bound.

Space: O(n) auxiliary, not counting the output. One shared path of at most n elements, plus a recursion stack at most n deep. The output itself is O(n · 2ⁿ) and is conventionally excluded — but say that you are excluding it rather than silently ignoring it.

Why pruning matters even though the bound stays exponential: pruning does not change the worst case; it changes the actual case, often by orders of magnitude. In Subsets II (LC 90), two prunes removed 25% of the tree on a three-element input. That ratio grows with n.

Production Systems Deployment & Motivation

A CI (Continuous Integration) system must decide which subset of 20 optional feature flags to enable for a canary release. Some combinations are forbidden. There is no formula — the only way is to try combinations, and the moment a partial combination already violates a rule, every completion of it also violates it and the entire branch can be abandoned unexplored. That abandonment ("pruning") is why backtracking beats generating all 2²⁰ combinations blindly. Sudoku solvers, regex engines, dependency resolvers, and constraint schedulers are all this same loop.

Mastery Roadmap & Prerequisite Graph

💡 Don't worry if this feels abstract right now! Backtracking is one fundamental 3-move loop applied across different search spaces. As you progress through the Archetype Variants (from Phone Numbers and Subsets II to Combination Sum and N-Queens), each hands-on challenge will cement this intuition until writing path.append() and path.pop() becomes second nature.

  • Appending live path reference instead of shallow copy: results.append(path) stores a reference to the mutable list. When backtracking empties the list with path.pop(), all elements in results become empty lists []. Always append path[:] or list(path).

  • Duplicate subsets from unsorted inputs: If inputs contain duplicates, subsets will generate duplicate combinations unless inputs are sorted and sibling duplicates skipped.

  • Recursion stack depth exceeding limits: For N>20N > 20N>20, 2N2^N2N subsets exceed time and memory bounds. Ensure N≤20N \le 20N≤20.

Staff+ Engineering Perspective

Staff+ Systems Architecture & Hardware Considerations

ARCHITECTURAL SIGNALS & INTERVIEW TRIGGERS

Triggers: "generate all valid combinations / permutations / subsets", "find all solutions satisfying constraints", "Sudoku / N-Queens constraint satisfaction". Systematic state-space tree traversal with early pruning of non-viable branches.

CONSTRAINTS & BOUNDS

Problem search space size NNN is small (N≤20N \le 20N≤20 for power sets O(2N)O(2^N)O(2N), N≤12N \le 12N≤12 for permutations O(N!)O(N!)O(N!)). Without pruning, exponential searches time out; aggressive early pruning drops branch complexity by orders of magnitude.

FAANG PRODUCTION TRAPS & EDGE CASES

Accidentally appending mutable reference res.append(path) instead of deep copy res.append(list(path)); exponential duplicate branch explosion when inputs contain duplicates (mitigate via sibling branch pruning nums[i] == nums[i-1]); inefficient backtracking state cloning.

© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•