Skip to content
Commitwise
Menu
Coding Interview

Backtracking

List every subset, permutation and combination with one choose-explore-unchoose template, cut the search short with pruning, and solve Generate Parentheses and N-Queens in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“Return all possible…” is the phrase that gives it away: all subsets, all orderings, all ways to reach a target, every valid board. There’s no clever formula for those; you have to build every candidate. BacktrackingBacktrackingBuilding a solution one choice at a time, undoing the last choice when it can't lead to an answer, to explore every valid combination.Open in glossary → does it systematically with one small template, and its pruning step is what makes the difference between a solution that finishes in a millisecond and one that never finishes.

New to some of these? Open what you need, or skip ahead.

Basics:What is recursion?

A recursive function calls itself on a smaller version of the same problem. Every recursive function needs two parts:

  • a base case that answers directly and stops the calls (if n == 0 { return 1 }),
  • a recursive step that makes the problem smaller (return n * factorial(n - 1)).

Each call waits until the calls it made have finished, so you can picture recursion as a tree of calls. The Trees tutorial uses recursion everywhere.

Basics:What are lists, sets and maps?

Collections hold many values:

  • A list (Swift Array, Kotlin List, Java List, a TypeScript array) keeps items in order, and allows duplicates: ["Ana", "Ben", "Ana"].
  • A set holds each value at most once and answers “is this in it?” very quickly.
  • A map (Swift Dictionary, Kotlin, Java and TypeScript Map) stores values under keys: "Ana" → 30, looked up by the key.

Lists use indexes starting at 0: in ["a", "b", "c"], "a" is at index 0 and "c" at index 2.

Basics:What does O(n) mean?

Big-O describes how the amount of work grows with the size of the input n:

  • O(1): the same work whatever the size (reading array[5]).
  • O(log n): grows very slowly (about 20 steps for a million items).
  • O(n): grows in step with the input (one pass over the array).
  • O(n²): comparing every item with every other item, which gets slow fast.

You’ll see it next to every solution, for time and for extra space (memory). The Big-O tutorial explains it properly.

Part Not started

Choose, explore, unchoose

Imagine walking through a maze. At every fork you pick a path and keep going. If you hit a dead end, you walk back to the last fork and try the next path. You never start the whole maze again; you only undo the last step.

Backtracking is that idea in code. You build an answer one choice at a time in a list called the path. After exploring everything that can follow a choice, you undo it, so the path is exactly as it was, and try the next option. The three moves are always the same: choose, explore, unchoose.

Backtracking is a depth-first traversal of an implicit tree of partial solutions. One mutable path is shared across the recursion, and the “unchoose” step restores it, so memory stays O(depth) instead of copying a partial solution at every node. The work is proportional to the number of nodes in that tree (2ⁿ for subsets, n! for permutations) times the cost of emitting each solution. Everything interesting is in two places: how you avoid generating the same solution twice (a start index, a used array, skipping equal neighbors) and how early you can cut a branch (pruning).

All subsets

Subsets. Given distinct numbers, return every subset. [1, 2, 3] → [], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3].

func subsets(_ nums: [Int]) -> [[Int]] {
    var result: [[Int]] = []
    var path: [Int] = []

    func explore(_ start: Int) {
        result.append(path)                 // every path is a subset
        for i in start..<nums.count {
            path.append(nums[i])            // choose
            explore(i + 1)                  // explore: only later numbers
            path.removeLast()               // unchoose
        }
    }

    explore(0)
    return result
}
fun subsets(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    val path = mutableListOf<Int>()

    fun explore(start: Int) {
        result.add(path.toList())           // every path is a subset (a copy)
        for (i in start until nums.size) {
            path.add(nums[i])               // choose
            explore(i + 1)                  // explore: only later numbers
            path.removeAt(path.size - 1)    // unchoose
        }
    }

    explore(0)
    return result
}
List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    explore(nums, 0, new ArrayList<>(), result);
    return result;
}

