Coding patternsBacktracking and DP
Backtracking
The backtracking coding pattern for interviews. Choose, explore, undo, with a step-through of subsets as a decision tree, pruning and duplicate handling, code in Java, Python, C#, Go and TypeScript, and 10 LeetCode problems.
- Read first
- Tree traversal
Some problems ask for every answer: all subsets, all permutations, every way to place eight queens, every combination that sums to a target. There’s no clever shortcut, because the output itself is huge. What you control is doing the search cleanly, never building the same answer twice, and abandoning a branch the moment it can’t lead anywhere.
Backtracking is that search. Picture the choices as a tree: the root is “nothing chosen yet”, and each edge is one choice. A depth-first walk of that tree, using one shared list for the current path, visits every answer. The whole pattern is three lines: choose, explore, undo.
How to spot it
Section titled “How to spot it”- “All”, “every”, “generate”, “list all possible”: subsets, permutations, combinations, partitions, placements.
- Constraint puzzles: N-queens, sudoku, word search in a grid.
- Small inputs. Constraints like n ≤ 10 or n ≤ 20 are a strong hint that an exponential search is expected.
Sometimes the problem asks how many ways, or the best way, instead of listing them. Then it’s often dynamic programming. Counting doesn’t need to build each answer, and the subproblems usually overlap.
Watch it run
Section titled “Watch it run”Subsets of [1, 2, 3], drawn as the tree of choices. Each node is the path at that moment, and
every node is a subset. Follow the path box: it grows on the way down and shrinks on the way
back, and it’s the same list the whole time.
- def backtrack(start, path):
- result.append(path[:]) # a subset
- for j in range(start, len(nums)):
- path.append(nums[j]) # choose
- backtrack(j + 1, path) # explore
- path.pop() # undo
Start with the empty path. It is a subset too.
Notice which branches don’t exist: there’s no {2, 1}, because after choosing 2, the loop only
looks at numbers after it. That start index is what makes each subset appear exactly once, in
increasing order.
The template
Section titled “The template”Subsets, permutations and combination sum. They differ in only two places: where the loop starts, and when a path counts as an answer.
import java.util.ArrayList;import java.util.Arrays;import java.util.List;
public class Backtracking { static List<List<Integer>> subsets(int[] nums) { List<List<Integer>> result = new ArrayList<>(); subsets(nums, 0, new ArrayList<>(), result); return result; }
// Choose, explore, undo. One shared path; every call records it, then tries each later // number. start keeps each subset in one order, so [1, 2] is built but [2, 1] never is. static void subsets(int[] nums, int start, List<Integer> path, List<List<Integer>> result) { result.add(new ArrayList<>(path)); // a copy: path keeps changing after this for (int j = start; j < nums.length; j++) { path.add(nums[j]); // choose subsets(nums, j + 1, path, result); // explore path.remove(path.size() - 1); // undo, so the next branch starts from the same path } }
static List<List<Integer>> permutations(int[] nums) { List<List<Integer>> result = new ArrayList<>(); permute(nums, new boolean[nums.length], new ArrayList<>(), result); return result; }
// Order matters now, so every position may take any unused number. used[] replaces start. static void permute( int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) { if (path.size() == nums.length) { result.add(new ArrayList<>(path)); return; } for (int j = 0; j < nums.length; j++) { if (used[j]) continue; used[j] = true; path.add(nums[j]); permute(nums, used, path, result); path.remove(path.size() - 1); used[j] = false; } }
static List<List<Integer>> combinationSum(int[] candidates, int target) { int[] sortedCandidates = candidates.clone(); Arrays.sort(sortedCandidates); List<List<Integer>> result = new ArrayList<>(); combine(sortedCandidates, 0, target, new ArrayList<>(), result); return result; }
// Numbers may repeat, so recurse with j, not j + 1. Sorting first lets us prune: once a // candidate is bigger than what's left, every candidate after it is too, so break. static void combine(int[] sortedCandidates, int start, int remaining, List<Integer> path, List<List<Integer>> result) { if (remaining == 0) { result.add(new ArrayList<>(path)); return; } for (int j = start; j < sortedCandidates.length && sortedCandidates[j] <= remaining; j++) { path.add(sortedCandidates[j]); combine(sortedCandidates, j, remaining - sortedCandidates[j], path, result); path.remove(path.size() - 1); } }
public static void main(String[] args) { check(subsets(new int[] {1, 2, 3}).equals(List.of(List.of(), List.of(1), List.of(1, 2), List.of(1, 2, 3), List.of(1, 3), List.of(2), List.of(2, 3), List.of(3)))); check(permutations(new int[] {1, 2, 3}).size() == 6); check(permutations(new int[] {1, 2}).get(1).equals(List.of(2, 1))); check(combinationSum(new int[] {2, 3, 6, 7}, 7) .equals(List.of(List.of(2, 2, 3), List.of(7)))); System.out.println("ok: subsets, permutations and combinationSum"); }
static void check(boolean ok) { if (!ok) throw new AssertionError(); }}def subsets(nums: list[int]) -> list[list[int]]: # Choose, explore, undo. One shared path; every call records it, then tries each later # number. start keeps each subset in one order, so [1, 2] is built but [2, 1] never is. result: list[list[int]] = [] path: list[int] = []
def backtrack(start: int) -> None: result.append(path[:]) # a copy: path keeps changing after this for j in range(start, len(nums)): path.append(nums[j]) # choose backtrack(j + 1) # explore path.pop() # undo, so the next branch starts from the same path
backtrack(0) return result
def permutations(nums: list[int]) -> list[list[int]]: # Order matters now, so every position may take any unused number. used[] replaces start. result: list[list[int]] = [] path: list[int] = [] used = [False] * len(nums)
def backtrack() -> None: if len(path) == len(nums): result.append(path[:]) return for j, number in enumerate(nums): if used[j]: continue used[j] = True path.append(number) backtrack() path.pop() used[j] = False
backtrack() return result
def combination_sum(candidates: list[int], target: int) -> list[list[int]]: # Numbers may repeat, so recurse with j, not j + 1. Sorting first lets us prune: once a # candidate is bigger than what's left, every candidate after it is too, so break. candidates = sorted(candidates) result: list[list[int]] = [] path: list[int] = []
def backtrack(start: int, remaining: int) -> None: if remaining == 0: result.append(path[:]) return for j in range(start, len(candidates)): if candidates[j] > remaining: break # prune the whole rest of this level path.append(candidates[j]) backtrack(j, remaining - candidates[j]) path.pop()
backtrack(0, target) return result
assert subsets([1, 2, 3]) == [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]assert len(permutations([1, 2, 3])) == 6 and permutations([1, 2])[1] == [2, 1]assert combination_sum([2, 3, 6, 7], 7) == [[2, 2, 3], [7]]print("ok: subsets, permutations and combination_sum")using System;using System.Collections.Generic;using System.Linq;
static class Backtracking{ public static List<List<int>> Subsets(int[] nums) { // Choose, explore, undo. One shared path; every call records it, then tries each later // number. start keeps each subset in one order, so [1, 2] is built but [2, 1] never is. var result = new List<List<int>>(); var path = new List<int>(); void Backtrack(int start) { result.Add([.. path]); // a copy: path keeps changing after this for (int j = start; j < nums.Length; j++) { path.Add(nums[j]); // choose Backtrack(j + 1); // explore path.RemoveAt(path.Count - 1); // undo, so the next branch starts the same } } Backtrack(0); return result; }
public static List<List<int>> Permutations(int[] nums) { // Order matters now, so every position may take any unused number. used replaces start. var result = new List<List<int>>(); var path = new List<int>(); var used = new bool[nums.Length]; void Backtrack() { if (path.Count == nums.Length) { result.Add([.. path]); return; } for (int j = 0; j < nums.Length; j++) { if (used[j]) continue; used[j] = true; path.Add(nums[j]); Backtrack(); path.RemoveAt(path.Count - 1); used[j] = false; } } Backtrack(); return result; }
public static List<List<int>> CombinationSum(int[] candidates, int target) { // Numbers may repeat, so recurse with j, not j + 1. Sorting first lets us prune: once // a candidate is bigger than what's left, every candidate after it is too, so stop. int[] sortedCandidates = [.. candidates.Order()]; var result = new List<List<int>>(); var path = new List<int>(); void Backtrack(int start, int remaining) { if (remaining == 0) { result.Add([.. path]); return; } for (int j = start; j < sortedCandidates.Length && sortedCandidates[j] <= remaining; j++) { path.Add(sortedCandidates[j]); Backtrack(j, remaining - sortedCandidates[j]); path.RemoveAt(path.Count - 1); } } Backtrack(0, target); return result; }
static string Show(List<List<int>> sets) => string.Join(" ", sets.Select(numbers => $"[{string.Join(",", numbers)}]"));
static void Main() { Check(Show(Subsets([1, 2, 3])) == "[] [1] [1,2] [1,2,3] [1,3] [2] [2,3] [3]"); Check(Permutations([1, 2, 3]).Count == 6); Check(Show(Permutations([1, 2])) == "[1,2] [2,1]"); Check(Show(CombinationSum([2, 3, 6, 7], 7)) == "[2,2,3] [7]"); Console.WriteLine("ok: Subsets, Permutations and CombinationSum"); }
static void Check(bool ok) { if (!ok) throw new InvalidOperationException("check failed"); }}package main
import ( "fmt" "reflect" "slices")
func subsets(nums []int) [][]int { // Choose, explore, undo. One shared path; every call records it, then tries each later // number. start keeps each subset in one order, so [1 2] is built but [2 1] never is. var result [][]int var path []int var backtrack func(start int) backtrack = func(start int) { result = append(result, slices.Clone(path)) // a copy: path's array is reused by later appends for j := start; j < len(nums); j++ { path = append(path, nums[j]) // choose backtrack(j + 1) // explore path = path[:len(path)-1] // undo, so the next branch starts from the same path } } backtrack(0) return result}
func permutations(nums []int) [][]int { // Order matters now, so every position may take any unused number. used replaces start. var result [][]int var path []int used := make([]bool, len(nums)) var backtrack func() backtrack = func() { if len(path) == len(nums) { result = append(result, slices.Clone(path)) return } for j, number := range nums { if used[j] { continue } used[j] = true path = append(path, number) backtrack() path = path[:len(path)-1] used[j] = false } } backtrack() return result}
func combinationSum(candidates []int, target int) [][]int { // Numbers may repeat, so recurse with j, not j + 1. Sorting first lets us prune: once a // candidate is bigger than what's left, every candidate after it is too, so stop. sortedCandidates := slices.Sorted(slices.Values(candidates)) var result [][]int var path []int var backtrack func(start, remaining int) backtrack = func(start, remaining int) { if remaining == 0 { result = append(result, slices.Clone(path)) return } for j := start; j < len(sortedCandidates) && sortedCandidates[j] <= remaining; j++ { path = append(path, sortedCandidates[j]) backtrack(j, remaining-sortedCandidates[j]) path = path[:len(path)-1] } } backtrack(0, target) return result}
func main() { check(fmt.Sprint(subsets([]int{1, 2, 3})) == "[[] [1] [1 2] [1 2 3] [1 3] [2] [2 3] [3]]") check(len(permutations([]int{1, 2, 3})) == 6) check(reflect.DeepEqual(permutations([]int{1, 2}), [][]int{{1, 2}, {2, 1}})) check(reflect.DeepEqual(combinationSum([]int{2, 3, 6, 7}, 7), [][]int{{2, 2, 3}, {7}})) fmt.Println("ok: subsets, permutations and combinationSum")}
func check(ok bool) { if !ok { panic("check failed") }}function subsets(nums: number[]): number[][] { // Choose, explore, undo. One shared path; every call records it, then tries each later // number. start keeps each subset in one order, so [1, 2] is built but [2, 1] never is. const result: number[][] = []; const path: number[] = []; const backtrack = (start: number) => { result.push([...path]); // a copy: path keeps changing after this for (let j = start; j < nums.length; j++) { path.push(nums[j]); // choose backtrack(j + 1); // explore path.pop(); // undo, so the next branch starts from the same path } }; backtrack(0); return result;}
function permutations(nums: number[]): number[][] { // Order matters now, so every position may take any unused number. used replaces start. const result: number[][] = []; const path: number[] = []; const used = new Array<boolean>(nums.length).fill(false); const backtrack = () => { if (path.length === nums.length) { result.push([...path]); return; } nums.forEach((number, j) => { if (used[j]) return; used[j] = true; path.push(number); backtrack(); path.pop(); used[j] = false; }); }; backtrack(); return result;}
function combinationSum(candidates: number[], target: number): number[][] { // Numbers may repeat, so recurse with j, not j + 1. Sorting first lets us prune: once a // candidate is bigger than what's left, every candidate after it is too, so stop. Sort // numerically: the default sort compares as strings, so 10 would come before 2. const sortedCandidates = [...candidates].sort((first, second) => first - second); const result: number[][] = []; const path: number[] = []; const backtrack = (start: number, remaining: number) => { if (remaining === 0) { result.push([...path]); return; } for (let j = start; j < sortedCandidates.length && sortedCandidates[j] <= remaining; j++) { path.push(sortedCandidates[j]); backtrack(j, remaining - sortedCandidates[j]); path.pop(); } }; backtrack(0, target); return result;}
const check = (ok: boolean) => { if (!ok) throw new Error("check failed");};check(JSON.stringify(subsets([1, 2, 3])) === "[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]");check(permutations([1, 2, 3]).length === 6);check(JSON.stringify(permutations([1, 2])) === "[[1,2],[2,1]]");check(JSON.stringify(combinationSum([2, 3, 6, 7], 7)) === "[[2,2,3],[7]]");console.log("ok: subsets, permutations and combinationSum");| Problem | Loop over | Record when | Recurse with |
|---|---|---|---|
| Subsets | j from start |
every call | j + 1 |
| Combinations of size k | j from start |
len(path) == k |
j + 1 |
| Combination sum (reuse allowed) | j from start |
remaining == 0 |
j |
| Permutations | every unused j |
len(path) == len(nums) |
mark used[j] |
Skipping duplicates
Section titled “Skipping duplicates”When the input has repeats, like [1, 2, 2], the plain template builds [1, 2] twice, once with
each 2. Sort the input, then in the loop skip an element equal to the one before it at the same
depth: if j > start and nums[j] == nums[j - 1]: continue. The j > start part matters. It
still allows [2, 2], where the second 2 is chosen one level deeper, but stops a second 2 being
tried as the same choice twice.
Pruning
Section titled “Pruning”Pruning is what makes backtracking fast enough. In combination sum, sort the candidates. Once one is
bigger than what’s left, every later one is too. So break out of the loop, instead of trying them.
In N-queens, keep sets of used columns and diagonals (row - column and row + column). Then each
placement is checked in O(1), and impossible rows are dropped at once.
Grids: word search
Section titled “Grids: word search”The choices are the four neighbors, and the path is the cells used so far. Mark a cell when you
step on it (for example, change it to #), and restore it when you return. That restore is the
“undo”, and forgetting it is the most common bug: later searches then think the cell is still in
use.
Complexity
Section titled “Complexity”Backtracking is exponential because the answers are. Subsets of n items: 2ⁿ answers, each up to n long, so O(n × 2ⁿ). Permutations: n! answers, O(n × n!). State the output size and say the search can’t do better than writing out every answer. Memory beyond the output is O(n): one path and a recursion n deep.
There are 2ⁿ subsets, so any solution is at least O(2ⁿ). I’ll do a depth-first search over the choices with one shared path: add an element, recurse on the later ones, then remove it.
Interview questions
Section titled “Interview questions”Interviewer asksWhy loop from start instead of from 0 in subsets?
A subset is a set, so {1, 2} and {2, 1} are the same answer. Only allowing elements after the
last one chosen means every subset is built in exactly one order, increasing by index, so each
appears once. Permutations want every order, so they loop from 0 and skip used elements instead.
Interviewer asksCan you generate subsets without recursion?
Yes, two ways. Iteratively: start with [[]], and for each number, add a copy of every existing
subset with that number appended. Or with bits: for each mask from 0 to 2ⁿ - 1, the subset
contains element i when bit i is set. Both are O(n × 2ⁿ). The recursive version is still the one
to know, because it extends to pruning and constraints and the others don’t.
Interviewer asksN-queens: how do you check a placement in O(1)?
Place one queen per row, so rows never clash. Keep three sets: used columns, used
row - column diagonals and used row + column anti-diagonals. Every square on the same
diagonal shares the same row - column, and every square on the same anti-diagonal shares
row + column. A square is safe when its column and both diagonal values are unused. Add them when placing, remove them when undoing.
Practice
Section titled “Practice”10 problems on LeetCode: 9 medium, 1 hard
- Subsets(opens LeetCode in a new tab)Medium
At each element, branch on take it or skip it.
- Permutations(opens LeetCode in a new tab)Medium
Each level picks one unused element for the next position.
- Combination Sum(opens LeetCode in a new tab)Medium
Pass the start index on so each combination is built in one order.
- Letter Combinations of a Phone Number(opens LeetCode in a new tab)Medium
One level per digit, one branch per letter.
- Generate Parentheses(opens LeetCode in a new tab)Medium
Only add ")" while it would still close an open "(".
- Subsets II(opens LeetCode in a new tab)Medium
Sort, then skip a duplicate at the same depth.
- Combination Sum II(opens LeetCode in a new tab)Medium
Use each number once and skip equal siblings.
- Word Search(opens LeetCode in a new tab)Medium
Mark a cell while it is on the path, and unmark it on the way back.
- Palindrome Partitioning(opens LeetCode in a new tab)Medium
Each branch cuts off one palindromic prefix.
- N-Queens(opens LeetCode in a new tab)Hard
One queen per row; sets of columns and diagonals prune early.
Ticks are saved in this browser only. Clear your site data and they're gone.
- Use it when the problem asks for all solutions, or a constraint puzzle has small inputs.
- The shape: choose, explore, undo, with one shared path.
- Use a start index for sets, and a used array for orderings. Recurse with
jwhen items can repeat. - Duplicates: sort, then skip equal elements at the same depth (
j > start). - Cost: exponential, matching the output size. Prune early to cut whole branches.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.