Backtracking

intermediate backtracking recursion permutations combinations

Backtracking is recursion with an undo button. We try a choice, explore what happens, and if it doesn’t work out, we undo it and try the next option. It’s the algorithmic equivalent of “let me try this path… nope, dead end, let me go back and try another.”

In simple language, imagine navigating a maze. At each fork, we pick a direction. If we hit a wall, we walk back to the last fork and try a different direction. That’s backtracking.

The Decision Tree

Every backtracking problem can be visualized as a tree. Each node is a decision point, and each branch is a choice we make.

Decision Tree for Subsets of [1, 2, 3]
[ ]
include 1?    /        \
[1]
include 2? /   \
[1,2]
3? / \
[1,2,3] [1,2]
[1]
3? / \
[1,3] [1]
[ ]
include 2? /   \
[2]
3? / \
[2,3] [2]
[ ]
3? / \
[3] [ ]
Leaves (green) = all 8 subsets. Each path = a sequence of include/exclude decisions.

The Universal Backtracking Template

Here’s the beautiful thing — almost every backtracking problem uses the exact same skeleton. Learn this template and we can solve dozens of problems.

function backtrack(result, current, choices, start) {
  // 1. Base case: found a valid solution
  if (/* goal reached */) {
    result.push([...current]); // copy current state
    return;
  }

  // 2. Try each choice
  for (let i = start; i < choices.length; i++) {
    current.push(choices[i]);   // make a choice
    backtrack(result, current, choices, i + 1); // explore
    current.pop();              // UNDO the choice (backtrack!)
  }
}
def backtrack(result, current, choices, start):
    # 1. Base case: found a valid solution
    if True:  # goal reached
        result.append(current[:])  # copy current state
        return

    # 2. Try each choice
    for i in range(start, len(choices)):
        current.append(choices[i])   # make a choice
        backtrack(result, current, choices, i + 1)  # explore
        current.pop()                # UNDO the choice (backtrack!)
void backtrack(List<List<Integer>> result, List<Integer> current,
               int[] choices, int start) {
    // 1. Base case: found a valid solution
    if (/* goal reached */) {
        result.add(new ArrayList<>(current)); // copy current
        return;
    }

    // 2. Try each choice
    for (int i = start; i < choices.length; i++) {
        current.add(choices[i]);    // make a choice
        backtrack(result, current, choices, i + 1); // explore
        current.remove(current.size() - 1); // UNDO (backtrack!)
    }
}

The three steps are always: choose, explore, unchoose. The current.pop() (or remove) is the undo — that’s the whole magic of backtracking.

Subsets (Power Set)

Generate all subsets of a given array (LeetCode #78). For each element, we either include it or skip it.

function subsets(nums) {
  const result = [];
  function backtrack(start, current) {
    result.push([...current]); // every path is a valid subset
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);       // include nums[i]
      backtrack(i + 1, current);   // explore with it
      current.pop();               // exclude nums[i]
    }
  }
  backtrack(0, []);
  return result;
}
// subsets([1,2,3]) => [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
def subsets(nums):
    result = []
    def backtrack(start, current):
        result.append(current[:])  # every path is a valid subset
        for i in range(start, len(nums)):
            current.append(nums[i])     # include nums[i]
            backtrack(i + 1, current)   # explore with it
            current.pop()               # exclude nums[i]
    backtrack(0, [])
    return result

# subsets([1,2,3]) => [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
static List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), result);
    return result;
}
static void backtrack(int[] nums, int start,
                      List<Integer> current, List<List<Integer>> result) {
    result.add(new ArrayList<>(current)); // every path is valid
    for (int i = start; i < nums.length; i++) {
        current.add(nums[i]);
        backtrack(nums, i + 1, current, result);
        current.remove(current.size() - 1);
    }
}

Time: O(n * 2^n) — there are 2^n subsets, and we copy each one. Space: O(n) for the recursion stack.

Permutations