void explore(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
    result.add(new ArrayList<>(path));      // every path is a subset (a copy)
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                  // choose
        explore(nums, i + 1, path, result); // explore: only later numbers
        path.remove(path.size() - 1);       // unchoose
    }
}
function subsets(nums: number[]): number[][] {
  const result: number[][] = [];
  const path: number[] = [];

  const explore = (start: number): void => {
    result.push([...path]);               // every path is a subset (a copy)
    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);                 // choose
      explore(i + 1);                     // explore: only later numbers
      path.pop();                         // unchoose
    }
  };

  explore(0);
  return result;
}

Two details do all the work:

  • start makes each call only look at numbers after the last one chosen. That’s why [2, 1] never appears next to [1, 2]: once you’ve moved past 1, you can’t go back to it.
  • Unchoose removes the number that was just added, so when the loop moves on to the next i, the path is exactly what it was before.

kotlin java typescript note

path is one list that keeps changing. Saving path itself would store the same list many times, and it ends up empty. Always save a copy: path.toList(), new ArrayList<>(path), [...path].

Swift note

Swift arrays are values, so result.append(path) already stores a copy (cheaply, with copy-on-write). In Kotlin, Java and TypeScript you have to copy explicitly.

What happens if you forget to undo

Spot the bugSubsets that keep growing

Tap the line with the bug.

func subsets(_ nums: [Int]) -> [[Int]] {
    var result: [[Int]] = []
    var path: [Int] = []
    func explore(_ start: Int) {
        result.append(path)
        for i in start..<nums.count {
            path.append(nums[i])
            explore(i + 1)
        }
    }
    explore(0)
    return result
}
fun subsets(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    val path = mutableListOf<Int>()
    fun explore(start: Int) {
        result.add(path.toList())
        for (i in start until nums.size) {
            path.add(nums[i])
            explore(i + 1)
        }
    }
    explore(0)
    return result
}
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();

void explore(int[] nums, int start) {
    result.add(new ArrayList<>(path));
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);
        explore(nums, i + 1);
    }
}
function subsets(nums: number[]): number[][] {
  const result: number[][] = [];
  const path: number[] = [];
  const explore = (start: number): void => {
    result.push([...path]);
    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);
      explore(i + 1);
    }
  };
  explore(0);
  return result;
}

Practice: trace the calls

Drawing the calls as a tree is the best way to understand any backtracking solution. For subsets([1, 2]):

  1. explore(0) with path []: save []. The loop tries index 0 and index 1.
  2. Choose 1 → path [1], call explore(1): save [1]. Its loop tries index 1.
  3. Choose 2 → path [1, 2], call explore(2): save [1, 2]. Its loop has nothing to try (start is 2), so it returns.
  4. Unchoose 2 → path [1]. explore(1) is done; back in explore(0), unchoose 1 → path [].
  5. Choose 2 → path [2], call explore(2): save [2], return. Unchoose 2 → []. Done: [], [1], [1, 2], [2].

Each number is either in a subset or not, so n numbers give 2ⁿ subsets: 4 for two numbers, 8 for three, about a million for twenty. That’s why backtracking is only used when the input is small.

Identity bugs across languages

Predict the output

This version saves path without copying it. What does subsets([1, 2, 3]) return?

func explore(_ start: Int) {
    result.append(path)
    for i in start..<nums.count {
        path.append(nums[i]); explore(i + 1); path.removeLast()
    }
}
fun explore(start: Int) {
    result.add(path)
    for (i in start until nums.size) {
        path.add(nums[i]); explore(i + 1); path.removeAt(path.size - 1)
    }
}
void explore(int[] nums, int start) {
    result.add(path);
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]); explore(nums, i + 1); path.remove(path.size() - 1);
    }
}
const explore = (start: number): void => {
  result.push(path);
  for (let i = start; i < nums.length; i++) {
    path.push(nums[i]); explore(i + 1); path.pop();
  }
};

