CodePark/ lessons / Backtracking: try a choice, then undo it
START PRACTICINGPRACTICE
Data structures and algorithms / Backtracking / lesson

Backtracking: try a choice, then undo it

The idea

Build each answer one choice at a time. After each choice, explore everything that can follow it. Then undo the choice and try the next one. If you do this for every choice, you visit every possible answer exactly once.

Think of it like this: walking through a maze with a ball of string. At every fork you pick a corridor and unroll string behind you. When you reach a dead end, or the exit, you wind the string back to the last fork. Then you try a corridor you haven't tried yet. The string is your path, and winding it back is the undo.

How to spot it

Words and constraints in a problem that point to this pattern:

  • “Return all possible …”: all subsets, all orderings, every combination that adds up to a target
  • Very small limits on the input: “n ≤ 10”, “the board is at most 6 × 6”, “the word has at most 12 letters”
  • A rule about reuse: “each number may be used once” or “may be used any number of times”
  • “Does a path exist … without using the same cell twice?”. You try a path, and you give the cells back when it fails
  • The number of answers grows very fast as the input grows, like 2ⁿ or n!. Both are explained below

Watch it work

This lesson builds directly on the recursion lesson. Here are the words you'll need:

  • A choice is one decision, such as “take the letter a”.
  • The path is the list of choices made so far, for example ["a"]. It's an ordinary array that grows and shrinks.
  • The decision tree draws every possible sequence of choices. Each box is a path. The root at the top is the empty path. Each line down is one more choice. A box with no lines below it is a leaf.
  • Backtracking walks this tree depth-first. Depth-first means you go down one branch as far as it goes, then come back up and try the next branch. The call stack from the recursion lesson remembers where you were.

Every step down has three moves:

  • choose: push the choice onto the path
  • explore: call yourself to make the next choice
  • un-choose: pop the choice off again

The trace finds every way to pick 2 letters from "abc", keeping them in their original order. In the tree, > marks where the code is now. ✓ marks a saved answer, and ✗ marks a dead end. We don't want [b, a] as well as [a, b]. So each pick may only use letters to the right of the previous pick. A number called start tracks the first letter still allowed.

The template

The general shape is a function, here called backtrack(path, start). It does two things:

  1. It checks whether the path is a complete answer. If it is, it saves a copy and returns. This is the base case.
  2. Otherwise, it loops over the choices still allowed. For each one it does choose → explore → un-choose.

Why save a copy? results.push(path) stores a reference to the same array, not a snapshot. A reference is like a link to the array. Every later push and pop changes what every saved link points to. When the search ends, the path is empty, so every saved answer would read []. [...path] makes a new array with the current contents.

Pruning is a check that skips a choice when it can't possibly lead to an answer. It doesn't change the result. It only stops the code from exploring branches that can never succeed.

The code below picks every group of k items, as in the trace. To adapt it:

  • every subset: save at every call, not only when the path is full
  • every ordering: loop from 0 every time and skip items already in the path, instead of using start
  • items can be reused: explore with i instead of i + 1
// Every way to choose k items from items, keeping their original order.
function chooseK<T>(items: T[], k: number): T[][] {
  const results: T[][] = [];               // every finished answer goes here

  // path: the choices made so far. start: the first index we may still pick.
  function backtrack(path: T[], start: number): void {
    if (path.length === k) {               // base case: the path is a complete answer
      results.push([...path]);             // save a COPY: path keeps changing after this
      return;                              // a full path can't grow any further
    }
    for (let i = start; i < items.length; i++) {
      const left = items.length - i;       // how many items remain, counting items[i]
      if (path.length + left < k) break;   // prune: even taking all of them can't reach k
      path.push(items[i]);                 // 1. choose
      backtrack(path, i + 1);              // 2. explore: later picks come after i, so no repeats
      path.pop();                          // 3. un-choose: undo, ready to try the next i
    }
  }

  backtrack([], 0);                        // start with nothing chosen, from the first item
  return results;
}

chooseK(["a", "b", "c"], 2); // [["a", "b"], ["a", "c"], ["b", "c"]]

Worked example

