Knowledge Guide
HomeDSA

Backtracking

Step 18 in the DSA path · 1 concepts · 4 problems

0 / 5 complete

📘 Learn Backtracking from zero

Backtracking explores a decision tree by building a candidate one choice at a time, and the instant a partial candidate cannot lead to a valid full solution, it abandons it and undoes the last choice (the "backtrack") to try the next option. The skeleton is always: choose → recurse → un-choose. Reach for it when a problem asks you to enumerate or count all valid configurations — permutations, combinations, subsets, partitions, board placements, or string decompositions — especially when each position has a small set of choices and constraints prune branches early. The trigger phrase to listen for: "find all ways / all combinations / all valid arrangements."

✨ Added by the guide to build intuition — not from the source course.

🏗️ Visual walkthrough — trace it step by step

Step 0 — The problem and the engine

Canonical example: Letter Combinations of a Phone Number for digits = "23". Digit 2 maps to abc, digit 3 maps to def. We must output every string formed by picking one letter per digit, so backtracking is the natural fit (one decision per position, small branching factor).

digits = " 2    3 "
          |    |
          v    v
        a b c  d e f      <- choices per slot

map = { '2': "abc", '3': "def" }

State we carry:
  index  -> which digit we are deciding (0..len)
  path   -> letters chosen so far (a stack)
  result -> completed combinations

INITIAL:
  index  = 0
  path   = [ ]            (empty)
  result = [ ]            (empty)

The path is a mutable stack we push to before recursing and pop from after — that pop is the backtrack. index == len(digits) is the base case: a full combination is ready to record.

Step 1 — Choose 'a' at index 0

At index = 0 the digit is '2' → choices a,b,c. We pick the first, 'a': push it onto path and recurse to index = 1. This is safe because every letter of '2' is a legal first character, so no pruning is needed yet — we just commit and dive deeper.

index = 1   (just descended)

path stack (bottom -> top):
  +---+
  | a |   <- chose 'a' for digit '2'
  +---+

path as string: "a"

remaining digit to decide: '3' -> d e f
result = [ ]

We are now one level deep. The recursion will return here later so we can try 'b' and 'c' — that is the breadth at this level, explored one branch at a time.

Step 2 — Choose 'd' at index 1 → base case → record "ad"

At index = 1 the digit is '3' → choices d,e,f. Pick 'd', push it, recurse to index = 2. Now index == len("23") == 2: base case hit. The path is a complete combination, so we copy it into result. Copying (not aliasing) is safe and required, because path will keep mutating after we return.

index = 2  ==  len(digits)  -> BASE CASE

path stack:
  +---+
  | d |   <- top  (digit '3')
  +---+
  | a |   <- bottom (digit '2')
  +---+
path as string: "ad"   --copy-->  result

result = [ "ad" ]

No further choices exist past the last digit, so we stop descending and unwind back to index = 1 to try the siblings of 'd'.

Step 3 — Backtrack, then choose 'e' and 'f' → "ae", "af"

Returning from the base case, we pop 'd' (un-choose) so path is just "a" again. The loop at index = 1 advances to 'e': push, recurse, hit base case, record "ae", pop. Then 'f': record "af", pop. The pop after each child is what keeps the stack clean for the next sibling — without it, letters would leak across branches.

back at index = 1, cycling choices d -> e -> f

after 'e':  path = [a][e] -> record "ae", then pop e
after 'f':  path = [a][f] -> record "af", then pop f

path stack now (all '3'-choices exhausted):
  +---+
  | a |   <- only the '2'-choice remains
  +---+

result = [ "ad", "ae", "af" ]

Digit '3' is fully explored under the prefix "a". We return to index = 0 to advance past 'a'.

Step 4 — Backtrack to index 0, choose 'b'

The index = 1 call finishes, so we pop 'a' at index = 0, emptying path. The loop at index = 0 advances from 'a' to 'b': push 'b' and recurse into index = 1 again. The subtree under 'b' is explored with exactly the same machinery, producing "bd", "be", "bf".

back at index = 0, advanced 'a' -> 'b'

path stack:
  +---+
  | b |   <- fresh '2'-choice, 'a' was popped
  +---+
path as string: "b"

(recurse index=1 over d,e,f as before)

result = [ "ad", "ae", "af",
           "bd", "be", "bf" ]

Notice the stack never grows beyond len(digits) = 2 at any moment — backtracking gives us all 9 strings using O(depth) extra space, not O(number of results).