Cost and alternatives

  • Time: 2ⁿ subsets, each copied in O(n): O(n · 2ⁿ), which is also the output size, so you can’t do better.
  • Space: O(n) recursion depth plus the path, excluding output. Deep recursion (n in the thousands) can overflow the stack; for subsets, n is tiny by necessity.
  • Bitmask enumeration: for n ≤ 20 or so, for mask in 0..<(1 << n) and taking the bits that are set lists every subset without recursion. It’s a good follow-up answer, and useful when you need to iterate subsets in a hot loop.
  • DFS over partial solutions with one shared path: choose → recurse → unchoose. O(depth) extra memory.
  • Subsets: save the path at every node, recurse with i + 1 so each set appears once in one order. O(n · 2ⁿ).
  • Saving path without copying stores one shared list in Kotlin/Java/TS (all entries end up empty); Swift arrays copy by value.
  • Alternative for small n: bitmask enumeration over 0 ..< 1 << n.
func subsets(_ nums: [Int]) -> [[Int]] {
    var result: [[Int]] = [], path: [Int] = []
    func go(_ s: Int) { result.append(path); for i in s..<nums.count { path.append(nums[i]); go(i + 1); path.removeLast() } }
    go(0); return result
}
fun subsets(nums: IntArray): List<List<Int>> = buildList {
    val path = ArrayDeque<Int>()
    fun go(s: Int) { add(path.toList()); for (i in s until nums.size) { path.addLast(nums[i]); go(i + 1); path.removeLast() } }
    go(0)
}
void go(int[] nums, int s, Deque<Integer> path, List<List<Integer>> out) {
    out.add(new ArrayList<>(path));
    for (int i = s; i < nums.length; i++) { path.addLast(nums[i]); go(nums, i + 1, path, out); path.removeLast(); }
}
function subsets(nums: number[]): number[][] {
  const out: number[][] = [], path: number[] = [];
  const go = (s: number): void => { out.push([...path]); for (let i = s; i < nums.length; i++) { path.push(nums[i]); go(i + 1); path.pop(); } };
  go(0);
  return out;
}

Check this part

  1. What are the three steps of the backtracking template?

  2. Why does each subsets call start its loop at start instead of 0?

  3. How many subsets does an array of 3 distinct numbers have?

  4. A Kotlin port saves result.add(path) without copying. What does it return for subsets of [1, 2, 3]?result.add(path) without a copy. What does subsets of [1, 2, 3] return?result.push(path) without a copy. What does subsets of [1, 2, 3] return?

Keep a path of choices. At each step: choose an option, explore the rest recursively, then undo the choice so the next option starts clean. Saving the path at the right moment lists every subset.

Part Not started

Permutations and combinations

The template stays the same; what changes is which options each step may choose:

ProblemOptions at each stepRecurse with
Subsetsany later elementi + 1
Permutationsany element not used yetthe same “used” set
Combinations that may reuse an elementthis element or later onesi
Combinations without reuselater elementsi + 1

Permutations

Permutations. Return every ordering of distinct numbers. [1, 2, 3] → [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1].

func permute(_ nums: [Int]) -> [[Int]] {
    var result: [[Int]] = []
    var path: [Int] = []
    var used = [Bool](repeating: false, count: nums.count)

    func explore() {
        if path.count == nums.count { result.append(path); return }   // a full ordering
        for i in 0..<nums.count where !used[i] {
            used[i] = true; path.append(nums[i])                      // choose
            explore()                                                 // explore
            used[i] = false; path.removeLast()                        // unchoose
        }
    }

    explore()
    return result
}
fun permute(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    val path = mutableListOf<Int>()
    val used = BooleanArray(nums.size)

    fun explore() {
        if (path.size == nums.size) { result.add(path.toList()); return }   // a full ordering
        for (i in nums.indices) {
            if (used[i]) continue
            used[i] = true; path.add(nums[i])                               // choose
            explore()                                                       // explore
            used[i] = false; path.removeAt(path.size - 1)                   // unchoose
        }
    }

    explore()
    return result
}
List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    explore(nums, new boolean[nums.length], new ArrayList<>(), result);
    return result;
}