Generate all permutations of a given array (LeetCode #46). Unlike subsets, order matters and we use every element.

The key difference from subsets: instead of start, we use a used set to track which elements we’ve already placed.

function permute(nums) {
  const result = [];
  const used = new Set();
  function backtrack(current) {
    if (current.length === nums.length) {
      result.push([...current]);
      return;
    }
    for (let i = 0; i < nums.length; i++) {
      if (used.has(i)) continue; // skip already used
      used.add(i);
      current.push(nums[i]);
      backtrack(current);
      current.pop();             // undo
      used.delete(i);            // undo
    }
  }
  backtrack([]);
  return result;
}
// permute([1,2,3]) => [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
def permute(nums):
    result = []
    used = set()
    def backtrack(current):
        if len(current) == len(nums):
            result.append(current[:])
            return
        for i in range(len(nums)):
            if i in used:
                continue  # skip already used
            used.add(i)
            current.append(nums[i])
            backtrack(current)
            current.pop()      # undo
            used.discard(i)    # undo
    backtrack([])
    return result

# permute([1,2,3]) => [[1,2,3],[1,3,2],[2,1,3],...]
static List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    boolean[] used = new boolean[nums.length];
    backtrack(nums, used, new ArrayList<>(), result);
    return result;
}
static void backtrack(int[] nums, boolean[] used,
                      List<Integer> current, List<List<Integer>> result) {
    if (current.size() == nums.length) {
        result.add(new ArrayList<>(current));
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true;
        current.add(nums[i]);
        backtrack(nums, used, current, result);
        current.remove(current.size() - 1); // undo
        used[i] = false;                     // undo
    }
}

Time: O(n * n!) — there are n! permutations. Space: O(n) for recursion depth.

Combinations

Choose k elements from n elements (LeetCode #77). Like subsets, but we only collect paths of length k.

function combine(n, k) {
  const result = [];
  function backtrack(start, current) {
    if (current.length === k) {
      result.push([...current]);
      return; // don't go deeper
    }
    for (let i = start; i <= n; i++) {
      current.push(i);
      backtrack(i + 1, current);
      current.pop();
    }
  }
  backtrack(1, []);
  return result;
}
// combine(4, 2) => [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
def combine(n, k):
    result = []
    def backtrack(start, current):
        if len(current) == k:
            result.append(current[:])
            return  # don't go deeper
        for i in range(start, n + 1):
            current.append(i)
            backtrack(i + 1, current)
            current.pop()
    backtrack(1, [])
    return result

# combine(4, 2) => [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
static List<List<Integer>> combine(int n, int k) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(n, k, 1, new ArrayList<>(), result);
    return result;
}
static void backtrack(int n, int k, int start,
                      List<Integer> current, List<List<Integer>> result) {
    if (current.size() == k) {
        result.add(new ArrayList<>(current));
        return;
    }
    for (int i = start; i <= n; i++) {
        current.add(i);
        backtrack(n, k, i + 1, current, result);
        current.remove(current.size() - 1);
    }
}

Time: O(k * C(n,k)) — C(n,k) combinations, each copied in O(k).

The Three Flavors Compared

ProblemLoop starts atUses used set?Base case
Subsetsstart (moves forward)NoEvery node is valid
Permutations0 (always)YesLength == n
Combinationsstart (moves forward)NoLength == k

That’s it. Same template, slightly different parameters. Once we see this, backtracking problems stop being scary.

N-Queens Concept

Place N queens on an N x N chessboard so no two queens attack each other. This is the classic constraint satisfaction problem.

We place queens row by row. For each row, we try each column. Before placing, we check if any existing queen attacks that position (same column, same diagonal). If no conflict, we place it and move to the next row. If we get stuck, we backtrack.

The key optimization is tracking which columns and diagonals are occupied using sets, so our conflict check is O(1) instead of O(n).

Sudoku Solver Concept

Fill in a 9x9 grid so each row, column, and 3x3 box contains digits 1-9. Same idea:

  1. Find an empty cell
  2. Try digits 1-9
  3. If valid (no conflict in row, column, or box), place it and recurse
  4. If we get stuck, undo and try the next digit

Both N-Queens and Sudoku follow the exact same template — choose, explore, unchoose. The only difference is the constraint checking logic.

When to Use Backtracking

Look for these signals:

  • “Find all combinations/permutations/subsets”
  • “Generate all valid configurations”
  • “Can we place/arrange items following constraints?”
  • The problem says “all possible” — that’s almost always backtracking
  • Constraint satisfaction (Sudoku, crosswords, N-Queens)

In simple language, if the problem asks us to explore all possibilities and there’s no shortcut to avoid it, backtracking is our tool. We’re systematically trying everything, but we’re smart about it — we prune branches that can’t lead to solutions instead of blindly checking every path.