Balanced brackets: given n, return every string of n opening and n closing round brackets where the brackets are balanced. Balanced means two things. Reading left to right, you never close more brackets than you've opened. And every opened bracket gets closed. For n = 3 there are 5: ((())), (()()), (())(), ()(()) and ()()().

  1. The first idea is to build every string of length 2n made of ( and ), then keep the balanced ones. Each of the 6 positions has 2 options. So for n = 3 that's 2 × 2 × 2 × 2 × 2 × 2 = 64 strings to check, and only 5 survive. For n = 10 it's over a million strings for 16,796 answers.

  2. Notice the waste. Half of those 64 strings start with ). A string that starts by closing a bracket that was never opened can't be fixed by anything after it. But the brute force still builds all 32 of them in full before throwing them away. If we build the string one character at a time, we can stop the moment it goes wrong.

  3. That's backtracking. The choice at each step is “add (” or “add )”. Two rules prune the bad branches before they start. Rule 1: you may add ( only while fewer than n have been opened. Rule 2: you may add ) only while fewer have been closed than opened. If you obey both rules, every path that reaches length 2n is balanced. So there's nothing left to check at the end.

  4. Write it with the template's shape. Count the opened and closed brackets, so the rules are quick to test. Each branch is choose, explore, un-choose. Joining the path makes a new string, so saving it is already a copy.

    function balancedBrackets(n: number): string[] {
      const results: string[] = [];
    
      function backtrack(path: string[], open: number, close: number): void {
        if (path.length === 2 * n) {          // complete: the rules guarantee it's balanced
          results.push(path.join(""));        // join builds a new string: a copy
          return;
        }
        if (open < n) {                       // rule 1: there's still a bracket left to open
          path.push("(");                     // choose
          backtrack(path, open + 1, close);   // explore
          path.pop();                         // un-choose
        }
        if (close < open) {                   // rule 2: something is open that we can close
          path.push(")");
          backtrack(path, open, close + 1);
          path.pop();
        }
      }
    
      backtrack([], 0, 0);
      return results;
    }
    
    balancedBrackets(3); // ["((()))", "(()())", "(())()", "()(())", "()()()"]
    
  5. Check it. For n = 3 it returns the 5 strings above. For n = 1 the only path is ( then ), giving ["()"]. For n = 0 the empty path is already complete, so it returns [""], one empty string. No path ever starts with ), because rule 2 needs close < open and both start at 0. Those 32 strings are never built.

Common mistakes

  • Saving path instead of [...path]. Every saved answer is then the same array. After the last pop they all show [], so you get [[], [], []].
  • Forgetting path.pop() after the recursive call. The path keeps every old choice. So later answers come out too long, like [a, b, c] when you wanted 2 letters, or the base case is never reached.
  • Exploring with start or 0 instead of i + 1 when each item can be used once. You get [a, a]. You also get both [a, b] and [b, a], as if they were different answers.
  • Marking something as used, like a grid cell or a used[i] flag, and not unmarking it on the way back. Later paths think it's still taken, so valid answers go missing.
  • Pruning with break on input that isn't sorted. “This number is too big, so all the rest are too” is only true after sorting. Without sorting, answers disappear and nothing warns you.

Cost

Backtracking visits every box in the decision tree, so the cost is the size of the tree. That size grows exponentially: each extra item multiplies the work instead of adding to it.

  • Subsets: each of the n items has 2 choices, in or out. That gives 2ⁿ paths. 10 items give 1,024, and 20 items give over a million.
  • Orderings: n items have n! paths. n! means n × (n − 1) × … × 1, the factorial from the recursion lesson. 10 items give 3,628,800.

Saving an answer copies up to n items. So subsets cost O(n × 2ⁿ) and orderings cost O(n × n!). That's why these prompts promise very small inputs like n ≤ 10. An exponential answer is expected. Pruning only makes it faster in practice.

Space is the path plus the call stack, and both are at most n deep. So space is O(n), not counting the results.

In interviews

Say the plan out loud: “I'll build each answer one choice at a time. After exploring a choice I'll undo it and try the next one. I'll save a copy whenever the path is complete.” Draw the decision tree for a 3-item input before you code. It shows the base case and the loop at the same time. Interviewers usually ask for one of these:

  • subsets
  • orderings
  • combinations that add up to a target
  • a word hidden in a grid

Then they ask for the time complexity. Answer with 2ⁿ or n!. Point out that the small limit on n is what makes this acceptable.