void explore(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
    if (path.size() == nums.length) { result.add(new ArrayList<>(path)); return; }  // a full ordering
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true; path.add(nums[i]);                                  // choose
        explore(nums, used, path, result);                                  // explore
        used[i] = false; path.remove(path.size() - 1);                      // unchoose
    }
}
function permute(nums: number[]): number[][] {
  const result: number[][] = [];
  const path: number[] = [];
  const used = new Array<boolean>(nums.length).fill(false);

  const explore = (): void => {
    if (path.length === nums.length) { result.push([...path]); return; }  // a full ordering
    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue;
      used[i] = true; path.push(nums[i]);                                 // choose
      explore();                                                          // explore
      used[i] = false; path.pop();                                        // unchoose
    }
  };

  explore();
  return result;
}

Unlike subsets, order matters now: [2, 1] and [1, 2] are different permutations. So every step may pick any number, as long as it isn’t in the path yet. The used array answers “is it in the path?” in O(1), and it’s undone together with the path. A permutation is saved only when the path is full; that’s the base case.

Permutations: n choices, then n − 1, …: n! leaves, each copied in O(n), so O(n · n!). The used array is the “state besides the path” that must be restored on unchoose; forgetting to restore it is the most common bug after forgetting the path. An in-place alternative swaps nums[start] with each nums[i], recurses on start + 1 and swaps back, which avoids the used array but doesn’t produce lexicographic order.

Predict the output

How many lists does permute([1, 2, 3, 4]) return?

Exercisemedium

Combination Sum

Given distinct positive numbers candidates and a target, return every combination that adds up to target. The same number may be used any number of times; the order inside a combination doesn’t matter.

