Backtracking — The Art of Systematic Undoing
Published on June 19, 2026
Backtracking is just DFS on a decision tree — at each node you make a choice, recurse into it, and undo the choice before trying the next one.
The core idea
Every backtracking problem can be modeled as a tree where:
- Each node is a partial state (decisions made so far).
- Each edge is a single choice extending that state.
- Leaves are either valid complete solutions or dead ends.
Backtracking = exhaustive search with early termination. You explore the tree depth-first, and whenever a partial state can’t lead to any valid solution, you prune the branch and backtrack to the parent.
The universal template:
void backtrack(State& state, Choices& available) {
if (is_solution(state)) {
record(state);
return;
}
for (Choice c : available) {
if (!is_valid(state, c)) continue; // prune
apply(state, c); // choose
backtrack(state, available); // explore
undo(state, c); // unchoose
}
}
The critical discipline: every apply must be mirrored by an undo. If you forget to undo, choices from one branch bleed into siblings.
Problem 1 — Subsets (power set)
Generate all subsets of [1, 2, 3, ..., n].
At each index i, make a binary choice: include a[i] or skip it.
vector<vector<int>> result;
void backtrack(vector<int>& a, int i, vector<int>& current) {
result.push_back(current); // every prefix is a valid subset
for (int j = i; j < a.size(); j++) {
current.push_back(a[j]); // choose a[j]
backtrack(a, j + 1, current); // explore subsets starting after j
current.pop_back(); // unchoose a[j]
}
}
vector<vector<int>> subsets(vector<int>& a) {
vector<int> current;
backtrack(a, 0, current);
return result;
}
The loop iterates j from i forward (not from 0) to avoid counting the same subset twice in different orderings. There are \(2^n\) subsets, so time is \(O(2^n \cdot n)\) (copying each subset takes \(O(n)\)).
Problem 2 — Permutations
Generate all permutations of n distinct integers.
The trick: swap element i into position start, recurse on [start+1, n), then swap back.
vector<vector<int>> result;
void backtrack(vector<int>& a, int start) {
if (start == a.size()) {
result.push_back(a);
return;
}
for (int i = start; i < a.size(); i++) {
swap(a[start], a[i]); // place a[i] at position start
backtrack(a, start + 1);
swap(a[start], a[i]); // restore
}
}
vector<vector<int>> permutations(vector<int> a) {
backtrack(a, 0);
return result;
}
There are \(n!\) permutations, so time is \(O(n! \cdot n)\).
Permutations with duplicates: sort first, then skip if a[i] == a[start] and i != start (same value was already placed at this position).
void backtrack(vector<int>& a, int start) {
if (start == a.size()) { result.push_back(a); return; }
unordered_set<int> seen;
for (int i = start; i < a.size(); i++) {
if (seen.count(a[i])) continue; // same value placed here before
seen.insert(a[i]);
swap(a[start], a[i]);
backtrack(a, start + 1);
swap(a[start], a[i]);
}
}
Problem 3 — Combination Sum
Given candidates (distinct positive integers) and a target, find all combinations that sum to target. You may use each number unlimited times.
The pruning key: sort first, then break early when candidates[j] > remaining.
vector<vector<int>> result;
void backtrack(vector<int>& cands, int start, int remaining, vector<int>& current) {
if (remaining == 0) {
result.push_back(current);
return;
}
for (int i = start; i < cands.size(); i++) {
if (cands[i] > remaining) break; // sorted — no point continuing
current.push_back(cands[i]);
backtrack(cands, i, remaining - cands[i], current); // i, not i+1 (reuse allowed)
current.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
vector<int> current;
backtrack(candidates, 0, target, current);
return result;
}
For Combination Sum II (each element used once, may have duplicates): pass i+1 and skip cands[i] == cands[i-1] at the same recursion depth.
for (int i = start; i < cands.size(); i++) {
if (i > start && cands[i] == cands[i-1]) continue; // skip same value at same level
if (cands[i] > remaining) break;
current.push_back(cands[i]);
backtrack(cands, i + 1, remaining - cands[i], current);
current.pop_back();
}
Problem 4 — N-Queens
Place n queens on an n×n board such that no two attack each other.
Constraint: no two queens share a column, row, or diagonal. Since we place one queen per row, rows are automatically distinct. Track forbidden columns and diagonals.
int n;
vector<vector<string>> result;
// col[c] = queen in column c
// diag1[r-c+n] = queen on major diagonal (r-c constant)
// diag2[r+c] = queen on minor diagonal (r+c constant)
vector<bool> col, diag1, diag2;
void backtrack(int row, vector<string>& board) {
if (row == n) {
result.push_back(board);
return;
}
for (int c = 0; c < n; c++) {
if (col[c] || diag1[row - c + n] || diag2[row + c]) continue;
board[row][c] = 'Q';
col[c] = diag1[row - c + n] = diag2[row + c] = true;
backtrack(row + 1, board);
board[row][c] = '.';
col[c] = diag1[row - c + n] = diag2[row + c] = false;
}
}
vector<vector<string>> solveNQueens(int _n) {
n = _n;
col.assign(n, false);
diag1.assign(2 * n, false);
diag2.assign(2 * n, false);
vector<string> board(n, string(n, '.'));
backtrack(0, board);
return result;
}
The diagonal indexing is the key insight:
- Major diagonal:
r - cis constant. Shift bynto avoid negatives:r - c + n. - Minor diagonal:
r + cis constant.
This gives \(O(1)\) conflict checks instead of scanning the board each time.
Problem 5 — Word Search on a grid
Given a 2D character grid and a word, determine whether the word exists as a connected path (horizontal/vertical, no cell reused).
int rows, cols;
vector<vector<char>> grid;
string word;
bool backtrack(int r, int c, int idx) {
if (idx == word.size()) return true;
if (r < 0 || r >= rows || c < 0 || c >= cols) return false;
if (grid[r][c] != word[idx]) return false;
char tmp = grid[r][c];
grid[r][c] = '#'; // mark visited
bool found = backtrack(r+1, c, idx+1)
|| backtrack(r-1, c, idx+1)
|| backtrack(r, c+1, idx+1)
|| backtrack(r, c-1, idx+1);
grid[r][c] = tmp; // restore
return found;
}
bool exist(vector<vector<char>>& g, string w) {
grid = g; word = w;
rows = g.size(); cols = g[0].size();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (backtrack(r, c, 0)) return true;
return false;
}
In-place mutation with restore (grid[r][c] = '#' then restore) avoids a separate visited array. This is a common trick in grid backtracking.
Worst case: \(O(m \cdot n \cdot 4^L)\) where \(L\) is word length — but in practice pruning cuts this heavily.
Problem 6 — Sudoku Solver
Fill a 9×9 grid so each row, column, and 3×3 box contains digits 1–9 exactly once.
bool used_row[9][10] = {}, used_col[9][10] = {}, used_box[9][10] = {};
int box(int r, int c) { return (r / 3) * 3 + c / 3; }
bool solve(vector<vector<char>>& board, int pos) {
while (pos < 81 && board[pos/9][pos%9] != '.') pos++;
if (pos == 81) return true;
int r = pos / 9, c = pos % 9;
for (int d = 1; d <= 9; d++) {
if (used_row[r][d] || used_col[c][d] || used_box[box(r,c)][d]) continue;
board[r][c] = '0' + d;
used_row[r][d] = used_col[c][d] = used_box[box(r,c)][d] = true;
if (solve(board, pos + 1)) return true;
board[r][c] = '.';
used_row[r][d] = used_col[c][d] = used_box[box(r,c)][d] = false;
}
return false;
}
void solveSudoku(vector<vector<char>>& board) {
// initialize constraint sets from pre-filled cells
for (int r = 0; r < 9; r++)
for (int c = 0; c < 9; c++)
if (board[r][c] != '.') {
int d = board[r][c] - '0';
used_row[r][d] = used_col[c][d] = used_box[box(r,c)][d] = true;
}
solve(board, 0);
}
Pre-computing used_row/col/box makes each candidate check \(O(1)\). Without this, you’d scan the row/column/box for each digit — much slower.
Problem 7 — Letter Combinations of a Phone Number
You’re given a sequence of digit presses on an old T9 phone keypad (digits 2–9). Each digit maps to 2–4 letters. Return every possible string that could have been typed.
2 → abc 3 → def 4 → ghi 5 → jkl
6 → mno 7 → pqrs 8 → tuv 9 → wxyz
Pressing 2 then 3 can produce any of: ad, ae, af, bd, be, bf, cd, ce, cf.
This is a clean illustration of the backtracking template — the state is the combination being built, and the choices at each position are the letters mapped to that digit.
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
choices = {
"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
"6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
}
n = len(digits)
ret = []
def solve(pos, cur):
if pos >= n:
ret.append(''.join(cur))
return
for ch in choices[digits[pos]]:
cur.append(ch) # choose
solve(pos + 1, cur) # explore
cur.pop() # unchoose
solve(0, [])
return ret
The decision tree has depth n (one level per digit) and branching factor 3 or 4 (letters per key). Total leaves = total combinations = product of letters per digit, so time is \(O(4^n \cdot n)\) in the worst case (all 9s and 7s, which map to 4 letters).
What makes this a good first backtracking problem: there is no pruning needed — every path from root to leaf is valid. It isolates the choose/explore/unchoose rhythm without the distraction of constraint checking.
Problem 8 — Generate Parentheses
Given n pairs of parentheses, generate every string of length 2n that is a valid bracket sequence. For n = 3 there are 5 such strings: ((())), (()()), (())(), ()(()), ()()().
The string has 2n positions. At each position you choose ( or ). The trick is knowing when ) is legal — you need to track how many unmatched opens you have so far (cur):
- You can always add
(, which incrementscur. - You can only add
)whencur > 0(there’s an open bracket to close). - At position
2n, the string is valid iffcur == 0(all brackets matched).
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
ret = []
def solve(pos, res, cur):
if pos >= 2 * n:
if cur == 0: # all brackets matched
ret.append(''.join(res))
return
for ch in '()':
if ch == '(':
res.append(ch)
solve(pos + 1, res, cur + 1) # one more unmatched open
res.pop()
elif ch == ')' and cur > 0: # prune: can't close what isn't open
res.append(ch)
solve(pos + 1, res, cur - 1)
res.pop()
solve(0, [], 0)
return ret
The pruning cur > 0 is what prevents invalid sequences — it cuts every branch that would produce a ) with nothing to close. This eliminates the need to validate at the leaf.
Compare this to Letter Combinations (Problem 7): there, every path was valid and we only checked at the leaf. Here, the constraint (cur > 0) is checked mid-path, halving the branches at each ) decision.
Time complexity: \(O(4^n / \sqrt{n})\) — the number of valid sequences is the \(n\)-th Catalan number \(C_n = \frac{1}{n+1}\binom{2n}{n}\), which grows as \(O(4^n / n^{3/2})\). Each sequence takes \(O(n)\) to copy, giving the total.
Recognizing when to prune
Backtracking without pruning is just brute force. The speedup comes entirely from cutting branches early. Ask:
- Can this partial state ever reach a valid solution? If not, return immediately.
- Is the current cost/sum already beyond the target? Break (especially if candidates are sorted).
- Is a constraint already violated? Skip before recursing.
Patterns summary
| Problem | Choice at each step | Key pruning |
|---|---|---|
| Subsets | include or skip a[i] |
iterate j from i forward |
| Permutations | place which element at start |
skip duplicates at same depth |
| Combination Sum | pick a[i] (reusable) or move on |
a[i] > remaining → break |
| N-Queens | place queen in which column for this row | bitmask/boolean for col, diag |
| Word Search | extend path in 4 directions | cell mismatch or already visited |
| Sudoku | which digit fills this cell | row/col/box constraint sets |
| Letter Combinations | which letter maps to this digit | none — all paths are valid |
| Generate Parentheses | add ( or ) at each position |
) only when cur > 0 |
The mental checklist:
- What is the partial state?
- What are the choices at each step?
- What makes a choice invalid (prune)?
- What makes the state complete (record)?
- Does undo fully restore the state?
Tags: competitive-programming, backtracking, recursion, dfs