Part 8 · Lesson 34 · read
Backtracking
Choose, try, then undo. Examples: combination sum, N-Queens, word search.
On this page
- Backtracking = depth-first search plus pruning (cutting off bad branches)
- The template
- Combination sum: reuse allowed
- Traced: combination sum for target 7
- Palindrome partitioning: where to cut
- Word search: backtracking on a grid
- N-Queens: pruning with three Sets
- Why pruning matters, and what it costs
- Practice questions (7)
- Make it stick
- Part 8 complete: what's next
Backtracking = depth-first search plus pruning (cutting off bad branches)
Lesson 33 made every subset or permutation. Backtracking is also called depth-first search on a tree of choices: you follow one branch as deep as it goes before you try the next one. Real problems usually want only the answers that follow a rule (a constraint). For example: numbers that add up to a target, cuts where every piece is a palindrome, or queens that do not attack each other. It would be a waste to walk the whole tree and filter at the end. Backtracking checks the rule while building the answer. As soon as a partial answer cannot work any more, it drops that branch and steps back (“backtracks”) to try the next choice. Cutting off a branch early is called pruning, like cutting a dead branch off a real tree.
There is nothing new in how it works. It is the same choose, explore, un-choose loop from Lesson 33, with one extra line that says “this choice is no good, skip it”.
The template
function backtrack(state) { if (isComplete(state)) { save(state); return; } // 1. a full answer: save it for (const choice of choicesFrom(state)) { if (!isValid(state, choice)) continue; // 2. PRUNE: skip a choice that can never lead to an answer apply(state, choice); // 3. choose backtrack(state); // 4. explore undo(state, choice); // 5. un-choose (undo) }}For any new problem, answer five questions and the code is easy to write. What is the state (the path so far)? When is it complete (then save it)? What are the choices at each step? Which choices are not allowed (prune them)? How do I undo a choice?
Combination sum: reuse allowed
You get different positive numbers and a target. Find every combination that adds up to the target. A number may be used any number of times. The state is the path and the remaining amount. It is complete when remaining === 0. Prune any number that is larger than remaining. Two details make the code correct and fast.
- Make the recursive call with
i, noti + 1, so the same number can be chosen again. But never look backwards (startonly moves forward). Then[2, 3]and[3, 2]are not both made. - Sort first. Then, when one number is too big, you can use
break(stop the whole loop) instead ofcontinue(skip only this number), because all later numbers are bigger.
function combinationSum(candidates, target) { candidates = [...candidates].sort((a, b) => a - b); // sorting lets us stop early const out = [], path = []; function go(start, remaining) { if (remaining === 0) { out.push([...path]); return; } // we reached the target exactly for (let i = start; i < candidates.length; i++) { if (candidates[i] > remaining) break; // prune: this value and every larger value is too big path.push(candidates[i]); go(i, remaining - candidates[i]); // i, not i + 1: the same number may be used again path.pop(); } } go(0, target); return out;}console.log(JSON.stringify(combinationSum([2, 3, 6, 7], 7))); // [[2,2,3],[7]]console.log(JSON.stringify(combinationSum([2, 3, 5], 8))); // [[2,2,2,2],[2,3,3],[3,5]]console.log(JSON.stringify(combinationSum([2], 1))); // []Traced: combination sum for target 7
path holds the numbers chosen so far. We want paths that add up to 7.
1const nums = [2, 3, 6, 7], target = 7;▶const out = [], path = [];3function go(start, remaining) {4 if (remaining === 0) { out.push([...path]); return; }5 for (let i = start; i < nums.length; i++) {6 if (nums[i] > remaining) break;7 path.push(nums[i]);8 go(i, remaining - nums[i]);9 path.pop();10 }11}12go(0, target);13console.log(out);| nums | [2, 3, 6, 7] |
| target | 7 |
| path | [] |
| out | [] |
| Path | remaining | What happens |
|---|---|---|
| [] | 7 | try 2, 3, 6, 7 in turn |
| [2] | 5 | try 2, 3 (6 is too big) |
| [2, 2] | 3 | try 2, 3 (6 is too big) |
| [2, 2, 2] | 1 | 2 > 1: prune, dead end |
| [2, 2, 3] | 0 | save → answer |
| [2, 3] | 2 | 3 > 2: prune, dead end |
| [3] | 4 | try 3 (6 is too big) |
| [3, 3] | 1 | 3 > 1: prune, dead end |
| [6] | 1 | 6 > 1: prune, dead end |
| [7] | 0 | save → answer |
The whole search is just 10 calls (one for each row above). A walk without pruning would make dozens.
Palindrome partitioning: where to cut
Split a string into pieces so that every piece is a palindrome (a word that reads the same forwards and backwards, like “aba”). At each position the choice is how long the next piece is. The pruning rule is to choose only a piece that is a palindrome. When the start reaches the end of the string, every piece so far was valid, so the path is an answer.
function partition(s) { const out = [], path = []; const isPal = (l, r) => { while (l < r) if (s[l++] !== s[r--]) return false; return true; }; function go(start) { if (start === s.length) { out.push([...path]); return; } // we cut the whole string: one answer for (let end = start; end < s.length; end++) { if (!isPal(start, end)) continue; // prune: only cut when the piece is a palindrome path.push(s.slice(start, end + 1)); go(end + 1); path.pop(); } } go(0); return out;}console.log(JSON.stringify(partition("aab"))); // [["a","a","b"],["aa","b"]]Word search: backtracking on a grid
Is a word hidden in a grid of letters? You can move up, down, left or right, and you cannot use a cell twice. From each starting cell, try to match the next letter in each of the four directions. The special part is marking cells as used. When you step on a cell, change it to a placeholder (a temporary marker value, here "#"). When you leave, put the letter back. That is the un-choose step. Stop that path at once when the cell is outside the grid, already used, or the wrong letter.
function exist(board, word) { const R = board.length, C = board[0].length; function dfs(r, c, k) { if (k === word.length) return true; // every letter matched if (r < 0 || c < 0 || r >= R || c >= C || board[r][c] !== word[k]) return false; // prune const saved = board[r][c]; board[r][c] = "#"; // choose: mark this cell as used const found = dfs(r + 1, c, k + 1) || dfs(r - 1, c, k + 1) || dfs(r, c + 1, k + 1) || dfs(r, c - 1, k + 1); board[r][c] = saved; // un-choose: free the cell for other paths return found; } for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) if (dfs(r, c, 0)) return true; return false;}const board = [["A", "B", "C", "E"], ["S", "F", "C", "S"], ["A", "D", "E", "E"]];console.log(exist(board, "ABCCED")); // trueconsole.log(exist(board, "SEE")); // trueconsole.log(exist(board, "ABCB")); // falseN-Queens: pruning with three Sets
Place n queens (chess pieces) on an n × n board so that no queen attacks another. Queens attack along the same row, column or diagonal. Place one queen in each row. The choice in each row is the column. A queen at (row, c) attacks its column c and two diagonals. One diagonal is the cells with the same row − c. The other diagonal is the cells with the same row + c. Keep three Sets (a Set is a collection that keeps each item once and answers “is this item inside?” very quickly), so the question “is this cell attacked?” takes O(1) time (the same short time every time).
function solveNQueens(n) { const out = [], queens = []; // queens[row] = the column of the queen in that row const cols = new Set(), diag1 = new Set(), diag2 = new Set(); function go(row) { if (row === n) { out.push(queens.map((c) => ".".repeat(c) + "Q" + ".".repeat(n - c - 1))); return; } for (let c = 0; c < n; c++) { if (cols.has(c) || diag1.has(row - c) || diag2.has(row + c)) continue; // attacked: prune cols.add(c); diag1.add(row - c); diag2.add(row + c); queens.push(c); go(row + 1); queens.pop(); cols.delete(c); diag1.delete(row - c); diag2.delete(row + c); } } go(0); return out;}console.log(solveNQueens(4).length); // 2console.log(JSON.stringify(solveNQueens(4)[0])); // [".Q..","...Q","Q...","..Q."]console.log(solveNQueens(8).length); // 92Why pruning matters, and what it costs
| Problem | Without pruning | With pruning |
|---|---|---|
| N-Queens, n = 8 | 8⁸ = 16.7 million placements | 2,057 calls (92 answers) |
| combination sum | every sequence of numbers | stops when the sum is bigger than the target |
| word search | every path of any length | stops at the first letter that does not match |
| palindrome partitioning | all 2ⁿ⁻¹ ways to cut | only cuts that make a palindrome |
Pruning does not change the worst-case Big-O (the time in the worst case). It is still exponential, which means the work multiplies by a fixed factor for every extra item. But pruning often makes the real running time many times smaller.
In the worst case, backtracking takes exponential time. That is why these problems have small inputs. Interviewers expect you to say this. They also expect you to point at your pruning rule as the reason your code is fast enough.
Practice questions
For each question, write the five answers (state, complete, choices, prune, undo) as comments before you write the code.
You get different positive integers and a target. Return all different combinations that add up to the target. You may use the same number as many times as you want.
Hint 1
Hint 2
Hint 3
break as soon as a number is bigger than what remains.Show the solution with explanations
This is the algorithm we traced. Choose a number, make the recursive call with the smaller target, then undo.
function combinationSum(candidates, target) { candidates = [...candidates].sort((a, b) => a - b); const out = [], path = []; function go(start, remaining) { if (remaining === 0) { out.push([...path]); return; } for (let i = start; i < candidates.length; i++) { if (candidates[i] > remaining) break; path.push(candidates[i]); go(i, remaining - candidates[i]); path.pop(); } } go(0, target); return out;}console.log(JSON.stringify(combinationSum([2, 3, 6, 7], 7))); // [[2,2,3],[7]]console.log(JSON.stringify(combinationSum([2, 3, 5], 8))); // [[2,2,2,2],[2,3,3],[3,5]]console.log(JSON.stringify(combinationSum([2], 1))); // []The number of combinations can grow exponentially, so the worst case cannot be better. Pruning keeps it fast in real use.
(LeetCode 39.)
You get numbers that may repeat, and a target. Return all different combinations that add up to the target. You may use each element at most once.
Hint 1
i + 1.Hint 2
Show the solution with explanations
Use both ideas together: i + 1 in the recursive call, and i > start && same as previous → continue.
function combinationSum2(candidates, target) { candidates = [...candidates].sort((a, b) => a - b); const out = [], path = []; function go(start, remaining) { if (remaining === 0) { out.push([...path]); return; } for (let i = start; i < candidates.length; i++) { if (candidates[i] > remaining) break; if (i > start && candidates[i] === candidates[i - 1]) continue; path.push(candidates[i]); go(i + 1, remaining - candidates[i]); path.pop(); } } go(0, target); return out;}console.log(JSON.stringify(combinationSum2([10, 1, 2, 7, 6, 1, 5], 8))); // [[1,1,6],[1,2,5],[1,7],[2,6]]console.log(JSON.stringify(combinationSum2([2, 5, 2, 1, 2], 5))); // [[1,2,2],[5]]Without the skip line, you would make [1, 2, 5] twice, once for each 1. Sorting also lets you use the early break.
(LeetCode 40.)
Split a string so that every piece is a palindrome. Return all the possible ways to split it.
Hint 1
Hint 2
Show all 2 solutions with explanations
Try every end position for the current start. Skip pieces that are not palindromes. Make the recursive call from end + 1.
function partition(s) { const out = [], path = []; const isPal = (l, r) => { while (l < r) if (s[l++] !== s[r--]) return false; return true; }; function go(start) { if (start === s.length) { out.push([...path]); return; } for (let end = start; end < s.length; end++) { if (!isPal(start, end)) continue; path.push(s.slice(start, end + 1)); go(end + 1); path.pop(); } } go(0); return out;}console.log(JSON.stringify(partition("aab"))); // [["a","a","b"],["aa","b"]]console.log(JSON.stringify(partition("a"))); // [["a"]]console.log(partition("aaa").length); // 4The worst case is O(n · 2n). A string of one repeated letter makes every cut valid. The palindrome check costs O(n) for each piece.
Build a table pal[i][j] once with dynamic programming (solving small parts first and reusing their answers). Then each check takes O(1).
function partition(s) { const n = s.length; const pal = Array.from({ length: n }, () => new Array(n).fill(false)); for (let i = n - 1; i >= 0; i--) for (let j = i; j < n; j++) pal[i][j] = s[i] === s[j] && (j - i < 2 || pal[i + 1][j - 1]); // the two ends match and the inside is a palindrome const out = [], path = []; function go(start) { if (start === n) { out.push([...path]); return; } for (let end = start; end < n; end++) { if (!pal[start][end]) continue; path.push(s.slice(start, end + 1)); go(end + 1); path.pop(); } } go(0); return out;}console.log(JSON.stringify(partition("aab"))); // [["a","a","b"],["aa","b"]]console.log(partition("aaaa").length); // 8The table costs O(n²) to build. After that, every check is O(1). This is your first look at dynamic programming.
Start with the first one. Then mention the table as a way to make it faster. (LeetCode 131.)
You get a grid of letters and a word. Return true if the word can be spelled with cells that touch each other (left, right, up or down). You may use each cell at most once.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
While a cell is on the current path, write a placeholder in it. Put the letter back afterwards.
function exist(board, word) { const R = board.length, C = board[0].length; function dfs(r, c, k) { if (k === word.length) return true; if (r < 0 || c < 0 || r >= R || c >= C || board[r][c] !== word[k]) return false; const saved = board[r][c]; board[r][c] = "#"; const found = dfs(r + 1, c, k + 1) || dfs(r - 1, c, k + 1) || dfs(r, c + 1, k + 1) || dfs(r, c - 1, k + 1); board[r][c] = saved; return found; } for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) if (dfs(r, c, 0)) return true; return false;}const board = [["A", "B", "C", "E"], ["S", "F", "C", "S"], ["A", "D", "E", "E"]];console.log(exist(board, "ABCCED")); // trueconsole.log(exist(board, "SEE")); // trueconsole.log(exist(board, "ABCB")); // falseThe time is O(R · C · 3L) for a word of length L (three directions after the first step). The stack space is O(L). The || chain stops at the first success. Each cell is put back before the function returns, so the board is not left changed.
If you must not change the input, keep a separate grid of true/false values.
function exist(board, word) { const R = board.length, C = board[0].length; const seen = Array.from({ length: R }, () => new Array(C).fill(false)); function dfs(r, c, k) { if (k === word.length) return true; if (r < 0 || c < 0 || r >= R || c >= C || seen[r][c] || board[r][c] !== word[k]) return false; seen[r][c] = true; const found = dfs(r + 1, c, k + 1) || dfs(r - 1, c, k + 1) || dfs(r, c + 1, k + 1) || dfs(r, c - 1, k + 1); seen[r][c] = false; return found; } for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) if (dfs(r, c, 0)) return true; return false;}console.log(exist([["a", "b"], ["c", "d"]], "abdc")); // trueconsole.log(exist([["a", "b"], ["c", "d"]], "abcd")); // falseIt is the same search, with O(R · C) extra memory for the visited grid.
Marking inside the board is shorter. Mention the visited grid when the interviewer says “don't change the input”. (LeetCode 79.)
Place n queens on an n × n board so that no two queens attack each other. Return every different solution as a list of board rows.
Hint 1
Hint 2
Hint 3
row − col stays the same. Along the other diagonal, row + col stays the same.Show the solution with explanations
Go row by row. Try each column that is not in cols, diag1 (row − col) or diag2 (row + col).
function solveNQueens(n) { const out = [], queens = []; const cols = new Set(), diag1 = new Set(), diag2 = new Set(); function go(row) { if (row === n) { out.push(queens.map((c) => ".".repeat(c) + "Q" + ".".repeat(n - c - 1))); return; } for (let c = 0; c < n; c++) { if (cols.has(c) || diag1.has(row - c) || diag2.has(row + c)) continue; cols.add(c); diag1.add(row - c); diag2.add(row + c); queens.push(c); go(row + 1); queens.pop(); cols.delete(c); diag1.delete(row - c); diag2.delete(row + c); } } go(0); return out;}console.log(JSON.stringify(solveNQueens(4))); // [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]console.log(JSON.stringify(solveNQueens(1))); // [["Q"]]console.log(solveNQueens(6).length); // 4console.log(solveNQueens(8).length); // 92For n = 8 the search makes 2,057 calls. It does not check all 88 placements. The worst case is still exponential, but the Sets make the attack check O(1).
(LeetCode 51. LeetCode's N-Queens II is the same problem, but it only counts the solutions.)
Find all combinations of k different numbers from 1 to 9 that add up to n.
Hint 1
Hint 2
Show the solution with explanations
Loop from start to 9 and make the recursive call with i + 1. Stop early when the number is bigger than the sum that remains.
function combinationSum3(k, n) { const out = [], path = []; function go(start, remaining) { if (path.length === k) { if (remaining === 0) out.push([...path]); return; } for (let i = start; i <= 9; i++) { if (i > remaining) break; // prune: this number alone is already too big path.push(i); go(i + 1, remaining - i); path.pop(); } } go(1, n); return out;}console.log(JSON.stringify(combinationSum3(3, 7))); // [[1,2,4]]console.log(JSON.stringify(combinationSum3(3, 9))); // [[1,2,6],[1,3,5],[2,3,4]]console.log(JSON.stringify(combinationSum3(4, 1))); // []There are at most C(9, k) combinations, so the search is very small. The point is to use the same template with slightly different rules.
(LeetCode 216.)
You get a string of digits. Return every valid IPv4 address (four numbers separated by dots) that you can make by adding three dots. You cannot change the order of the digits or remove any digit.
Hint 1
Hint 2
Hint 3
Show the solution with explanations
Choose a length of 1 to 3 and check that the piece is valid. Then make the recursive call for the next part. Save the answer when four parts use the whole string.
function restoreIpAddresses(s) { const out = [], parts = []; function go(start) { if (parts.length === 4) { if (start === s.length) out.push(parts.join(".")); return; } for (let len = 1; len <= 3 && start + len <= s.length; len++) { const seg = s.slice(start, start + len); if (seg.length > 1 && seg[0] === "0") break; // no zero at the front if (Number(seg) > 255) break; // too big (longer pieces are even bigger) parts.push(seg); go(start + len); parts.pop(); } } go(0); return out;}console.log(JSON.stringify(restoreIpAddresses("25525511135"))); // ["255.255.11.135","255.255.111.35"]console.log(JSON.stringify(restoreIpAddresses("0000"))); // ["0.0.0.0"]console.log(JSON.stringify(restoreIpAddresses("101023"))); // ["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]The search tree has at most 3 choices at each of 4 levels, so at most 81 leaves. The running time is effectively constant, O(1), for any input length that can make an address.
(LeetCode 93.) Here the tree is small. The hard part is to write the validity checks correctly.
| Problem | Choice at each step | Prune when… | Save when… |
|---|---|---|---|
| combination sum | which number (may be used again) | number > remaining | remaining = 0 |
| combination sum II | which number (once, skip repeated values) | number > remaining | remaining = 0 |
| palindrome partitioning | end of the next piece | piece is not a palindrome | start = length |
| word search | one of 4 neighbours | out of bounds / used / wrong letter | all letters matched |
| N-Queens | column in this row | column or diagonal attacked | row = n |
| restore IP | part length 1–3 | leading zero or > 255 | 4 parts use all digits |
Make it stick
- Write the backtracking template from memory and point at the pruning line.
- Explain why combination sum recurses with
ibut combination sum II recurses withi + 1. - Explain how the three Sets in N-Queens save you from scanning the board.
- Explain what goes wrong if you forget to put a cell back in word search.
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
Part 8 complete: what's next
You can now explore decision trees, with rules and without rules. Part 9 uses a new data structure: linked lists. In a linked list, every element points to the next one. The main skill is to change the pointers instead of moving items around in an array.
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series