candidates = [2, 3, 6, 7], target = 7  → [[2, 2, 3], [7]]
candidates = [2, 3, 5],    target = 8  → [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
›

Pass the remaining amount into the recursion. When it’s 0, the path is a valid combination. When it’s negative, stop.

›

“May reuse” means the next step may choose the same number again: recurse with i, not i + 1. Sorting first lets you break the loop as soon as a candidate is bigger than what remains.

SolutionTry it yourself firstTime O(branches^(target / smallest))Space O(target / smallest) recursion depth
func combinationSum(_ candidates: [Int], _ target: Int) -> [[Int]] {
    let nums = candidates.sorted()
    var result: [[Int]] = []
    var path: [Int] = []

    func explore(_ start: Int, _ remaining: Int) {
        if remaining == 0 { result.append(path); return }
        for i in start..<nums.count {
            if nums[i] > remaining { break }          // sorted: the rest are too big too
            path.append(nums[i])
            explore(i, remaining - nums[i])           // i, not i + 1: may reuse
            path.removeLast()
        }
    }

    explore(0, target)
    return result
}
fun combinationSum(candidates: IntArray, target: Int): List<List<Int>> {
    val nums = candidates.sorted()
    val result = mutableListOf<List<Int>>()
    val path = mutableListOf<Int>()

    fun explore(start: Int, remaining: Int) {
        if (remaining == 0) { result.add(path.toList()); return }
        for (i in start until nums.size) {
            if (nums[i] > remaining) break          // sorted: the rest are too big too
            path.add(nums[i])
            explore(i, remaining - nums[i])         // i, not i + 1: may reuse
            path.removeAt(path.size - 1)
        }
    }

    explore(0, target)
    return result
}
List<List<Integer>> combinationSum(int[] candidates, int target) {
    int[] nums = candidates.clone();
    Arrays.sort(nums);
    List<List<Integer>> result = new ArrayList<>();
    explore(nums, 0, target, new ArrayList<>(), result);
    return result;
}

void explore(int[] nums, int start, int remaining, List<Integer> path, List<List<Integer>> result) {
    if (remaining == 0) { result.add(new ArrayList<>(path)); return; }
    for (int i = start; i < nums.length; i++) {
        if (nums[i] > remaining) break;                 // sorted: the rest are too big too
        path.add(nums[i]);
        explore(nums, i, remaining - nums[i], path, result);   // i, not i + 1: may reuse
        path.remove(path.size() - 1);
    }
}
function combinationSum(candidates: number[], target: number): number[][] {
  const nums = [...candidates].sort((a, b) => a - b);
  const result: number[][] = [];
  const path: number[] = [];

  const explore = (start: number, remaining: number): void => {
    if (remaining === 0) { result.push([...path]); return; }
    for (let i = start; i < nums.length; i++) {
      if (nums[i] > remaining) break;           // sorted: the rest are too big too
      path.push(nums[i]);
      explore(i, remaining - nums[i]);          // i, not i + 1: may reuse
      path.pop();
    }
  };

  explore(0, target);
  return result;
}

The start index still prevents [3, 2, 2] from appearing next to [2, 2, 3]: once the loop moves past 2, later calls can’t pick 2 again. The break is your first taste of pruning: on sorted input, one number that’s too big means all the following ones are too.

Duplicates in the input

With repeated values in the input, the template produces repeated answers: [1, 1, 2] yields [1, 2] twice (once with each 1). Sort, then skip a value that equals the previous one at the same level of the tree:

Spot the bugCombination Sum II misses answers

Tap the line with the bug.

func combinationSum2(_ candidates: [Int], _ target: Int) -> [[Int]] {
    let nums = candidates.sorted()
    var result: [[Int]] = [], path: [Int] = []
    func explore(_ start: Int, _ remaining: Int) {
        if remaining == 0 { result.append(path); return }
        for i in start..<nums.count {
            if i > 0 && nums[i] == nums[i - 1] { continue }
            if nums[i] > remaining { break }
            path.append(nums[i])
            explore(i + 1, remaining - nums[i])
            path.removeLast()
        }
    }
    explore(0, target)
    return result
}
fun combinationSum2(candidates: IntArray, target: Int): List<List<Int>> {
    val nums = candidates.sorted()
    val result = mutableListOf<List<Int>>(); val path = mutableListOf<Int>()
    fun explore(start: Int, remaining: Int) {
        if (remaining == 0) { result.add(path.toList()); return }
        for (i in start until nums.size) {
            if (i > 0 && nums[i] == nums[i - 1]) continue
            if (nums[i] > remaining) break
            path.add(nums[i])
            explore(i + 1, remaining - nums[i])
            path.removeAt(path.size - 1)
        }
    }
    explore(0, target)
    return result
}
void explore(int[] nums, int start, int remaining, List<Integer> path, List<List<Integer>> result) {
    if (remaining == 0) { result.add(new ArrayList<>(path)); return; }
    for (int i = start; i < nums.length; i++) {
        if (i > 0 && nums[i] == nums[i - 1]) continue;
        if (nums[i] > remaining) break;
        path.add(nums[i]);
        explore(nums, i + 1, remaining - nums[i], path, result);
        path.remove(path.size() - 1);
    }
}
function combinationSum2(candidates: number[], target: number): number[][] {
  const nums = [...candidates].sort((a, b) => a - b);
  const result: number[][] = [], path: number[] = [];
  const explore = (start: number, remaining: number): void => {
    if (remaining === 0) { result.push([...path]); return; }
    for (let i = start; i < nums.length; i++) {
      if (i > 0 && nums[i] === nums[i - 1]) continue;
      if (nums[i] > remaining) break;
      path.push(nums[i]);
      explore(i + 1, remaining - nums[i]);
      path.pop();
    }
  };
  explore(0, target);
  return result;
}

ProblemLoopRecurseSave whenCost
Subsetsstart…ni + 1every nodeO(n · 2ⁿ)
Permutations0…n, skip usedsame usedpath fullO(n · n!)
Combination Sum (reuse)start…n, sorted, break if too bigiremaining == 0exponential in target/min
With duplicatessort, skip i > start && nums[i] == nums[i−1]i + 1as above
  • Every piece of state changed on choose (path, used, counters, board cells) must be restored on unchoose.
  • Duplicate skip compares with the previous element at the same level (i > start), not globally.

Check this part

  1. Permutations instead of subsets: what replaces the start index?

  2. In Combination Sum, a number may be used again. Which index does the recursive call get?

  3. Combination Sum II with duplicates. Which skip condition is correct?

  4. Permutations of n distinct elements cost…

Permutations use every element once in any order: track used elements instead of a start index. Combinations pick from a start index; reusing an element means recursing with i instead of i + 1. Duplicates are skipped by comparing with the previous element at the same level.

Part Not started

Pruning: stop early

Back in the maze: if you can see from the fork that a path ends in a wall, you don’t walk down it. That’s pruning: before exploring a choice, check whether it can still lead to a valid answer, and skip it if not.

Pruning is a cheap necessary condition checked before recursing. It doesn’t change the worst-case bound on paper, but it cuts the explored tree by orders of magnitude, often making exponential searches practical for interview-sized inputs. Good pruning checks are O(1): maintain the state they need incrementally (counts, sets of attacked columns) instead of recomputing it at every node.

You’ve already seen one: in Combination Sum, if nums[i] > remaining { break } skips every candidate that would overshoot. Here’s a problem where pruning is the whole idea:

Generate Parentheses. Return every valid string of n pairs of brackets. n = 3 → ((())), (()()), (())(), ()(()), ()()().

func generateParenthesis(_ n: Int) -> [String] {
    var result: [String] = []
    var path = ""

    func explore(_ open: Int, _ close: Int) {
        if path.count == 2 * n { result.append(path); return }
        if open < n {                                   // room for another "("
            path.append("("); explore(open + 1, close); path.removeLast()
        }
        if close < open {                               // a ")" must close something
            path.append(")"); explore(open, close + 1); path.removeLast()
        }
    }

    explore(0, 0)
    return result
}
fun generateParenthesis(n: Int): List<String> {
    val result = mutableListOf<String>()
    val path = StringBuilder()

    fun explore(open: Int, close: Int) {
        if (path.length == 2 * n) { result.add(path.toString()); return }
        if (open < n) {                                 // room for another "("
            path.append('('); explore(open + 1, close); path.deleteCharAt(path.length - 1)
        }
        if (close < open) {                             // a ")" must close something
            path.append(')'); explore(open, close + 1); path.deleteCharAt(path.length - 1)
        }
    }

    explore(0, 0)
    return result
}
List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    explore(n, 0, 0, new StringBuilder(), result);
    return result;
}

