Emmanuel Akinfulubi

Lesson 19 of 2420 min read

Backtracking

Try a choice, recurse, undo it. This one template generates subsets, permutations, combinations and solves puzzles, and pruning is what makes it fast enough.

By the end: Generate every valid arrangement with one recursive template.

Try a choice. Recurse. Undo it. Try the next choice.

That is backtracking, and one template generates every subset, every permutation, every combination, and solves sudoku and N-queens. The reason it deserves its own lesson is that the undo step is where everyone goes wrong.

The shape. Every problem in this lesson is this.

function solve(state, results) {
  if (/* the state is a complete answer */) {
    results.push([...state]);        // COPY, or every result aliases the same array
    return;
  }

  for (const choice of availableChoices(state)) {
    if (!isValid(choice, state)) continue;   // pruning

    state.push(choice);              // choose
    solve(state, results);           // explore
    state.pop();                     // un-choose
  }
}

Subsets: the simplest case

Every subset, two ways to think about it

// Style 1: at each index, decide to include it or not. A binary tree of depth n.
function subsetsInclude(nums) {
  const results = [];

  function walk(index, current) {
    if (index === nums.length) {
      results.push([...current]);          // a complete decision set
      return;
    }
    // Choice A: skip nums[index].
    walk(index + 1, current);

    // Choice B: take it.
    current.push(nums[index]);
    walk(index + 1, current);
    current.pop();                          // undo
  }

  walk(0, []);
  return results;
}

// Style 2: build from a start index. Every node in the tree is itself an answer.
function subsetsStart(nums) {
  const results = [];

  function walk(start, current) {
    results.push([...current]);              // record at every node, not just leaves
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);
      walk(i + 1, current);                 // i + 1: never reuse an element
      current.pop();
    }
  }

  walk(0, []);
  return results;
}

console.log('include/exclude:', subsetsInclude([1, 2, 3]));
console.log('start index:    ', subsetsStart([1, 2, 3]));
console.log('count for n=3:', subsetsStart([1, 2, 3]).length, '= 2^3');
console.log('empty input:', subsetsStart([]));

Output


      

Why the copy matters: the classic bug

function broken(nums) {
  const results = [];
  function walk(start, current) {
    results.push(current);                  // NO COPY — pushing the live array
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);
      walk(i + 1, current);
      current.pop();
    }
  }
  walk(0, []);
  return results;
}

console.log('without copying:', broken([1, 2]));
console.log('every entry is the same array, now empty after unwinding');

function fixed(nums) {
  const results = [];
  function walk(start, current) {
    results.push([...current]);              // snapshot
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);
      walk(i + 1, current);
      current.pop();
    }
  }
  walk(0, []);
  return results;
}
console.log('with copying:   ', fixed([1, 2]));

Output


      

Permutations

Every ordering, tracking what is already used

function permutations(nums) {
  const results = [];
  const used = new Array(nums.length).fill(false);

  function walk(current) {
    if (current.length === nums.length) {
      results.push([...current]);
      return;
    }
    // Start from 0 every time: order matters, so any unused element can come next.
    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue;
      used[i] = true;
      current.push(nums[i]);
      walk(current);
      current.pop();
      used[i] = false;                       // undo BOTH pieces of state
    }
  }

  walk([]);
  return results;
}

console.log(permutations([1, 2, 3]));
console.log('count for n=3:', permutations([1, 2, 3]).length, '= 3!');
console.log('count for n=4:', permutations([1, 2, 3, 4]).length, '= 4!');

Output


      

Combinations, and the sum variants

Choose k from n

function combinations(n, k) {
  const results = [];

  function walk(start, current) {
    if (current.length === k) { results.push([...current]); return; }

    // Pruning: stop if not enough numbers remain to reach length k.
    const needed = k - current.length;
    for (let i = start; i <= n - needed + 1; i++) {
      current.push(i);
      walk(i + 1, current);
      current.pop();
    }
  }

  walk(1, []);
  return results;
}

console.log('choose 2 from 4:', combinations(4, 2));
console.log('choose 3 from 4:', combinations(4, 3));
console.log('count:', combinations(5, 3).length, '= 5 choose 3 = 10');

Output


      

Combination sum: reuse allowed, so pass i not i+1

