Backtracking Problems
Introduction to Backtracking
Backtracking is a powerful algorithmic technique used to find all (or some) solutions to computational problems, particularly those that require exploring all potential candidates for the solution. The approach works by incrementally building candidates to the solutions, and abandoning a candidate ("backtracking") as soon as it determines that the candidate cannot possibly be completed to a valid solution.
Think of backtracking like exploring a maze with many paths - if you reach a dead end, you go back to the previous junction and try another path. This systematic exploration ensures that all possibilities are considered.
When to Use Backtracking?
Backtracking is particularly useful for problems that exhibit these characteristics:
- Choice-based problems: When we need to make a series of decisions, each with multiple options
- Constraint satisfaction: When solutions must meet certain criteria
- Combinatorial problems: Finding all possible combinations or permutations
- Complete search: When we need to search through all possibilities
The Backtracking Framework
Every backtracking algorithm follows a similar structure:
function backtrack(currentState, choices):
if isGoalReached(currentState):
addToResults(currentState)
return
if isInvalidState(currentState):
return
for choice in choices:
make(choice)
backtrack(newState, remainingChoices)
unmake(choice) // backtrack step
The key elements that make backtracking work are:
- State building: Incrementally constructing a potential solution
- Constraint checking: Verifying if the current state violates any constraints
- Backtracking: Undoing choices to explore different paths
- Base cases: Determining when to stop (either we've found a solution or hit a dead end)
Classic Backtracking Problems on LeetCode
1. Generating Permutations (LeetCode #46)
Let's implement a solution to generate all permutations of a given array of distinct integers.
/**
* @param {number[]} nums
* @return {number[][]}
*/
const permute = function(nums) {
const result = [];
function backtrack(current, remaining) {
// Base case: if no numbers remain, we have a complete permutation
if (remaining.length === 0) {
result.push([...current]);
return;
}
// Try each remaining number
for (let i = 0; i < remaining.length; i++) {
// Choose
current.push(remaining[i]);
const newRemaining = [...remaining.slice(0, i), ...remaining.slice(i + 1)];
// Explore
backtrack(current, newRemaining);
// Un-choose (backtrack)
current.pop();
}
}
backtrack([], nums);
return result;
};
Example:
Input: [1, 2, 3]
Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
Explanation:
- We start with an empty array
[]
as our current permutation and[1,2,3]
as remaining numbers - For each step, we try each remaining number, add it to our current permutation, and recursively generate all permutations with the remaining numbers
- After exploring all possibilities with a particular number in a position, we backtrack (remove the number) and try other combinations
2. Generating Subsets (LeetCode #78)
The subsets problem asks us to return all possible subsets of a given array.
/**
* @param {number[]} nums
* @return {number[][]}
*/
const subsets = function(nums) {
const result = [];
function backtrack(start, current) {
// Add the current subset to our results
result.push([...current]);
// Try adding each remaining number
for (let i = start; i < nums.length; i++) {
// Choose
current.push(nums[i]);
// Explore
backtrack(i + 1, current);
// Un-choose (backtrack)
current.pop();
}
}
backtrack(0, []);
return result;
};
Example:
Input: [1, 2, 3]
Output: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
Explanation:
- We start with an empty subset
[]
- For each position, we have two choices: include the current number or skip it
- We build our subsets incrementally, ensuring we don't create duplicates by only considering elements after our current position
3. N-Queens Problem (LeetCode #51)
The N-Queens problem asks us to place N queens on an N×N chessboard such that no two queens attack each other.
/**
* @param {number} n
* @return {string[][]}
*/
const solveNQueens = function(n) {
const result = [];
// Function to check if a queen can be placed at (row, col)
function isValid(board, row, col) {
// Check column
for (let i = 0; i < row; i++) {
if (board[i][col] === 'Q') return false;
}
// Check upper left diagonal
for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (board[i][j] === 'Q') return false;
}
// Check upper right diagonal
for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (board[i][j] === 'Q') return false;
}
return true;
}
function backtrack(row, board) {
// Base case: If all queens are placed, add the solution
if (row === n) {
const solution = board.map(row => row.join(''));
result.push(solution);
return;
}
// Try placing queen in each column of current row
for (let col = 0; col < n; col++) {
if (isValid(board, row, col)) {
// Choose
board[row][col] = 'Q';
// Explore
backtrack(row + 1, board);
// Un-choose (backtrack)
board[row][col] = '.';
}
}
}
// Initialize empty board
const board = Array(n).fill().map(() => Array(n).fill('.'));
backtrack(0, board);
return result;
};
Example:
Input: 4
Output:
[
[".Q..",
"...Q",
"Q...",
"..Q."],
["..Q.",
"Q...",
"...Q",
".Q.."]
]
Explanation:
- We build the solution row by row, trying to place a queen in each column
- For each position, we check if placing a queen violates any constraints (same column, diagonal)
- If a position is valid, we place the queen and move to the next row
- If we reach the end, we've found a valid solution; otherwise, we backtrack and try other positions
Pruning: Optimizing Backtracking
Backtracking can be computationally expensive as it explores many possibilities. Pruning is a technique to avoid exploring paths that will not lead to valid solutions:
- Early stopping: Check constraints before making recursive calls
- Memoization: Remember previously computed results to avoid duplicate work
- Constraint propagation: Use constraints to reduce the search space
Word Search Example (LeetCode #79)
The Word Search problem asks if a word exists in a 2D board of characters.
/**
* @param {character[][]} board
* @param {string} word
* @return {boolean}
*/
const exist = function(board, word) {
if (!board.length || !board[0].length) return false;
const rows = board.length;
const cols = board[0].length;
function dfs(r, c, index) {
// Base cases
if (index === word.length) return true;
if (r < 0 || r >= rows || c < 0 || c >= cols || board[r][c] !== word[index]) return false;
// Mark as visited
const temp = board[r][c];
board[r][c] = '#';
// Explore in all four directions
const found = dfs(r + 1, c, index + 1) ||
dfs(r - 1, c, index + 1) ||
dfs(r, c + 1, index + 1) ||
dfs(r, c - 1, index + 1);
// Backtrack
board[r][c] = temp;
return found;
}
// Try each cell as a starting point
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (dfs(r, c, 0)) return true;
}
}
return false;
};
Example:
Input:
- board =
[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]
- word =
"ABCCED"
Output: true
Explanation:
- We perform a depth-first search from each cell in the board
- At each step, we mark the current cell as visited to avoid re-using it
- We explore all possible directions (up, down, left, right) to find the next character
- Once we've explored all options from a cell, we backtrack by unmarking it as visited
Real-world Applications
Backtracking is widely used in real-world applications:
- Puzzle solvers: Sudoku solvers, crossword puzzle generators
- Path finding: Navigation systems finding all possible routes
- Game playing: Chess engines exploring possible moves (with additional optimizations)
- Constraint satisfaction problems: Scheduling systems, resource allocation
- Parsing: Compiler construction and regular expression matching
Common Patterns in Backtracking Problems
When approaching backtracking problems, look for these patterns:
- Choosing and unchoosing: Always undo what you did before backtracking
- State tracking: Keep track of your current state (often using recursion parameters)
- Constraint checking: Validate the current state before continuing
- Base cases: Define clear conditions for when to stop recursion
Visualizing Backtracking with Decision Trees
Backtracking is often visualized as traversing a decision tree:
The colored nodes represent valid solutions found through backtracking.
Summary
Backtracking is a powerful technique for solving problems that require exploring multiple possible solutions. The key concepts to remember are:
- Incrementally build solutions: Start with an empty solution and add choices one by one
- Explore all valid choices: Try all possibilities that satisfy constraints
- Backtrack when needed: When you reach a dead end, undo your last choice and try another path
- Pruning: Optimize by avoiding paths that won't lead to solutions
By mastering backtracking, you'll be equipped to solve a wide variety of combinatorial and constraint-based problems efficiently.
Practice Problems
To strengthen your backtracking skills, try these LeetCode problems:
- Letter Combinations of a Phone Number (LeetCode #17) - Medium
- Combination Sum (LeetCode #39) - Medium
- Palindrome Partitioning (LeetCode #131) - Medium
- Sudoku Solver (LeetCode #37) - Hard
- Word Break II (LeetCode #140) - Hard
Additional Resources
- Introduction to Algorithms - CLRS textbook has excellent coverage of backtracking
- Algorithms, 4th Edition by Robert Sedgewick and Kevin Wayne
- Backtracking on GeeksforGeeks
Happy coding, and remember - when stuck, sometimes you need to take a step back to move forward!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)