void explore(int n, int open, int close, StringBuilder path, List<String> result) {
    if (path.length() == 2 * n) { result.add(path.toString()); return; }
    if (open < n) {                                     // room for another "("
        path.append('('); explore(n, open + 1, close, path, result); path.deleteCharAt(path.length() - 1);
    }
    if (close < open) {                                 // a ")" must close something
        path.append(')'); explore(n, open, close + 1, path, result); path.deleteCharAt(path.length() - 1);
    }
}
function generateParenthesis(n: number): string[] {
  const result: string[] = [];
  const path: string[] = [];

  const explore = (open: number, close: number): void => {
    if (path.length === 2 * n) { result.push(path.join("")); return; }
    if (open < n) {                                     // room for another "("
      path.push("("); explore(open + 1, close); path.pop();
    }
    if (close < open) {                                 // a ")" must close something
      path.push(")"); explore(open, close + 1); path.pop();
    }
  };

  explore(0, 0);
  return result;
}

Without the two checks, you’d generate all 2⁶ = 64 strings of six brackets and then test each one. With them, an invalid string is never even started: you can’t open more than n brackets, and you can’t close one that isn’t open. For n = 3, only the 5 valid strings are ever completed.

ProblemResults for nn = 10
Subsets2ⁿ1,024
Permutationsn!3,628,800
Valid parenthesesCatalan number16,796
N-Queens solutionsno simple formula724

That’s why backtracking problems have small inputs (often n ≤ 10 to 20). If a problem with “all combinations” allows n = 10⁵, it’s asking for a count or a best value, not a list, and the tool is usually dynamic programming.

Exercisemedium

Letter Combinations of a Phone Number

On an old phone keypad, digits map to letters: 2 → abc, 3 → def, 4 → ghi, 5 → jkl, 6 → mno, 7 → pqrs, 8 → tuv, 9 → wxyz. Given a string of digits 2–9, return every letter combination it could spell.

"23" → ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
""   → []
›

The path is the letters chosen so far, and its length tells you which digit comes next. The options at each step are that digit’s letters.

›

