Backtracking: Practice Problems with Full Solutions
Welcome to the practice problems for backtracking. If you need a refresher on the code, the code templates have the patterns in all six languages. Each problem below includes a hint, a visual walkthrough, and the full solution.
Recommended Study Order
The problems are ordered by difficulty, but the progression matters as much as the individual solutions.
- Letter Case Permutation introduces the choice-at-each-step pattern in its simplest form. Master this before adding complexity like tracking used elements or navigating grids.
- Subsets teaches the start index pattern that prevents duplicate permutations when order does not matter.
- Permutations adds the used array, which is necessary when every element must be used exactly once.
- Combinations layers a depth constraint on top of the subset pattern.
- Word Search extends backtracking to two dimensions with temporary state modification.
- Generate Parentheses demonstrates constraint-based pruning with multiple counters.
- Sudoku Solver combines multiple constraint checks into a single search on a 2D grid.
- N-Queens II introduces diagonal constraint tracking using sets or boolean arrays.
Easy
1. Letter Case Permutation
LeetCode 784 | Difficulty: Easy
- Brief: Given a string, return all possible strings you can create by transforming each letter individually to lowercase or uppercase.
- Why this pattern?: Each character position offers a binary choice: leave digits as they are, or flip a letter between lowercase and uppercase. This is the simplest form of backtracking where every position has a fixed set of alternatives.
- Key Insight: Digits are deterministic, so they just extend the string without branching. Letters create exactly two branches, which makes this a clean binary decision tree.
Visual:
graph TD
Root["'a1b'"] --> L["'a1b'"]
Root --> U["'A1b'"]
L --> LL["'a1b'"]
L --> LU["'a1B'"]
U --> UL["'A1b'"]
U --> UU["'A1B'"]
Code:
var letterCasePermutation = function(s) {
const result = [];
function backtrack(i, current) {
if (i === s.length) {
result.push(current);
return;
}
const char = s[i];
if (/\d/.test(char)) {
backtrack(i + 1, current + char);
} else {
backtrack(i + 1, current + char.toLowerCase());
backtrack(i + 1, current + char.toUpperCase());
}
}
backtrack(0, "");
return result;
};The decision tree has at most 2^L leaves where L is the number of letters, so the time complexity is
Medium
2. Permutations
LeetCode 46 | Difficulty: Medium
- Brief: Given an array of distinct integers, return all possible permutations in any order.
- Why this pattern?: You need to explore every possible ordering. Since you must use every element exactly once, this is a classic ordering backtracking problem.
- Key Insight: Use a
usedarray or hash set to keep track of elements already in your current path so you do not pick the same number twice.
Visual:
graph TD
Root["[]"] --> A["[1]"]
Root --> B["[2]"]
Root --> C["[3]"]
A --> A1["[1,2]"]
A --> A2["[1,3]"]
A1 --> A11["[1,2,3]"]
A2 --> A21["[1,3,2]"]
style Root fill:#f9f,stroke:#333,stroke-width:2px
Code:
function permute(nums) {
const result = [];
const used = new Array(nums.length).fill(false);
function backtrack(current) {
if (current.length === nums.length) {
result.push([...current]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true;
current.push(nums[i]);
backtrack(current);
current.pop();
used[i] = false;
}
}
backtrack([]);
return result;
}3. Subsets
LeetCode 78 | Difficulty: Medium
- Brief: Given an array of unique elements, return all possible subsets (the power set).
- Why this pattern?: Every element has two choices: either it is included in the current subset or it is excluded.
- Key Insight: To avoid duplicate sets when order does not matter, always iterate from a
startindex and only look forward.
Visual:
graph TD
Root["[]"] --> A["[1]"]
Root --> B["[2]"]
Root --> C["[3]"]
A --> AB["[1,2]"]
A --> AC["[1,3]"]
AB --> ABC["[1,2,3]"]
B --> BC["[2,3]"]
Code:
function subsets(nums) {
const result = [];
function backtrack(start, current) {
result.push([...current]);
for (let i = start; i < nums.length; i++) {
current.push(nums[i]);
backtrack(i + 1, current);
current.pop();
}
}
backtrack(0, []);
return result;
}4. Combinations
LeetCode 77 | Difficulty: Medium
- Brief: Given two integers
nandk, return all possible combinations ofknumbers out of1 ... n. - Why this pattern?: This is a direct subset problem with a depth constraint that stops at length
k. - Key Insight: You can prune branches earlier if there are not enough remaining numbers to reach length
k.
Visual:
graph TD
Root["[]"] --> A["[1]"]
Root --> B["[2]"]
Root --> C["[3]"]
A --> AB["[1,2]"]
A --> AC["[1,3]"]
B --> BC["[2,3]"]
style AB fill:#f96,stroke:#333,stroke-width:2px
style AC fill:#f96,stroke:#333,stroke-width:2px
style BC fill:#f96,stroke:#333,stroke-width:2px
Code:
function combine(n, k) {
const result = [];
function backtrack(start, current) {
if (current.length === k) {
result.push([...current]);
return;
}
for (let i = start; i <= n; i++) {
current.push(i);
backtrack(i + 1, current);
current.pop();
}
}
backtrack(1, []);
return result;
}5. Word Search
LeetCode 79 | Difficulty: Medium
- Brief: Given an
m x ngrid of characters and aword, returntrueif the word exists in the grid. - Why this pattern?: You are exploring a 2D plane. Since you cannot reuse the same cell in a single word path, you must mark cells as visited for the duration of the current search and unmark them when you backtrack.
- Key Insight: Modifying the original grid (like replacing a character with
#) is a space-efficient way to track visited cells without an extravisitedmatrix.
Visual:
graph LR
Cell["(r, c)"] --> Up["(r-1, c)"]
Cell --> Down["(r+1, c)"]
Cell --> Left["(r, c-1)"]
Cell --> Right["(r, c+1)"]
style Cell fill:#ccf,stroke:#333
Code:
function exist(board, word) {
const m = board.length, n = board[0].length;
function dfs(r, c, i) {
if (i === word.length) return true;
if (r < 0 || c < 0 || r >= m || c >= n || board[r][c] !== word[i]) return false;
const temp = board[r][c];
board[r][c] = '#';
if (dfs(r+1, c, i+1) || dfs(r-1, c, i+1) || dfs(r, c+1, i+1) || dfs(r, c-1, i+1)) {
return true;
}
board[r][c] = temp;
return false;
}
for (let r = 0; r < m; r++) {
for (let c = 0; c < n; c++) {
if (dfs(r, c, 0)) return true;
}
}
return false;
}6. Generate Parentheses
LeetCode 22 | Difficulty: Medium
- Brief: Given
npairs of parentheses, generate all combinations of well-formed parentheses. - Why this pattern?: You are building a string character by character. At each position, you can choose
(or). - Key Insight: The search space is pruned by two rules. Only add
(if currentopen_count < n. Only add)if currentclose_count < open_count.
Visual:
graph TD
Start["'' (0,0)"] --> L["'(' (1,0)"]
L --> LL["'((' (2,0)"]
L --> LR["'()' (1,1)"]
LL --> LLR["'(()' (2,1)"]
style Start fill:#eee
Code:
function generateParenthesis(n) {
const result = [];
function backtrack(s, open, close) {
if (s.length === 2 * n) {
result.push(s);
return;
}
if (open < n) backtrack(s + "(", open + 1, close);
if (close < open) backtrack(s + ")", open, close + 1);
}
backtrack("", 0, 0);
return result;
}Hard
7. Sudoku Solver
LeetCode 37 | Difficulty: Hard
- Brief: Write a program to solve a Sudoku puzzle by filling the empty cells.
- Why this pattern?: The number of possible configurations is astronomical. Backtracking lets you fill one cell, check if it is currently valid, and only then proceed to the next empty cell.
- Key Insight: Upon finding an empty cell (
.), try every digit from1-9. If a digit leads to a solution, returntrue. Otherwise, reset to.and try the next digit.
Visual:
graph TD
A[Empty Cell] --> B[Try 1]
B -- Valid --> C[Next Cell]
B -- Invalid --> D[Try 2]
C -- No path --> B
Code:
function solveSudoku(board) {
function isValid(row, col, char) {
for (let i = 0; i < 9; i++) {
if (board[row][i] === char) return false;
if (board[i][col] === char) return false;
const blockRow = 3 * Math.floor(row / 3) + Math.floor(i / 3);
const blockCol = 3 * Math.floor(col / 3) + (i % 3);
if (board[blockRow][blockCol] === char) return false;
}
return true;
}
function solve() {
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (board[r][c] === '.') {
for (let n = 1; n <= 9; n++) {
const char = n.toString();
if (isValid(r, c, char)) {
board[r][c] = char;
if (solve()) return true;
board[r][c] = '.';
}
}
return false;
}
}
}
return true;
}
solve();
}8. N-Queens II
LeetCode 52 | Difficulty: Hard
- Brief: Return the number of distinct solutions to the N-Queens puzzle.
- Why this pattern?: You must place
nqueens such that no two queens attack each other. This requires checking row, column, and diagonal constraints at every step. - Key Insight: While rows are handled by recursion level, you can use sets or boolean arrays to track which columns and diagonals are already occupied.
- Columns: index
c. - Diagonals (positive slope):
r + c. - Diagonals (negative slope):
r - c.
- Columns: index
Visual:
graph TD
Row0[Row 0] --> Q0["Q at (0,0)"]
Q0 --> Row1[Row 1]
Row1 --> Q1["Q at (1,2)"]
Row1 --> Q2["Q at (1,3)"]
style Row0 fill:#fcf
Code:
function totalNQueens(n) {
let count = 0;
const cols = new Set(), diag1 = new Set(), diag2 = new Set();
function backtrack(row) {
if (row === n) {
count++;
return;
}
for (let col = 0; col < n; col++) {
if (cols.has(col) || diag1.has(row + col) || diag2.has(row - col)) continue;
cols.add(col);
diag1.add(row + col);
diag2.add(row - col);
backtrack(row + 1);
cols.delete(col);
diag1.delete(row + col);
diag2.delete(row - col);
}
}
backtrack(0);
return count;
}These eight problems cover the full range of backtracking techniques. Start with the basic choice pattern in Letter Case Permutation, work through the combinatorial problems, and finish with the complex constraint satisfaction in Sudoku and N-Queens. By the end, you should be able to recognize when backtracking applies and reach for the right template.