Browse curriculum

Introduction to Backtracking

Choose, explore, unchoose — build every subset with one recursive call per candidate.

Medium
15 min
Solve on LeetCode

Examples

Input:nums = [1,2,3]
Output:[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
Explain:

Eight subsets, one recorded by each of the eight calls.

Input:nums = [0]
Output:[[],[0]]
Explain:

The root records the empty subset, its one child records [0].

1. What is Backtracking?

Backtracking is an algorithmic technique for solving problems recursively by trying to build a solution incrementally, one piece at a time, and removing those solutions that fail to satisfy the constraints of the problem at any point of time (this is the "backtrack" step).

Think of it as "Smart Brute Force". Instead of blindly generating every possible combination (like a brute force approach might), Backtracking uses pruning to abandon a path as soon as it determines the path cannot lead to a valid solution.

2. The Motivation: Dynamic Choices

Imagine you need every subset of [1, 2, 3].

  • Size 0: []
  • Size 1: [1], [2], [3]
  • Size 2: [1,2], [1,3], [2,3]
  • Size 3: [1,2,3]

That is 8 subsets, which is 2³ — each number is either in or out, so every number you add doubles the answer.

If the array were always three long you could write three nested for loops. But n is only known at runtime, and you cannot write n nested loops. Recursion is how you get nesting that is as deep as the input, and backtracking is what keeps the bookkeeping straight while you do it.

3. Attempt 1: Iterative Approach (Why it fails)

A common instinct is to use loops. For N=2, it works:

for i in range(n):
  for j in range(i+1, n):
    print([nums[i], nums[j]])

The Problem: This code is rigid. It can only find subsets of size 2. To find subsets of size 3, you'd need another loop. To find subsets of size N, you'd need N loops. Since N is unknown until runtime, pure iteration fails.

4. Visualizing the Approach

Draw the calls as a tree and the method stops being abstract.

  • Root: the call with an empty path.
  • A branch: one number being added to the path.
  • Every node: a call, holding one partial subset — and in this method, every node records it.

The figure below is exactly that tree. backtrack(0) has one child for each number it may take first; each of those has one child for each number after it. Looking only forwards is what stops [1, 3] and [3, 1] both being produced — the tree has no room for the second.

5. When should I use Backtracking?

Look for these Pattern Triggers:

  • Find all solutions: "Return all substrings", "Generate all parentheses", "All permutations".

  • Constraint Satisfaction: "Solve Sudoku", "N-Queens", "Valid word placement".

  • Combinatorial Optimization: "Knapsack problem" (small constraints).

  • Valid Arrangements: Subsets, Combinations, Permutations.

6. The Intuition: Choose, Explore, Unchoose

The mantra for Backtracking is:

  1. Choose: Make a choice (add an element to your current candidate).
  2. Explore: Recursively move to the next state (call the function again).
  3. Unchoose (Backtrack): Undo the choice (remove the element) to return to the previous state.

This "Unchoose" step is why it's called Backtracking. It allows you to reuse the same data structure (current_path) for all solutions, saving memory.

7. Interactive Walkthrough

ONE NODE PER CALL · THE LABEL IS THE NUMBER IT ADDED, THE LINE BELOW IS THE SUBSET IT RECORDS

EMPTY RESULT AND PATH

path

[]

subsets found

0

Answer

—

call stack

No call running

result

Nothing recorded yet

 

Start with an empty result and an empty path. The numbers are 1, 2, 3

Next

backtrack(0): every call records its own path — result gains the empty subset []

nums

Legend & Complexity

The call running now

A call further up that is waiting for this one to return

Returned — its subset is already in the result

The number just chosen or unchosen

Time

O(n × 2ⁿ)

Space

O(n) path, O(n × 2ⁿ) output

Every node is one call to backtrack. Its label is the number that call added, and the line under it is the path that call holds — which is the subset it records the moment it is entered, so the result list beside the figure fills in the order the tree is walked. The number in amber is the one being chosen or unchosen on this step. Step through the Two numbers preset first: four calls, four subsets.

8. Dry Run Table

Tracing [1, 2] all the way through — seven calls and returns, no ellipsis.

# Inside Path What happens
1 backtrack(0) [] Records []. Chooses 1 → calls backtrack(1)
2 backtrack(1) [1] Records [1]. Chooses 2 → calls backtrack(2)
3 backtrack(2) [1,2] Records [1,2]. The loop has nothing to try — returns
4 backtrack(1) [1] Unchooses 2. Its loop is finished — returns
5 backtrack(0) [] Unchooses 1. Chooses 2 → calls backtrack(2)
6 backtrack(2) [2] Records [2]. Nothing to try — returns
7 backtrack(0) [] Unchooses 2. Its loop is finished — returns

Result: [[], [1], [1,2], [2]] — four subsets for two numbers, which is 2².

Notice rows 4 and 7: unchoosing is not bookkeeping for its own sake. Without the pop at row 4, row 5 would start from [1, 2] and produce [1, 2, 2].

9. The Solution: Subsets (LeetCode 78)

The template above, filled in for LeetCode 78. Every line of it is one of the five moves: record, choose, explore, unchoose, return.

Introduction to Backtracking Code
1var subsets = function(nums) {
2 const result = [];
3 const path = [];
4
5 const backtrack = (start) => {
6 result.push([...path]);
7 for (let i = start; i < nums.length; i++) {
8 path.push(nums[i]);
9 backtrack(i + 1);
10 path.pop();
11 }
12 };
13
14 backtrack(0);
15 return result;
16};

10. Edge Cases & Common Mistakes

Watch out for these pitfalls:

  • Reference vs Value: Attempting to store current list directly without making a copy (e.g., res.add(current) in Java). This results in a list of empty arrays because current is emptied by backtracking.

  • Base Case Logic: Forgetting to return after a base case, causing stack overflow or invalid states.

  • State Cleanup: Forgetting to pop() or remove the element after the recursive call. This 'pollutes' the state for future branches.

The approach, step by step

  1. Record the candidate

    On entering a call, copy the current path into the result — in this problem every call holds a valid subset.

  2. Choose and explore

    For each number from the start index onwards, append it to the path and recurse from the next index.

  3. Unchoose

    Pop the number back off before trying the next one, so each branch starts from the candidate its caller held.

Frequently asked questions

Why does backtracking need the "unchoose" step?

The path is one shared list rather than a fresh copy per branch. Popping the number back off returns the list to what the caller had, so the next branch starts from the right candidate. Without it, choices from one branch leak into the next.


Why copy the path when recording it?

The path keeps changing after the record. Storing a reference means every entry in the result ends up pointing at the same list, which is empty by the time the search finishes — so you take a copy: path[:] in Python, new ArrayList<>(path) in Java.


How is backtracking different from plain recursion?

The undo. Plain recursion passes new state down; backtracking mutates one piece of shared state on the way down and restores it on the way back up, which is what keeps memory proportional to the depth rather than to the number of candidates.

Want to see the mechanism first? Step through it in the visualizer:

Backtracking

Applying for software engineering roles? Free, no sign-up:

AI resume checker — match your resume to the job description