Stop when the path is as long as the digits string. Handle the empty input before starting, or you’ll return [""].

SolutionTry it yourself firstTime O(4ⁿ · n)Space O(n) besides the output
func letterCombinations(_ digits: String) -> [String] {
    let letters: [Character: String] = ["2": "abc", "3": "def", "4": "ghi", "5": "jkl",
                                        "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"]
    let ds = Array(digits)
    guard !ds.isEmpty else { return [] }
    var result: [String] = []
    var path = ""

    func explore(_ i: Int) {
        if i == ds.count { result.append(path); return }
        for c in letters[ds[i]]! {
            path.append(c); explore(i + 1); path.removeLast()
        }
    }

    explore(0)
    return result
}
fun letterCombinations(digits: String): List<String> {
    if (digits.isEmpty()) return emptyList()
    val letters = mapOf('2' to "abc", '3' to "def", '4' to "ghi", '5' to "jkl",
                        '6' to "mno", '7' to "pqrs", '8' to "tuv", '9' to "wxyz")
    val result = mutableListOf<String>()
    val path = StringBuilder()

    fun explore(i: Int) {
        if (i == digits.length) { result.add(path.toString()); return }
        for (c in letters.getValue(digits[i])) {
            path.append(c); explore(i + 1); path.deleteCharAt(path.length - 1)
        }
    }

    explore(0)
    return result
}
private static final String[] LETTERS = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (!digits.isEmpty()) explore(digits, 0, new StringBuilder(), result);
    return result;
}

void explore(String digits, int i, StringBuilder path, List<String> result) {
    if (i == digits.length()) { result.add(path.toString()); return; }
    for (char c : LETTERS[digits.charAt(i) - '0'].toCharArray()) {
        path.append(c); explore(digits, i + 1, path, result); path.deleteCharAt(path.length() - 1);
    }
}
function letterCombinations(digits: string): string[] {
  if (digits.length === 0) return [];
  const letters: Record<string, string> = {
    "2": "abc", "3": "def", "4": "ghi", "5": "jkl", "6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz",
  };
  const result: string[] = [];
  const path: string[] = [];

  const explore = (i: number): void => {
    if (i === digits.length) { result.push(path.join("")); return; }
    for (const c of letters[digits[i]]) {
      path.push(c); explore(i + 1); path.pop();
    }
  };

  explore(0);
  return result;
}

Each digit has at most 4 letters, so n digits give at most 4ⁿ strings of length n. There’s nothing to prune here, since every combination is valid; the template alone does the job.

Exercisehard

N-Queens

Place n queens on an n × n chessboard so that no two attack each other (same row, column or diagonal). Return the number of distinct solutions. n = 4 → 2, n = 8 → 92.

›

Place exactly one queen per row, row by row, so rows never conflict. The choice at each step is the column.

›

A queen at (row, col) attacks one “down-right” diagonal, identified by row − col, and one “down-left” diagonal, identified by row + col. Keep three sets (or boolean arrays): used columns, used row − col, used row + col. Checking and updating them is O(1).

SolutionTry it yourself firstTime O(n!) worst case, far less with pruningSpace O(n)
func totalNQueens(_ n: Int) -> Int {
    var cols = [Bool](repeating: false, count: n)
    var diag = [Bool](repeating: false, count: 2 * n)      // row - col + n
    var anti = [Bool](repeating: false, count: 2 * n)      // row + col
    var count = 0

    func place(_ row: Int) {
        if row == n { count += 1; return }
        for col in 0..<n where !cols[col] && !diag[row - col + n] && !anti[row + col] {
            cols[col] = true; diag[row - col + n] = true; anti[row + col] = true
            place(row + 1)
            cols[col] = false; diag[row - col + n] = false; anti[row + col] = false
        }
    }

    place(0)
    return count
}
fun totalNQueens(n: Int): Int {
    val cols = BooleanArray(n)
    val diag = BooleanArray(2 * n)        // row - col + n
    val anti = BooleanArray(2 * n)        // row + col
    var count = 0

    fun place(row: Int) {
        if (row == n) { count++; return }
        for (col in 0 until n) {
            if (cols[col] || diag[row - col + n] || anti[row + col]) continue
            cols[col] = true; diag[row - col + n] = true; anti[row + col] = true
            place(row + 1)
            cols[col] = false; diag[row - col + n] = false; anti[row + col] = false
        }
    }

    place(0)
    return count
}
int totalNQueens(int n) {
    return place(0, n, new boolean[n], new boolean[2 * n], new boolean[2 * n]);
}

