Build each answer one choice at a time. After each choice, explore everything that can follow it. Then undo the choice and try the next one. If you do this for every choice, you visit every possible answer exactly once.
Think of it like this: walking through a maze with a ball of string. At every fork you pick a corridor and unroll string behind you. When you reach a dead end, or the exit, you wind the string back to the last fork. Then you try a corridor you haven't tried yet. The string is your path, and winding it back is the undo.
How to spot it
Words and constraints in a problem that point to this pattern:
- “Return all possible …”: all subsets, all orderings, every combination that adds up to a target
- Very small limits on the input: “
n ≤ 10”, “the board is at most 6 × 6”, “the word has at most 12 letters” - A rule about reuse: “each number may be used once” or “may be used any number of times”
- “Does a path exist … without using the same cell twice?”. You try a path, and you give the cells back when it fails
- The number of answers grows very fast as the input grows, like 2ⁿ or n!. Both are explained below
Watch it work
This lesson builds directly on the recursion lesson. Here are the words you'll need:
- A choice is one decision, such as “take the letter a”.
- The path is the list of choices made so far, for example
["a"]. It's an ordinary array that grows and shrinks. - The decision tree draws every possible sequence of choices. Each box is a path. The root at the top is the empty path. Each line down is one more choice. A box with no lines below it is a leaf.
- Backtracking walks this tree depth-first. Depth-first means you go down one branch as far as it goes, then come back up and try the next branch. The call stack from the recursion lesson remembers where you were.
Every step down has three moves:
- choose: push the choice onto the path
- explore: call yourself to make the next choice
- un-choose: pop the choice off again
The trace finds every way to pick 2 letters from "abc", keeping them in their original order. In the tree, > marks where the code is now. ✓ marks a saved answer, and ✗ marks a dead end. We don't want [b, a] as well as [a, b]. So each pick may only use letters to the right of the previous pick. A number called start tracks the first letter still allowed.
The template
The general shape is a function, here called backtrack(path, start). It does two things:
- It checks whether the path is a complete answer. If it is, it saves a copy and returns. This is the base case.
- Otherwise, it loops over the choices still allowed. For each one it does choose → explore → un-choose.
Why save a copy? results.push(path) stores a reference to the same array, not a snapshot. A reference is like a link to the array. Every later push and pop changes what every saved link points to. When the search ends, the path is empty, so every saved answer would read []. [...path] makes a new array with the current contents.
Pruning is a check that skips a choice when it can't possibly lead to an answer. It doesn't change the result. It only stops the code from exploring branches that can never succeed.
The code below picks every group of k items, as in the trace. To adapt it:
- every subset: save at every call, not only when the path is full
- every ordering: loop from 0 every time and skip items already in the path, instead of using
start - items can be reused: explore with
iinstead ofi + 1
// Every way to choose k items from items, keeping their original order.
function chooseK<T>(items: T[], k: number): T[][] {
const results: T[][] = []; // every finished answer goes here
// path: the choices made so far. start: the first index we may still pick.
function backtrack(path: T[], start: number): void {
if (path.length === k) { // base case: the path is a complete answer
results.push([...path]); // save a COPY: path keeps changing after this
return; // a full path can't grow any further
}
for (let i = start; i < items.length; i++) {
const left = items.length - i; // how many items remain, counting items[i]
if (path.length + left < k) break; // prune: even taking all of them can't reach k
path.push(items[i]); // 1. choose
backtrack(path, i + 1); // 2. explore: later picks come after i, so no repeats
path.pop(); // 3. un-choose: undo, ready to try the next i
}
}
backtrack([], 0); // start with nothing chosen, from the first item
return results;
}
chooseK(["a", "b", "c"], 2); // [["a", "b"], ["a", "c"], ["b", "c"]]
Worked example
Balanced brackets: given n, return every string of n opening and n closing round brackets where the brackets are balanced. Balanced means two things. Reading left to right, you never close more brackets than you've opened. And every opened bracket gets closed. For n = 3 there are 5: ((())), (()()), (())(), ()(()) and ()()().
The first idea is to build every string of length
2nmade of(and), then keep the balanced ones. Each of the 6 positions has 2 options. So forn = 3that's 2 × 2 × 2 × 2 × 2 × 2 = 64 strings to check, and only 5 survive. Forn = 10it's over a million strings for 16,796 answers.Notice the waste. Half of those 64 strings start with
). A string that starts by closing a bracket that was never opened can't be fixed by anything after it. But the brute force still builds all 32 of them in full before throwing them away. If we build the string one character at a time, we can stop the moment it goes wrong.That's backtracking. The choice at each step is “add
(” or “add)”. Two rules prune the bad branches before they start. Rule 1: you may add(only while fewer thannhave been opened. Rule 2: you may add)only while fewer have been closed than opened. If you obey both rules, every path that reaches length2nis balanced. So there's nothing left to check at the end.Write it with the template's shape. Count the opened and closed brackets, so the rules are quick to test. Each branch is choose, explore, un-choose. Joining the path makes a new string, so saving it is already a copy.
function balancedBrackets(n: number): string[] { const results: string[] = []; function backtrack(path: string[], open: number, close: number): void { if (path.length === 2 * n) { // complete: the rules guarantee it's balanced results.push(path.join("")); // join builds a new string: a copy return; } if (open < n) { // rule 1: there's still a bracket left to open path.push("("); // choose backtrack(path, open + 1, close); // explore path.pop(); // un-choose } if (close < open) { // rule 2: something is open that we can close path.push(")"); backtrack(path, open, close + 1); path.pop(); } } backtrack([], 0, 0); return results; } balancedBrackets(3); // ["((()))", "(()())", "(())()", "()(())", "()()()"]Check it. For
n = 3it returns the 5 strings above. Forn = 1the only path is(then), giving["()"]. Forn = 0the empty path is already complete, so it returns[""], one empty string. No path ever starts with), because rule 2 needsclose < openand both start at 0. Those 32 strings are never built.
Common mistakes
- Saving
pathinstead of[...path]. Every saved answer is then the same array. After the last pop they all show[], so you get[[], [], []]. - Forgetting
path.pop()after the recursive call. The path keeps every old choice. So later answers come out too long, like[a, b, c]when you wanted 2 letters, or the base case is never reached. - Exploring with
startor 0 instead ofi + 1when each item can be used once. You get[a, a]. You also get both[a, b]and[b, a], as if they were different answers. - Marking something as used, like a grid cell or a
used[i]flag, and not unmarking it on the way back. Later paths think it's still taken, so valid answers go missing. - Pruning with
breakon input that isn't sorted. “This number is too big, so all the rest are too” is only true after sorting. Without sorting, answers disappear and nothing warns you.
Cost
Backtracking visits every box in the decision tree, so the cost is the size of the tree. That size grows exponentially: each extra item multiplies the work instead of adding to it.
- Subsets: each of the n items has 2 choices, in or out. That gives 2ⁿ paths. 10 items give 1,024, and 20 items give over a million.
- Orderings: n items have n! paths. n! means n × (n − 1) × … × 1, the factorial from the recursion lesson. 10 items give 3,628,800.
Saving an answer copies up to n items. So subsets cost O(n × 2ⁿ) and orderings cost O(n × n!). That's why these prompts promise very small inputs like n ≤ 10. An exponential answer is expected. Pruning only makes it faster in practice.
Space is the path plus the call stack, and both are at most n deep. So space is O(n), not counting the results.
In interviews
Say the plan out loud: “I'll build each answer one choice at a time. After exploring a choice I'll undo it and try the next one. I'll save a copy whenever the path is complete.” Draw the decision tree for a 3-item input before you code. It shows the base case and the loop at the same time. Interviewers usually ask for one of these:
- subsets
- orderings
- combinations that add up to a target
- a word hidden in a grid
Then they ask for the time complexity. Answer with 2ⁿ or n!. Point out that the small limit on n is what makes this acceptable.