Backtracking Exploration Patterns: Complete Reference¶
API Kernel:
BacktrackingExploration
Core Mechanism: Systematically explore all candidate solutions by building them incrementally, abandoning paths that violate constraints (pruning), and undoing choices to try alternatives.
This document presents the canonical backtracking template and all its major variations. Each implementation follows consistent naming conventions and includes detailed algorithmic explanations.
Table of Contents¶
- Core Concepts
- Base Template: Permutations (LeetCode 46)
- Variation: Permutations with Duplicates (LeetCode 47)
- Variation: Subsets (LeetCode 78)
- Variation: Subsets with Duplicates (LeetCode 90)
- Variation: Combinations (LeetCode 77)
- Variation: Combination Sum (LeetCode 39)
- Variation: Combination Sum II (LeetCode 40)
- Variation: Combination Sum III (LeetCode 216)
- Variation: N-Queens (LeetCode 51/52)
- Variation: Palindrome Partitioning (LeetCode 131)
- Variation: Restore IP Addresses (LeetCode 93)
- Variation: Word Search (LeetCode 79)
- Deduplication Strategies
- Pruning Techniques
- Pattern Comparison Table
- When to Use Backtracking
- LeetCode Problem Mapping
- Template Quick Reference
1. Core Concepts¶
1.1 What is Backtracking?¶
Backtracking is a systematic trial-and-error approach that incrementally builds candidates to the solutions and abandons a candidate ("backtracks") as soon as it determines that the candidate cannot lead to a valid solution.
Decision Tree Visualization:
[]
ββββββββββΌβββββββββ
[1] [2] [3]
ββββ΄βββ ββββ΄βββ ββββ΄βββ
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
β β β β β β
[1,2,3] ... (continue building)
β
SOLUTION FOUND β collect and backtrack
1.2 The Three-Step Pattern: Choose β Explore β Unchoose¶
Every backtracking algorithm follows this fundamental pattern:
def backtrack(state, choices):
"""
Core backtracking template.
1. BASE CASE: Check if current state is a complete solution
2. RECURSIVE CASE: For each available choice:
a) CHOOSE: Make a choice and update state
b) EXPLORE: Recursively explore with updated state
c) UNCHOOSE: Undo the choice (backtrack)
"""
# BASE CASE: Is this a complete solution?
if is_solution(state):
collect_solution(state)
return
# RECURSIVE CASE: Try each choice
for choice in get_available_choices(state, choices):
# CHOOSE: Make this choice
apply_choice(state, choice)
# EXPLORE: Recurse with updated state
backtrack(state, remaining_choices(choices, choice))
# UNCHOOSE: Undo the choice (restore state)
undo_choice(state, choice)
1.3 Key Invariants¶
| Invariant | Description |
|---|---|
| State Consistency | After backtracking, state must be exactly as before the choice was made |
| Exhaustive Exploration | Every valid solution must be reachable through some path |
| Pruning Soundness | Pruned branches must not contain any valid solutions |
| No Duplicates | Each unique solution must be generated exactly once |
1.4 Time Complexity Discussion¶
Backtracking algorithms typically have exponential or factorial complexity because they explore the entire solution space:
| Problem Type | Typical Complexity | Output Size |
|---|---|---|
| Permutations | O(n! Γ n) | n! |
| Subsets | O(2^n Γ n) | 2^n |
| Combinations C(n,k) | O(C(n,k) Γ k) | C(n,k) |
| N-Queens | O(n!) | variable |
Important: The complexity is often output-sensitive β if there are many solutions, generating them all is inherently expensive.
1.5 Sub-Pattern Classification¶
| Sub-Pattern | Key Characteristic | Examples |
|---|---|---|
| Permutation | Used/visited tracking | LeetCode 46, 47 |
| Subset/Combination | Start-index canonicalization | LeetCode 78, 90, 77 |
| Target Search | Remaining/target pruning | LeetCode 39, 40, 216 |
| Constraint Satisfaction | Row-by-row with constraint sets | LeetCode 51, 52 |
| String Partitioning | Cut positions with validity | LeetCode 131, 93 |
| Grid/Path Search | Visited marking and undo | LeetCode 79 |
2. Base Template: Permutations (LeetCode 46)¶
Problem: Given an array of distinct integers, return all possible permutations.
Sub-Pattern: Permutation Enumeration with used tracking.
Key Insight: At each position, try all unused elements.
2.1 Implementation¶
def permute(nums: list[int]) -> list[list[int]]:
"""
Generate all permutations of distinct integers.
Algorithm:
- Build permutation position by position
- Track which elements have been used with a boolean array
- At each position, try every unused element
- When path length equals nums length, we have a complete permutation
Time Complexity: O(n! Γ n)
- n! permutations to generate
- O(n) to copy each permutation
Space Complexity: O(n)
- Recursion depth is n
- Used array is O(n)
- Output space not counted
Args:
nums: Array of distinct integers
Returns:
All possible permutations
"""
results: list[list[int]] = []
n = len(nums)
# State: Current permutation being built
path: list[int] = []
# Tracking: Which elements are already used in current path
used: list[bool] = [False] * n
def backtrack() -> None:
# BASE CASE: Permutation is complete
if len(path) == n:
results.append(path[:]) # Append a copy
return
# RECURSIVE CASE: Try each unused element
for i in range(n):
if used[i]:
continue # Skip already used elements
# CHOOSE: Add element to permutation
path.append(nums[i])
used[i] = True
# EXPLORE: Recurse to fill next position
backtrack()
# UNCHOOSE: Remove element (backtrack)
path.pop()
used[i] = False
backtrack()
return results
2.2 Why This Works¶
The used array ensures each element appears exactly once in each permutation. The decision tree has: - Level 0: n choices - Level 1: n-1 choices
- Level k: n-k choices - Total leaves: n!
2.3 Trace Example¶
Input: [1, 2, 3]
backtrack(path=[], used=[F,F,F])
ββ CHOOSE 1 β backtrack(path=[1], used=[T,F,F])
β ββ CHOOSE 2 β backtrack(path=[1,2], used=[T,T,F])
β β ββ CHOOSE 3 β backtrack(path=[1,2,3], used=[T,T,T])
β β β SOLUTION: [1,2,3]
β ββ CHOOSE 3 β backtrack(path=[1,3], used=[T,F,T])
β ββ CHOOSE 2 β backtrack(path=[1,3,2], used=[T,T,T])
β β SOLUTION: [1,3,2]
ββ CHOOSE 2 β ... β [2,1,3], [2,3,1]
ββ CHOOSE 3 β ... β [3,1,2], [3,2,1]
Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
2.4 Common Pitfalls¶
| Pitfall | Problem | Solution |
|---|---|---|
| Forgetting to copy | All results point to same list | Use path[:] or list(path) |
| Not unmarking used | Elements appear multiple times | Always set used[i] = False after recursion |
| Modifying during iteration | Concurrent modification errors | Iterate over indices, not elements |
3. Variation: Permutations with Duplicates (LeetCode 47)¶
Problem: Given an array with duplicate integers, return all unique permutations.
Delta from Base: Add same-level deduplication after sorting.
Key Insight: Skip duplicate elements at the same tree level.
3.1 Implementation¶
def permute_unique(nums: list[int]) -> list[list[int]]:
"""
Generate all unique permutations of integers that may contain duplicates.
Algorithm:
- Sort the array to bring duplicates together
- Use same-level deduplication: skip a duplicate if its previous
occurrence wasn't used (meaning we're at the same decision level)
Deduplication Rule:
- If nums[i] == nums[i-1] and used[i-1] == False, skip nums[i]
- This ensures we only use the first occurrence of a duplicate
at each level of the decision tree
Time Complexity: O(n! Γ n) in worst case (all unique)
Space Complexity: O(n)
Args:
nums: Array of integers (may contain duplicates)
Returns:
All unique permutations
"""
results: list[list[int]] = []
n = len(nums)
# CRITICAL: Sort to bring duplicates together
nums.sort()
path: list[int] = []
used: list[bool] = [False] * n
def backtrack() -> None:
if len(path) == n:
results.append(path[:])
return
for i in range(n):
if used[i]:
continue
# DEDUPLICATION: Skip duplicates at the same tree level
# Condition: Current equals previous AND previous is unused
# (unused previous means we're trying duplicate at same level)
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
path.append(nums[i])
used[i] = True
backtrack()
path.pop()
used[i] = False
backtrack()
return results
3.2 Deduplication Logic Explained¶
Input: [1, 1, 2] (sorted)
Indices: [0, 1, 2]
Without deduplication, we'd get:
- Path using indices [0,1,2] β [1,1,2]
- Path using indices [1,0,2] β [1,1,2] β DUPLICATE!
With deduplication (skip if nums[i]==nums[i-1] and !used[i-1]):
- When i=1 and used[0]=False: skip (same level, use i=0 first)
- When i=1 and used[0]=True: proceed (different subtree)
This ensures we always pick the leftmost duplicate first at each level.
4. Variation: Subsets (LeetCode 78)¶
Problem: Given an array of distinct integers, return all possible subsets.
Sub-Pattern: Subset enumeration with start-index canonicalization.
Key Insight: Use a start index to avoid revisiting previous elements.
4.1 Implementation¶
def subsets(nums: list[int]) -> list[list[int]]:
"""
Generate all subsets (power set) of distinct integers.
Algorithm:
- Each subset is a collection of elements with no ordering
- To avoid duplicates like {1,2} and {2,1}, enforce canonical ordering
- Use start_index to only consider elements at or after current position
- Every intermediate path is a valid subset (collect at every node)
Key Insight:
- Unlike permutations, subsets don't need a "used" array
- The start_index inherently prevents revisiting previous elements
Time Complexity: O(n Γ 2^n)
- 2^n subsets to generate
- O(n) to copy each subset
Space Complexity: O(n) for recursion depth
Args:
nums: Array of distinct integers
Returns:
All possible subsets
"""
results: list[list[int]] = []
n = len(nums)
path: list[int] = []
def backtrack(start_index: int) -> None:
# COLLECT: Every path (including empty) is a valid subset
results.append(path[:])
# EXPLORE: Only consider elements from start_index onwards
for i in range(start_index, n):
# CHOOSE
path.append(nums[i])
# EXPLORE: Move start_index forward to enforce ordering
backtrack(i + 1)
# UNCHOOSE
path.pop()
backtrack(0)
return results
4.2 Why Start Index Works¶
Input: [1, 2, 3]
Decision tree with start_index:
[] β start=0, collect []
ββ [1] β start=1, collect [1]
β ββ [1,2] β start=2, collect [1,2]
β β ββ [1,2,3] β start=3, collect [1,2,3]
β ββ [1,3] β start=3, collect [1,3]
ββ [2] β start=2, collect [2]
β ββ [2,3] β start=3, collect [2,3]
ββ [3] β start=3, collect [3]
Total: 8 subsets = 2^3 β
The start_index ensures: - We never pick element i after already having an element j > i - This enforces a canonical ordering (ascending by index) - Each subset is generated exactly once
5. Variation: Subsets with Duplicates (LeetCode 90)¶
Problem: Given an array with duplicates, return all unique subsets.
Delta from Subsets: Sort + same-level deduplication.
Key Insight: Skip duplicate values at the same recursion level.
5.1 Implementation¶
def subsets_with_dup(nums: list[int]) -> list[list[int]]:
"""
Generate all unique subsets from integers that may contain duplicates.
Algorithm:
- Sort to bring duplicates together
- Use same-level deduplication: skip if current equals previous
in the same iteration loop
Deduplication Condition:
- Skip nums[i] if i > start_index AND nums[i] == nums[i-1]
- This prevents choosing the same value twice at the same tree level
Time Complexity: O(n Γ 2^n) worst case
Space Complexity: O(n)
Args:
nums: Array of integers (may contain duplicates)
Returns:
All unique subsets
"""
results: list[list[int]] = []
n = len(nums)
# CRITICAL: Sort to bring duplicates together
nums.sort()
path: list[int] = []
def backtrack(start_index: int) -> None:
results.append(path[:])
for i in range(start_index, n):
# DEDUPLICATION: Skip duplicates at same level
# i > start_index ensures we're not skipping the first occurrence
if i > start_index and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return results
5.2 Deduplication Visualization¶
Input: [1, 2, 2] (sorted)
Without deduplication:
[]
ββ [1] β [1,2] β [1,2,2]
β β [1,2] β choosing second 2
ββ [2] β [2,2]
ββ [2] β DUPLICATE of above!
With deduplication (skip if i > start and nums[i] == nums[i-1]):
[]
ββ [1] β [1,2] β [1,2,2]
β β i=2, start=2, 2==2 but i==start, proceed
β β [1,2] skipped (i=2 > start=1, 2==2)
ββ [2] β [2,2]
ββ skip (i=2 > start=0, 2==2)
Result: [[], [1], [1,2], [1,2,2], [2], [2,2]]
6. Variation: Combinations (LeetCode 77)¶
Problem: Given n and k, return all combinations of k numbers from [1..n].
Sub-Pattern: Fixed-size subset enumeration.
Delta from Subsets: Only collect when path length equals k.
6.1 Implementation¶
def combine(n: int, k: int) -> list[list[int]]:
"""
Generate all combinations of k numbers from range [1, n].
Algorithm:
- Similar to subsets, but only collect when path has exactly k elements
- Use start_index to enforce canonical ordering
- Add pruning: stop early if remaining elements can't fill path to k
Pruning Optimization:
- If we need (k - len(path)) more elements, we need at least that many
elements remaining in [i, n]
- Elements remaining = n - i + 1
- Prune when: n - i + 1 < k - len(path)
- Equivalently: stop loop when i > n - (k - len(path)) + 1
Time Complexity: O(k Γ C(n,k))
Space Complexity: O(k)
Args:
n: Range upper bound [1..n]
k: Size of each combination
Returns:
All combinations of k numbers from [1..n]
"""
results: list[list[int]] = []
path: list[int] = []
def backtrack(start: int) -> None:
# BASE CASE: Combination is complete
if len(path) == k:
results.append(path[:])
return
# PRUNING: Calculate upper bound for current loop
# We need (k - len(path)) more elements
# Available elements from start to n is (n - start + 1)
# Stop when available < needed
need = k - len(path)
for i in range(start, n - need + 2): # n - need + 1 + 1 for range
path.append(i)
backtrack(i + 1)
path.pop()
backtrack(1)
return results
6.2 Pruning Analysis¶
n=4, k=2
Without pruning:
start=1: try 1,2,3,4
start=2: try 2,3,4
start=3: try 3,4
start=4: try 4 β only 1 element left, need 1 more β works
start=5: empty β wasted call
With pruning (need=2, loop until n-need+2=4):
start=1: try 1,2,3 (not 4, because 4β[] would fail)
...
This eliminates branches that can't possibly lead to valid combinations.
7. Variation: Combination Sum (LeetCode 39)¶
Problem: Find combinations that sum to target. Elements can be reused.
Sub-Pattern: Target search with element reuse.
Key Insight: Don't increment start_index when allowing reuse.
7.1 Implementation¶
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
"""
Find all combinations that sum to target. Each number can be used unlimited times.
Algorithm:
- Track remaining target (target - current sum)
- When remaining = 0, found a valid combination
- Allow reuse by NOT incrementing start_index when recursing
- Prune when remaining < 0 (overshot target)
Key Difference from Combinations:
- Reuse allowed: recurse with same index i, not i+1
- This means we can pick the same element multiple times
Time Complexity: O(n^(t/m)) where t=target, m=min(candidates)
- Branching factor up to n at each level
- Depth up to t/m (using smallest element repeatedly)
Space Complexity: O(t/m) for recursion depth
Args:
candidates: Array of distinct positive integers
target: Target sum
Returns:
All unique combinations that sum to target
"""
results: list[list[int]] = []
path: list[int] = []
# Optional: Sort for consistent output order
candidates.sort()
def backtrack(start_index: int, remaining: int) -> None:
# BASE CASE: Found valid combination
if remaining == 0:
results.append(path[:])
return
# PRUNING: Overshot target
if remaining < 0:
return
for i in range(start_index, len(candidates)):
# PRUNING: If current candidate exceeds remaining,
# all subsequent (if sorted) will too
if candidates[i] > remaining:
break
path.append(candidates[i])
# REUSE ALLOWED: Recurse with same index i
backtrack(i, remaining - candidates[i])
path.pop()
backtrack(0, target)
return results
7.2 Reuse vs No-Reuse Comparison¶
| Aspect | With Reuse (LC 39) | Without Reuse (LC 40) |
|---|---|---|
| Recurse with | backtrack(i, ...) | backtrack(i+1, ...) |
| Same element | Can appear multiple times | Can appear at most once |
| Deduplication | Not needed (distinct) | Needed (may have duplicates) |
8. Variation: Combination Sum II (LeetCode 40)¶
Problem: Find combinations that sum to target. Each element used at most once. Input may have duplicates.
Delta from Combination Sum: No reuse + duplicate handling.
Key Insight: Sort + same-level skip for duplicates.
8.1 Implementation¶
def combination_sum2(candidates: list[int], target: int) -> list[list[int]]:
"""
Find all unique combinations that sum to target. Each number used at most once.
Input may contain duplicates.
Algorithm:
- Sort to bring duplicates together
- Use start_index to prevent reuse (i+1 when recursing)
- Same-level deduplication: skip if current == previous at same level
Deduplication Rule:
- Skip candidates[i] if i > start_index AND candidates[i] == candidates[i-1]
- This prevents generating duplicate combinations
Time Complexity: O(2^n) worst case
Space Complexity: O(n)
Args:
candidates: Array of positive integers (may have duplicates)
target: Target sum
Returns:
All unique combinations summing to target
"""
results: list[list[int]] = []
path: list[int] = []
# CRITICAL: Sort for deduplication
candidates.sort()
def backtrack(start_index: int, remaining: int) -> None:
if remaining == 0:
results.append(path[:])
return
if remaining < 0:
return
for i in range(start_index, len(candidates)):
# DEDUPLICATION: Skip same value at same level
if i > start_index and candidates[i] == candidates[i - 1]:
continue
# PRUNING: Current exceeds remaining (sorted, so break)
if candidates[i] > remaining:
break
path.append(candidates[i])
# NO REUSE: Recurse with i+1
backtrack(i + 1, remaining - candidates[i])
path.pop()
backtrack(0, target)
return results
9. Variation: Combination Sum III (LeetCode 216)¶
Problem: Find k numbers from [1-9] that sum to n. Each number used at most once.
Delta from Combination Sum II: Fixed count k + bounded range [1-9].
Key Insight: Dual constraint β both count and sum must be satisfied.
9.1 Implementation¶
def combination_sum3(k: int, n: int) -> list[list[int]]:
"""
Find all combinations of k numbers from [1-9] that sum to n.
Algorithm:
- Fixed size k (must have exactly k numbers)
- Fixed sum n (must sum to exactly n)
- Range is [1-9], all distinct, no reuse
Pruning Strategies:
1. If current sum exceeds n, stop
2. If path length exceeds k, stop
3. If remaining numbers can't fill to k, stop
Time Complexity: O(C(9,k) Γ k)
Space Complexity: O(k)
Args:
k: Number of elements required
n: Target sum
Returns:
All valid combinations
"""
results: list[list[int]] = []
path: list[int] = []
def backtrack(start: int, remaining: int) -> None:
# BASE CASE: Have k numbers
if len(path) == k:
if remaining == 0:
results.append(path[:])
return
# PRUNING: Not enough numbers left to fill path
if 9 - start + 1 < k - len(path):
return
for i in range(start, 10):
# PRUNING: Current number too large
if i > remaining:
break
path.append(i)
backtrack(i + 1, remaining - i)
path.pop()
backtrack(1, n)
return results
10. Variation: N-Queens (LeetCode 51/52)¶
Problem: Place n queens on an nΓn board so no two queens attack each other.
Sub-Pattern: Constraint satisfaction with row-by-row placement.
Key Insight: Track columns and diagonals as constraint sets.
10.1 Implementation¶
def solve_n_queens(n: int) -> list[list[str]]:
"""
Find all solutions to the N-Queens puzzle.
Algorithm:
- Place queens row by row (one queen per row guaranteed)
- Track three constraints:
1. Columns: No two queens in same column
2. Main diagonals (β): row - col is constant
3. Anti-diagonals (β): row + col is constant
- Use hash sets for O(1) constraint checking
Key Insight:
- Row-by-row placement eliminates row conflicts by construction
- Only need to check column and diagonal conflicts
Time Complexity: O(n!)
- At row 0: n choices
- At row 1: at most n-1 choices
- ... and so on
Space Complexity: O(n) for constraint sets and recursion
Args:
n: Board size
Returns:
All valid board configurations as string arrays
"""
results: list[list[str]] = []
# State: queen_cols[row] = column where queen is placed
queen_cols: list[int] = [-1] * n
# Constraint sets for O(1) conflict checking
used_cols: set[int] = set()
used_diag_main: set[int] = set() # row - col
used_diag_anti: set[int] = set() # row + col
def backtrack(row: int) -> None:
# BASE CASE: All queens placed
if row == n:
results.append(build_board(queen_cols, n))
return
# Try each column in current row
for col in range(n):
# Calculate diagonal identifiers
diag_main = row - col
diag_anti = row + col
# CONSTRAINT CHECK (pruning)
if col in used_cols:
continue
if diag_main in used_diag_main:
continue
if diag_anti in used_diag_anti:
continue
# CHOOSE: Place queen
queen_cols[row] = col
used_cols.add(col)
used_diag_main.add(diag_main)
used_diag_anti.add(diag_anti)
# EXPLORE: Move to next row
backtrack(row + 1)
# UNCHOOSE: Remove queen
queen_cols[row] = -1
used_cols.discard(col)
used_diag_main.discard(diag_main)
used_diag_anti.discard(diag_anti)
backtrack(0)
return results
def build_board(queen_cols: list[int], n: int) -> list[str]:
"""Convert queen positions to board representation."""
board = []
for col in queen_cols:
row = '.' * col + 'Q' + '.' * (n - col - 1)
board.append(row)
return board
10.2 Diagonal Identification¶
Main diagonal (β): cells where row - col is constant
(0,0) (1,1) (2,2) β row - col = 0
(0,1) (1,2) (2,3) β row - col = -1
(1,0) (2,1) (3,2) β row - col = 1
Anti-diagonal (β): cells where row + col is constant
(0,2) (1,1) (2,0) β row + col = 2
(0,3) (1,2) (2,1) (3,0) β row + col = 3
10.3 N-Queens II (Count Only)¶
def total_n_queens(n: int) -> int:
"""Count solutions without building boards."""
count = 0
used_cols: set[int] = set()
used_diag_main: set[int] = set()
used_diag_anti: set[int] = set()
def backtrack(row: int) -> None:
nonlocal count
if row == n:
count += 1
return
for col in range(n):
dm, da = row - col, row + col
if col in used_cols or dm in used_diag_main or da in used_diag_anti:
continue
used_cols.add(col)
used_diag_main.add(dm)
used_diag_anti.add(da)
backtrack(row + 1)
used_cols.discard(col)
used_diag_main.discard(dm)
used_diag_anti.discard(da)
backtrack(0)
return count
11. Variation: Palindrome Partitioning (LeetCode 131)¶
Problem: Partition a string such that every substring is a palindrome.
Sub-Pattern: String segmentation with validity check.
Key Insight: Try all cut positions, validate each segment.
11.1 Implementation¶
def partition(s: str) -> list[list[str]]:
"""
Partition string so every part is a palindrome.
Algorithm:
- Try cutting at each position from current start
- Check if prefix is palindrome; if yes, recurse on suffix
- When start reaches end of string, we have a valid partition
Key Insight:
- Each "choice" is where to cut the string
- Only proceed if the cut-off prefix is a palindrome
Optimization:
- Precompute palindrome status with DP for O(1) checks
- Without precompute: O(n) per check, O(n^3) total
- With precompute: O(n^2) preprocessing, O(1) per check
Time Complexity: O(n Γ 2^n) worst case
- 2^(n-1) possible partitions (n-1 cut positions)
- O(n) to copy each partition
Space Complexity: O(n) for recursion
Args:
s: Input string
Returns:
All palindrome partitionings
"""
results: list[list[str]] = []
path: list[str] = []
n = len(s)
# Precompute: is_palindrome[i][j] = True if s[i:j+1] is palindrome
is_palindrome = [[False] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i, n):
if s[i] == s[j]:
if j - i <= 2:
is_palindrome[i][j] = True
else:
is_palindrome[i][j] = is_palindrome[i + 1][j - 1]
def backtrack(start: int) -> None:
# BASE CASE: Reached end of string
if start == n:
results.append(path[:])
return
# Try each end position for current segment
for end in range(start, n):
# VALIDITY CHECK: Only proceed if palindrome
if not is_palindrome[start][end]:
continue
path.append(s[start:end + 1])
backtrack(end + 1)
path.pop()
backtrack(0)
return results
12. Variation: Restore IP Addresses (LeetCode 93)¶
Problem: Return all valid IP addresses that can be formed from a digit string.
Sub-Pattern: String segmentation with multi-constraint validity.
Key Insight: Fixed 4 segments, each 1-3 digits, value 0-255, no leading zeros.
12.1 Implementation¶
def restore_ip_addresses(s: str) -> list[str]:
"""
Generate all valid IP addresses from a digit string.
Constraints per segment:
1. Length: 1-3 characters
2. Value: 0-255
3. No leading zeros (except "0" itself)
Algorithm:
- Exactly 4 segments required
- Try 1, 2, or 3 characters for each segment
- Validate each segment before proceeding
Pruning:
- Early termination if remaining chars can't form remaining segments
- Min remaining = segments_left Γ 1
- Max remaining = segments_left Γ 3
Time Complexity: O(3^4 Γ n) = O(81 Γ n) = O(n)
- At most 3 choices per segment, 4 segments
- O(n) to validate/copy
Space Complexity: O(4) = O(1) for path
Args:
s: String of digits
Returns:
All valid IP addresses
"""
results: list[str] = []
segments: list[str] = []
n = len(s)
def is_valid_segment(segment: str) -> bool:
"""Check if segment is a valid IP octet."""
if not segment:
return False
if len(segment) > 1 and segment[0] == '0':
return False # No leading zeros
if int(segment) > 255:
return False
return True
def backtrack(start: int, segment_count: int) -> None:
# PRUNING: Check remaining length bounds
remaining = n - start
remaining_segments = 4 - segment_count
if remaining < remaining_segments: # Too few chars
return
if remaining > remaining_segments * 3: # Too many chars
return
# BASE CASE: 4 segments formed
if segment_count == 4:
if start == n: # Used all characters
results.append('.'.join(segments))
return
# Try 1, 2, or 3 character segments
for length in range(1, 4):
if start + length > n:
break
segment = s[start:start + length]
if not is_valid_segment(segment):
continue
segments.append(segment)
backtrack(start + length, segment_count + 1)
segments.pop()
backtrack(0, 0)
return results
13. Variation: Word Search (LeetCode 79)¶
Problem: Find if a word exists in a grid by traversing adjacent cells.
Sub-Pattern: Grid/Path DFS with visited marking.
Key Insight: Mark visited, explore neighbors, unmark on backtrack.
13.1 Implementation¶
def exist(board: list[list[str]], word: str) -> bool:
"""
Check if word exists in grid by traversing adjacent cells.
Algorithm:
- Start DFS from each cell that matches word[0]
- Mark current cell as visited (modify in-place or use set)
- Try all 4 directions for next character
- Unmark on backtrack
Key Insight:
- Each cell can be used at most once per path
- In-place marking (temporary modification) is efficient
Pruning:
- Early return on mismatch
- Can add frequency check: if board doesn't have enough of each char
Time Complexity: O(m Γ n Γ 4^L) where L = len(word)
- mΓn starting positions
- 4 choices at each step, depth L
Space Complexity: O(L) for recursion depth
Args:
board: 2D character grid
word: Target word to find
Returns:
True if word can be formed
"""
if not board or not board[0]:
return False
rows, cols = len(board), len(board[0])
word_len = len(word)
# Directions: up, down, left, right
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def backtrack(row: int, col: int, index: int) -> bool:
# BASE CASE: All characters matched
if index == word_len:
return True
# BOUNDARY CHECK
if row < 0 or row >= rows or col < 0 or col >= cols:
return False
# CHARACTER CHECK
if board[row][col] != word[index]:
return False
# MARK AS VISITED (in-place modification)
original = board[row][col]
board[row][col] = '#' # Temporary marker
# EXPLORE: Try all 4 directions
for dr, dc in directions:
if backtrack(row + dr, col + dc, index + 1):
# Found! Restore and return
board[row][col] = original
return True
# UNMARK (backtrack)
board[row][col] = original
return False
# Try starting from each cell
for r in range(rows):
for c in range(cols):
if board[r][c] == word[0]:
if backtrack(r, c, 0):
return True
return False
13.2 In-Place Marking vs Visited Set¶
| Approach | Pros | Cons |
|---|---|---|
In-place (#) | O(1) space, fast | Modifies input temporarily |
| Visited set | Clean, no mutation | O(L) space for coordinates |
14. Deduplication Strategies¶
14.1 Strategy Comparison¶
| Strategy | When to Use | Example |
|---|---|---|
| Sorting + Same-Level Skip | Input has duplicates | Permutations II, Subsets II |
| Start Index | Subsets/Combinations (order doesn't matter) | Subsets, Combinations |
| Used Array | Permutations (all elements, order matters) | Permutations |
| Canonical Ordering | Implicit via index ordering | All subset-like problems |
14.2 Same-Level Skip Pattern¶
# Sort first, then skip duplicates at same level
nums.sort()
for i in range(start, n):
# Skip if current equals previous at same tree level
if i > start and nums[i] == nums[i - 1]:
continue
# ... process nums[i]
14.3 Used Array Pattern¶
# For permutations with duplicates
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
# This ensures we use duplicates in order (leftmost first)
15. Pruning Techniques¶
15.1 Pruning Categories¶
| Category | Description | Example |
|---|---|---|
| Feasibility Bound | Remaining elements can't satisfy constraints | Combinations: not enough elements left |
| Target Bound | Current path already exceeds target | Combination Sum: sum > target |
| Constraint Propagation | Future choices are forced/impossible | N-Queens: no valid columns left |
| Sorted Early Exit | If sorted, larger elements also fail | Combination Sum with sorted candidates |
15.2 Pruning Patterns¶
# 1. Not enough elements left (Combinations)
if remaining_elements < elements_needed:
return
# 2. Exceeded target (Combination Sum)
if current_sum > target:
return
# 3. Sorted early break (when candidates sorted)
if candidates[i] > remaining:
break # All subsequent are larger
# 4. Length/count bound
if len(path) > max_allowed:
return
16. Pattern Comparison Table¶
| Problem | Sub-Pattern | State | Dedup Strategy | Pruning |
|---|---|---|---|---|
| Permutations (46) | Permutation | used[] | None (distinct) | None |
| Permutations II (47) | Permutation | used[] | Sort + level skip | Same-level |
| Subsets (78) | Subset | start_idx | Index ordering | None |
| Subsets II (90) | Subset | start_idx | Sort + level skip | Same-level |
| Combinations (77) | Combination | start_idx | Index ordering | Count bound |
| Combination Sum (39) | Target Search | start_idx | None (distinct) | Target bound |
| Combination Sum II (40) | Target Search | start_idx | Sort + level skip | Target + level |
| Combination Sum III (216) | Target Search | start_idx | None (1-9 distinct) | Count + target |
| N-Queens (51) | Constraint | constraint sets | Row-by-row | Constraints |
| Palindrome Part. (131) | Segmentation | start_idx | None | Validity check |
| IP Addresses (93) | Segmentation | start_idx, count | None | Length bounds |
| Word Search (79) | Grid Path | visited | Path uniqueness | Boundary + char |
17. When to Use Backtracking¶
17.1 Problem Indicators¶
β Use backtracking when: - Need to enumerate all solutions (permutations, combinations, etc.) - Decision tree structure (sequence of choices) - Constraints can be checked incrementally - Solution can be built piece by piece
β Consider alternatives when: - Only need count (use DP with counting) - Only need one solution (may use greedy or simple DFS) - Optimization problem (consider DP or greedy) - State space is too large even with pruning
17.2 Decision Guide¶
Is the problem asking for ALL solutions?
βββ Yes β Does solution have natural ordering/structure?
β βββ Permutation β Use used[] array
β βββ Subset/Combination β Use start_index
β βββ Grid path β Use visited marking
β βββ Constraint satisfaction β Use constraint sets
βββ No β Need single solution or count?
βββ Single solution β Simple DFS may suffice
βββ Count β Consider DP
18. LeetCode Problem Mapping¶
| Sub-Pattern | Problems |
|---|---|
| Permutation Enumeration | 46. Permutations, 47. Permutations II |
| Subset/Combination | 78. Subsets, 90. Subsets II, 77. Combinations |
| Target Search | 39. Combination Sum, 40. Combination Sum II, 216. Combination Sum III |
| Constraint Satisfaction | 51. N-Queens, 52. N-Queens II, 37. Sudoku Solver |
| String Partitioning | 131. Palindrome Partitioning, 93. Restore IP Addresses, 140. Word Break II |
| Grid/Path Search | 79. Word Search, 212. Word Search II |
19. Template Quick Reference¶
19.1 Permutation Template¶
def permute(nums):
results = []
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
results.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return results
19.2 Subset/Combination Template¶
def subsets(nums):
results = []
def backtrack(start, path):
results.append(path[:]) # Collect at every node
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path) # i+1 for no reuse
path.pop()
backtrack(0, [])
return results
19.3 Target Sum Template¶
def combination_sum(candidates, target):
results = []
def backtrack(start, path, remaining):
if remaining == 0:
results.append(path[:])
return
if remaining < 0:
return
for i in range(start, len(candidates)):
path.append(candidates[i])
backtrack(i, path, remaining - candidates[i]) # i for reuse
path.pop()
backtrack(0, [], target)
return results
19.4 Grid Search Template¶
def grid_search(grid, word):
rows, cols = len(grid), len(grid[0])
def backtrack(r, c, index):
if index == len(word):
return True
if r < 0 or r >= rows or c < 0 or c >= cols:
return False
if grid[r][c] != word[index]:
return False
temp = grid[r][c]
grid[r][c] = '#'
for dr, dc in [(-1,0), (1,0), (0,-1), (0,1)]:
if backtrack(r + dr, c + dc, index + 1):
grid[r][c] = temp
return True
grid[r][c] = temp
return False
for r in range(rows):
for c in range(cols):
if backtrack(r, c, 0):
return True
return False
Document generated for NeetCode Practice Framework β API Kernel: BacktrackingExploration