int place(int row, int n, boolean[] cols, boolean[] diag, boolean[] anti) {
    if (row == n) return 1;
    int count = 0;
    for (int col = 0; col < n; col++) {
        if (cols[col] || diag[row - col + n] || anti[row + col]) continue;
        cols[col] = diag[row - col + n] = anti[row + col] = true;
        count += place(row + 1, n, cols, diag, anti);
        cols[col] = diag[row - col + n] = anti[row + col] = false;
    }
    return count;
}
function totalNQueens(n: number): number {
  const cols = new Array<boolean>(n).fill(false);
  const diag = new Array<boolean>(2 * n).fill(false);   // row - col + n
  const anti = new Array<boolean>(2 * n).fill(false);   // row + col
  let count = 0;

  const place = (row: number): void => {
    if (row === n) { count++; return; }
    for (let col = 0; col < n; col++) {
      if (cols[col] || diag[row - col + n] || anti[row + col]) continue;
      cols[col] = diag[row - col + n] = anti[row + col] = true;
      place(row + 1);
      cols[col] = diag[row - col + n] = anti[row + col] = false;
    }
  };

  place(0);
  return count;
}
  • One queen per row removes row conflicts by construction and turns n² cell choices into n column choices per level.
  • Incremental state makes the pruning check O(1); recomputing attacks by scanning the board would add an O(n) factor at every node.
  • Follow-ups: return the boards (keep queens[row] = col and render at the leaves); bitmask state (cols | diag | anti as integers, available = ~(cols | ld | rd) & full) is the fastest known simple approach; symmetry (mirror solutions) halves the work for counting.
Deep diveBacktracking vs dynamic programmingInterview favorite

Both explore choices recursively. Backtracking enumerates solutions and keeps a mutable path; dynamic programming aggregates (counts, minimum, maximum) and caches results of subproblems that repeat. A quick test: if two different paths lead to the same remaining subproblem, and you only need a number, not the list, memoize it and you have DP. “Number of combinations that sum to target” is DP; “list the combinations” is backtracking, because the output itself is exponential.

  • Prune with O(1) checks on incrementally maintained state (counts, column and diagonal sets), before recursing.
  • Generate Parentheses: add ( while open < n, ) while close < open; only valid strings are ever built.
  • N-Queens: one queen per row; sets for col, row − col, row + col; bitmasks for speed.
  • Output size bounds the cost (2ⁿ, n!, Catalan). Large n with “all” usually means a count or optimum: DP, not enumeration.

Check this part

  1. What does pruning do?

  2. In Generate Parentheses, when may you add a ")"?

  3. How do you check in O(1) whether a queen at (row, col) shares a diagonal with an earlier one?

  4. "Return the number of combinations summing to target", with target up to 10⁴. Backtracking or DP?

Pruning stops a branch as soon as it can't lead to a valid answer: a sum already too big, more closing than opening brackets, a queen under attack. It doesn't change the worst case on paper but decides whether the search finishes in practice.

Key takeaways

  • “Return all…” problems are backtracking: build a path one choice at a time.
  • The template is always choose, explore, unchoose; every change must be undone.
  • Subsets use a start index; permutations use a “used” array; reuse means recursing with i.
  • Pruning skips choices that can’t work, and inputs are small because the number of results explodes.
  • Backtracking is DFS over partial solutions with one shared, restored path: O(depth) memory, cost bounded by output size.
  • Copy the path when saving in Kotlin, Java and TypeScript; Swift arrays copy by value.
  • Skip duplicates with i > start && nums[i] == nums[i − 1] after sorting.
  • Make pruning checks O(1) with incremental state; when only a count or optimum is needed, switch to DP.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.