Step 5 — Choose 'c', exhaust the tree

Pop 'b', advance to the last choice 'c' at index = 0, and recurse one final time over d,e,f. This adds "cd", "ce", "cf". After the 'c' subtree returns and 'c' is popped, the loop at index = 0 has no more letters — the recursion fully unwinds and path is empty again, matching its initial state.

index=0 choice 'c' -> recurse d,e,f

final path stack: (empty, all popped)
  +---+
  |   |
  +---+

result = [ "ad","ae","af",
           "bd","be","bf",
           "cd","ce","cf" ]   (3 x 3 = 9)

The empty final stack is the proof the algorithm is balanced: every push was matched by a pop, so no state leaked. That symmetry is the heart of correctness in backtracking.

Step 6 — Generalize: pruning makes it powerful

Phone letters never prune (all choices are always legal), but the same skeleton scales to constrained problems by adding a validity check before recursing. The earlier you reject, the more of the tree you skip.

backtrack(index, path):
  if index == END:                # base case
      result.add(copy(path))
      return
  for choice in choices(index):
      if NOT valid(choice, path):  # <-- PRUNE here
          continue                 #     skip dead branch
      path.push(choice)            # choose
      backtrack(index+1, path)     # explore
      path.pop()                   # un-choose (backtrack)

Examples of valid():
  Permutations II : skip if choice used, OR
                    (choice == prev && prev not used)  // dedup
  Balanced parens : open

For Permutations II you sort first and skip a duplicate value whose identical predecessor is unused, killing duplicate subtrees. For Balanced Parentheses and Palindrome Partitioning the valid() guard rejects illegal prefixes immediately — same three-line choose/recurse/un-choose engine, different pruning rule. Master the symmetry and the guard, and all five lessons collapse into one pattern.

🎯 Guided practice

These two warm-ups isolate the two reusable levers — the start-index (for combinations/partitions) and the used-array dedup (for permutations with repeats) — that the in-scope problems (Permutations II, Letter Combinations, Generate Parentheses, Palindrome Partitioning) all reuse.

Problem 1 (easy) — Letter Combinations of a Phone Number, scaled down. Given digits = "23" and the phone mapping (2→abc, 3→def), return every string formed by picking one letter per digit.

  1. Frame the decision: the candidate is fixed-length (one char per digit), so you branch on position, not on a start index. At depth d, the branching factor is the number of letters on digits[d] (3 or 4).
  2. Define the recursion: backtrack(idx, path). Base case: when idx == len(digits), the candidate is complete — record "".join(path) (a fresh string, so no aliasing) and return.
  3. Choose / explore / un-choose: for each letter mapped to digits[idx]: append letter, recurse with backtrack(idx + 1, path), then pop it.
  4. Trace it: "" → a → ad ("ad") → pop → ae ("ae") → af ("af"), then back up and restart with b, then c. Output: ad, ae, af, bd, be, bf, cd, ce, cf — exactly 3×3 = 9 strings.
  5. The lesson: when every level consumes a fixed slot, the loop variable is the choice within a level and recursion advances by idx + 1. Edge case to state aloud: empty digits returns [], not [""].

Problem 2 (medium) — Generate Parentheses. Given n = 2, return all well-formed strings using n pairs. (LeetCode rates this Medium — note that, despite this set's "hard" label, the pattern is mid-tier; the rigor is in the pruning.)

  1. Spot the pattern: "return all well-formed strings" = enumerate-all + early pruning → backtracking. Counting them instead would be the Catalan number via DP; here we must emit the strings.
  2. State: backtrack(path, open, close) where open / close are counts placed so far.
  3. The pruning (the whole point): only add '(' while open < n; only add ')' while close < open. That second guard kills any prefix that would close more than it has opened — you never even build an invalid candidate, so there is no separate validity check at the leaf.
  4. Base case: when path has length 2n (equivalently open == close == n), record it and return.
  5. Trace n = 2: ( → (( → (() → (()) (record); backtrack to (() → ()( → ()() (record). Output: (()), ()() — the 2 Catalan-many strings.
  6. The lesson: the two count-guards encode the validity invariant directly into construct_candidates, so pruning happens at every node instead of via a reject-at-the-end test. This "make the invalid branch unconstructable" move is the same idea you reuse in Palindrome Partitioning, where you only recurse on a prefix after confirming it is a palindrome.

✨ Added by the guide — work these before the full problem set.

Lessons in this topic