function combinationSum(candidates, target) {
  const results = [];

  function walk(start, current, remaining) {
    if (remaining === 0) { results.push([...current]); return; }
    if (remaining < 0) return;                 // overshot: prune

    for (let i = start; i < candidates.length; i++) {
      current.push(candidates[i]);
      walk(i, current, remaining - candidates[i]);   // i, not i+1: reuse allowed
      current.pop();
    }
  }

  walk(0, [], target);
  return results;
}

console.log(combinationSum([2, 3, 6, 7], 7));      // [[2,2,3],[7]]
console.log(combinationSum([2, 3, 5], 8));         // [[2,2,2,2],[2,3,3],[3,5]]
console.log(combinationSum([2], 1));               // []

Output


      

Handling duplicates

When the input has repeats, the naive template produces duplicate results. The fix is to sort, then skip a value if it equals the previous one at the same level of the tree.

Duplicates: sort, then skip repeats at the same level

function subsetsWithDup(nums) {
  const sorted = [...nums].sort((a, b) => a - b);
  const results = [];

  function walk(start, current) {
    results.push([...current]);
    for (let i = start; i < sorted.length; i++) {
      // i > start means this is not the first choice at this level, so a repeat
      // here would produce a subset we have already generated.
      if (i > start && sorted[i] === sorted[i - 1]) continue;
      current.push(sorted[i]);
      walk(i + 1, current);
      current.pop();
    }
  }

  walk(0, []);
  return results;
}

console.log('with the skip:', subsetsWithDup([1, 2, 2]));

// Without the skip, you get [2] twice and [1,2] twice.
function withoutSkip(nums) {
  const s = [...nums].sort((a, b) => a - b), out = [];
  (function walk(start, cur) {
    out.push([...cur]);
    for (let i = start; i < s.length; i++) { cur.push(s[i]); walk(i + 1, cur); cur.pop(); }
  })(0, []);
  return out;
}
console.log('without it:   ', withoutSkip([1, 2, 2]), '← duplicates');

Output


      

Pruning is what makes it usable

Backtracking explores an exponential space. Pruning — abandoning a branch as soon as it cannot lead to an answer — is often the difference between instant and never finishing.

Measuring the effect of pruning

function combinationSumCounted(candidates, target, prune) {
  const results = [];
  let calls = 0;

  function walk(start, current, remaining) {
    calls++;
    if (remaining === 0) { results.push([...current]); return; }
    // This check is needed for termination, so both versions keep it.
    if (remaining < 0) return;

    for (let i = start; i < candidates.length; i++) {
      // The prune being measured: on sorted input, once a candidate is too big
      // every later one is too, so abandon the whole rest of the loop.
      if (prune && candidates[i] > remaining) break;
      current.push(candidates[i]);
      walk(i, current, remaining - candidates[i]);
      current.pop();
    }
  }

  walk(0, [], target);
  return { found: results.length, calls };
}

const sortedCandidates = [2, 3, 5, 7, 11];
console.log('no pruning:  ', combinationSumCounted(sortedCandidates, 20, false));
console.log('with pruning:', combinationSumCounted(sortedCandidates, 20, true));

Output


      

Grid backtracking: N-queens

N-queens, with O(1) conflict checks

function solveNQueens(n) {
  const solutions = [];
  const queens = [];                    // queens[row] = column

  // Instead of scanning the board, track which columns and diagonals are taken.
  const cols = new Set();
  const diag = new Set();               // row - col is constant along one diagonal
  const anti = new Set();               // row + col is constant along the other

  function place(row) {
    if (row === n) {
      solutions.push(queens.map(c => '.'.repeat(c) + 'Q' + '.'.repeat(n - c - 1)));
      return;
    }
    for (let col = 0; col < n; col++) {
      if (cols.has(col) || diag.has(row - col) || anti.has(row + col)) continue;

      queens.push(col); cols.add(col); diag.add(row - col); anti.add(row + col);
      place(row + 1);
      queens.pop(); cols.delete(col); diag.delete(row - col); anti.delete(row + col);
    }
  }

  place(0);
  return solutions;
}

console.log('4-queens has', solveNQueens(4).length, 'solutions:');
solveNQueens(4).forEach((board, i) => { console.log(` solution ${i + 1}:`); board.forEach(r => console.log('   ' + r)); });
console.log('8-queens has', solveNQueens(8).length, 'solutions');

Output


      

Word search in a grid: mark, recurse, unmark

function exist(board, word) {
  const rows = board.length, cols = board[0].length;

  function search(r, c, index) {
    if (index === word.length) return true;
    if (r < 0 || r >= rows || c < 0 || c >= cols) return false;
    if (board[r][c] !== word[index]) return false;

    const saved = board[r][c];
    board[r][c] = '#';                  // mark as used on the current path

    const found = search(r + 1, c, index + 1) || search(r - 1, c, index + 1) ||
                  search(r, c + 1, index + 1) || search(r, c - 1, index + 1);

    board[r][c] = saved;                // restore for other paths
    return found;
  }

  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (search(r, c, 0)) return true;
  return false;
}

const board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']];
console.log('ABCCED:', exist(board, 'ABCCED'));   // true
console.log('SEE:   ', exist(board, 'SEE'));      // true
console.log('ABCB:  ', exist(board, 'ABCB'));     // false, cannot reuse a cell

Output


      

A harder one: partitioning

Palindrome partitioning

function partitionPalindromes(s) {
  const results = [];
  const isPal = (str, lo, hi) => {
    while (lo < hi) { if (str[lo] !== str[hi]) return false; lo++; hi--; }
    return true;
  };

  function walk(start, current) {
    if (start === s.length) { results.push([...current]); return; }

    for (let end = start; end < s.length; end++) {
      if (!isPal(s, start, end)) continue;         // prune: not a valid piece
      current.push(s.slice(start, end + 1));
      walk(end + 1, current);
      current.pop();
    }
  }

  walk(0, []);
  return results;
}

console.log(partitionPalindromes('aab'));
console.log(partitionPalindromes('a'));
console.log(partitionPalindromes('aba'));

Output


      

Complexity

Be honest and specific. These are exponential, and interviewers expect you to say so.

When backtracking should have been DP

// Count the ways to climb n stairs taking 1 or 2 steps. Backtracking explores
// every path; but we only need the COUNT, and paths share subproblems.
let backtrackCalls = 0;
function countWaysBacktrack(n) {
  backtrackCalls++;
  if (n === 0) return 1;
  if (n < 0) return 0;
  return countWaysBacktrack(n - 1) + countWaysBacktrack(n - 2);
}

let memoCalls = 0;
function countWaysMemo(n, memo = new Map()) {
  memoCalls++;
  if (n === 0) return 1;
  if (n < 0) return 0;
  if (memo.has(n)) return memo.get(n);
  const result = countWaysMemo(n - 1, memo) + countWaysMemo(n - 2, memo);
  memo.set(n, result);
  return result;
}

for (const n of [10, 20, 30]) {
  backtrackCalls = 0; memoCalls = 0;
  const a = countWaysBacktrack(n), b = countWaysMemo(n);
  console.log(`n=${n}: answer ${a} | backtracking ${backtrackCalls.toLocaleString('en-US')} calls | memoised ${memoCalls} calls`);
}
console.log('If you need a COUNT or a BEST, not a LIST, you probably want DP.');

Output


      

Your turn: five backtracking problems

  1. permuteUnique(nums) — permutations of an array with duplicates, no repeated results. Sort, then skip i > 0 && nums[i] === nums[i-1] && !used[i-1].
  2. letterCombinations(digits) — all strings a phone keypad number could spell.
  3. generateParenthesis(n) — every valid arrangement of n pairs of brackets. Prune: never close more than you have opened.
  4. combinationSum2(candidates, target) — each number used at most once, no duplicate results. Combines the two skip rules.
  5. solveSudoku(board) — the hard one. Find an empty cell, try 1 to 9, check row, column and 3×3 box, recurse, undo. The box index is Math.floor(r/3)*3 + Math.floor(c/3).

Editable. Fill them in, then run.

Output


      

Recap

  • The template is choose, explore, un-choose. Every piece of state you change must be restored.
  • Push a copy of your working array into the results, or every entry aliases the same array.
  • i + 1 means no reuse, i allows reuse, and starting from 0 gives permutations.
  • For duplicate inputs, sort and skip when i > start && nums[i] === nums[i-1] — i > start, not i > 0.
  • Pruning is what makes exponential search practical; and if you only need a count or a best value rather than every answer, you want dynamic programming instead.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.