LeetCode: Backtracking

Table Of Content
Backtracking intro
- What is Backtracking
- Backtracking Characteristics
- Backtracking Representation
- Backtracking IRL
- Backtracking Application: DFS Generate All Combinations Or Subsets
- Backtracking Application: DFS Generate While Constraint Satisfaction
- Backtracking Application: Path Finding In Search Space
- Backtracking Application: Combinatorial Optimization
- Backtracking Application: Decision Trees And Partitioning
39. Combination Sum ::4:: - Medium
- Intro
- Abstraction
- Space & Time Complexity
- Brute Force:
- Find the Bug:
- Solution 1: Recursive Unsorted Late Branch Pruning Backtracking on Global Path - Backtracking/Generate All Combinations Or Subsets
- Solution 2: Recursive Sorted Early Candidate Pruning Forward Backtracking on Global Path - Backtracking/Generate While Constraint Satisfaction
- Solution 3: Recursive Sorted Early Pruning Backwards Backtracking on Global Path - Backtracking/Generate While Constraint Satisfaction
- Solution 4: Iterative DFS Early Pruning with Explicit Stack - Backtracking/Generate While Constraint Satisfaction
40. Combination Sum II ::2:: - Medium
- Intro
- Abstraction
- Space & Time Complexity
- Brute Force:
- Find the Bug:
- Solution 1: Recursive Sorted Early Pruning Forward Backtracking on Current Combination - Backtracking/Generate While Constraint Satisfaction
- Solution 2: Recursive Sorted Early Pruning Backwards Backtracking on Current Combination - Backtracking/Generate While Constraint Satisfaction
51. N Queens ::1:: - Hard
- Intro
- Abstraction
- Space & Time Complexity
- Brute Force:
- Find the Bug:
- Solution 1: Recursive DFS Backtracking on Current Row with Column/Diagonal Tracking - Backtracking/Combinatorial Optimization
- Solution 2: Recursive DFS Backtracking Using Bitmasking for Columns/Diagonals - Backtracking/Combinatorial Optimization
Backtracking intro
LeetCode problems with heap solutions.
What is Backtracking
Backtracking is a systematic technique for exploring all possible solutions to a problem by building them incrementally and abandoning ('backtracking') as soon as it becomes clear that a candidate cannot lead to a valid solution.
It is often implemented recursively, but can also be simulated iteratively with a stack.
While backtracking often has exponential time complexity in the worst case, it is the most practical way to solve problems that require exploring many possibilities with pruning.
Backtracking Characteristics
- Build -> partial solution
- Prune -> remove branches that violate constraints
- Explore -> recurse or continue to extend the current partial solution
- Backtrack -> undo the last step and try another option
Backtracking Representation
-
Tree representation: Each node represents a decision state
-
Children: represent choices from the current states
-
Leaves: Either full solutions or dead nodes
Generating subsets of [1,2,3] forms a decision tree where at each step, we decide to include or exclude a number
Decision tree for [1,2,3]:
[]
/ \
[1] []
/ \ / \
[1,2] [1] [2] []
/ \ ... ...
[1,2,3] ...
Backtracking IRL
In the context of solving a maze, at each intersection, you choose a path (a decision). If the path leads to a dead end, you backtrack to the intersection and try another route. Eventually, you either find the exit (solution) or exhaust all paths (no solutions).
Backtracking Application: DFS Generate All Combinations Or Subsets
Traversal Order: Root -> Choices Mindset: Process the current subset as soon as you build it, then explore further elements Trick: Ill record the current subset first, then decide which elements to include next. We can explore all subsets, permutations, or combinations by recursively building solutions and backtracking when needed.
Ex: Generate all subsets of a set
def subsets(nums):
res = []
def dfs_backtrack(start, path):
# Process Root -> : record current subset first
res.append(path[:])
# Process -> Choices : decide which to include/exclude
for i in range(start, len(nums)):
# Build: include nums[i]
path.append(nums[i])
# Explore: recurse to next index
dfs_backtrack(i + 1, path)
# Backtrack: remove last element
path.pop()
dfs_backtrack(0, [])
return res
# Example: subsets([1,2,3]) -> [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]
Backtracking Application: DFS Generate While Constraint Satisfaction
Traversal Order: Root -> Choices Mindset: Build sequences step by step, only adding valid elements and backtracking when constraints are violated Tricks: Ill try to add '(' or ')' next only if rules allow, then undo if needed. As we explore all permutations, we can enforce rules in order to prune invalid branches early.
Ex: Generate all valid parentheses
def generateParenthesis(n):
res = []
def dfs_backtrack(open_count, close_count, path):
# Process Root -> : check if sequence complete
if len(path) == 2 * n:
res.append("".join(path))
return
# Process -> Choices : add '(' if possible
if open_count < n:
# Build
path.append("(")
# Explore
dfs_backtrack(open_count + 1, close_count, path)
# Backtrack
path.pop()
# Process -> Choices : add ')' if it will not break validity
if close_count < open_count:
# Build
path.append(")")
# Explore
dfs_backtrack(open_count, close_count + 1, path)
# Backtrack
path.pop()
dfs_backtrack(0, 0, [])
return res
# Example: generateParenthesis(3) -> ["((()))","(()())","(())()","()(())","()()()"]
Backtracking Application: Path Finding In Search Space
Traversal Order: Root -> Choices (neighboring paths) Mindset: Explore each path step by step, backtracking when reaching dead ends. Trick: Ill walk one direction fully before trying another, undoing my steps if blocked. We can explore fully explore paths, by stepping through and backtracking when reaching dead ends for different search spaces (grids, graphs, networks).
Ex: Word Search in grid
def exist(board, word):
rows, cols = len(board), len(board[0])
def dfs_backtrack(r, c, idx):
# Early exit:
# Process Root -> : matched full word
if idx == len(word):
return True
# Early Pruning -> : check boundaries
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
# Early Pruning -> : check current cell
if board[r][c] != word[idx]:
return False
# Process -> Choices : explore all 4 directions
# Build: mark current cell as exploring
tmp, board[r][c] = board[r][c], "#"
# Explore: recurse to neighbor cell
found = (dfs_backtrack(r+1, c, idx+1) or
dfs_backtrack(r-1, c, idx+1) or
dfs_backtrack(r, c+1, idx+1) or
dfs_backtrack(r, c-1, idx+1))
# Backtrack: Restore cell
board[r][c] = tmp
return found
# dfs_backtrack starting from all cells
for r in range(rows):
for c in range(cols):
if dfs_backtrack(r, c, 0):
return True
return False
# Example: exist([["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], "ABCCED") -> True
Backtracking Application: Combinatorial Optimization
Traversal Order: Root -> Choices (column positions) Mindset: Place one queen at a time, pruning invalid columns, and backtrack when stuck Trick: Ill place a queen, explore further rows, then remove it if it leads to a conflict. We can search for an optimal solution while pruning bad candidates.
Ex: N Queens Problem
def solveNQueens(n):
res = []
cols = set()
diag1 = set() # r - c
diag2 = set() # r + c
board = [["."] * n for _ in range(n)]
def dfs_backtrack(r):
# Process Root -> : all queens placed
if r == n:
res.append(["".join(row) for row in board])
return
# Process -> Choices : try each column
for c in range(n):
if c in cols or (r-c) in diag1 or (r+c) in diag2:
continue
# Build: place queen
board[r][c] = "Q"
cols.add(c); diag1.add(r-c); diag2.add(r+c)
# Explore: recurse to next row
dfs_backtrack(r + 1)
# Backtrack: remove queen
board[r][c] = "."
cols.remove(c); diag1.remove(r-c); diag2.remove(r+c)
dfs_backtrack(0)
return res
# Example: solveNQueens(4) -> [
# [".Q..","...Q","Q...","..Q."],
# ["..Q.","Q...","...Q",".Q.."]
# ]
Backtracking Application: Decision Trees And Partitioning
Traversal Order: Root -> Choices (substring partitions) Mindset: Partition strings step by step, backtracking when a substring is not a palindrome Trick: Ill add a palindromic piece, explore further, then remove it if it doesn't lead to a solution. We can make binary or k-ary decision at each step, exploring all outcomes
Ex: Partition a string into palindromic substrings
def partition(s):
res = []
def is_palindrome(sub):
return sub == sub[::-1]
def dfs_backtrack(start, path):
# Process Root -> : reached end of string
if start == len(s):
res.append(path[:])
return
# Process -> Choices : try all possible substrings
for end in range(start+1, len(s)+1):
# Constraint check: palindrome check
if is_palindrome(s[start:end]):
# Build: add substring
path.append(s[start:end])
# Explore: recurse to next string
dfs_backtrack(end, path)
# Backtrack: remove substring
path.pop()
dfs_backtrack(0, [])
return res
# Example: partition("aab") -> [["a","a","b"], ["aa","b"]]
78. Subsets ::2:: - Medium
Topics: Array, Backtracking, Bit Manipulation
Intro
Given an integer array nums of unique elements, return all possible subsets (the power set). A subset of an array is a selection of elements (possibly none) of the array. The solution set must not contain duplicate subsets. Return the solution in any order.
Example Input | Output |
---|---|
nums = [1,2,3] | [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]] |
nums = [0] | [[],[0]] |
Constraints:
1 ≤ nums.length ≤ 10
-10 ≤ nums[i] ≤ 10
All the numbers of nums are unique.
Abstraction
Given a list of numbers, return all unique subsets.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive DFS Backtracking on Global Path to Generate All Subsets - Backtracking/Generate All Combinations Or Subsets
def subsets(self, nums: List[int]) -> List[List[int]]:
# Result list
res = []
path = [] # mutable path list defined once
def dfs_backtrack(start: int):
# Process Root -> shallow copy of current path (current subset)
res.append(path[:])
# Explore Choices -> for remaining elements
for i in range(start, len(nums)):
# Build: append current element
path.append(nums[i])
# Explore: recurse with next index
dfs_backtrack(i + 1)
# Backtrack: remove last element
path.pop()
dfs_backtrack(0) # initial call, no path passed
return res
Solution 2: Iterative Append to Existing Subsets - Backtracking/Generate All Combinations Or Subsets
def subsets(self, nums: List[int]) -> List[List[int]]:
# Note:
# Iterative: Build subsets in order to append to existing subsets
# 1. Process Root -> start with empty subset
# 2. Process Choices -> for each num in nums, add to existing subsets
# Result: Subsets continue building with new nums to provide all subsets
# Process Root -> : start with empty subset
res = [[]]
# Process -> Choices : iterate through all numbers in nums
# since we build as we go, we cant build backwards, so we cant create a duplicate?
for n in nums:
# Explore -> : iterate over existing subsets, append to create new subset
for i in range(len(res)):
# Build -> : for all existing subset, add curr number
# new string
new = res[i] + [n]
# Incorrect:
# new = res[i].append(n)
# .append() always returns None,
# as modification is in-place
# Explore -> : append new subset to allow for iterative exploration
res.append(new)
# Backtrack -> : implicit, existing subsets remain for following iterations
# overall: time complexity
# overall space complexity
return res
39. Combination Sum ::4:: - Medium
Topics: Array, Backtracking
Intro
Given an array of distinct integers candidates and a target integer target, return a list of all unique combinations of candidates where the chosen numbers sum to target. You may return the combinations in any order. The same number may be chosen from candidates an unlimited number of times. Two combinations are unique if the frequency of at least one of the chosen numbers is different. The test cases are generated such that the number of unique combinations that sum up to target is less than 150 combinations for the given input.
Example Input | Output |
---|---|
candidates = [2,3,6,7], target = 7 | [[2,2,3],[7]] |
candidates = [2,3,5], target = 8 | [[2,2,2,2],[2,3,3],[3,5]] |
candidates = [2], target = 1 | [] |
Constraints:
1 ≤ candidates.length ≤ 30
2 ≤ candidates[i] ≤ 40
All the numbers of nums are unique.
1 ≤ target ≤ 40
Abstraction
Find all combinations that add up to target.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive Unsorted Late Branch Pruning Backtracking on Global Path - Backtracking/Generate All Combinations Or Subsets
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Recursive DFS backtracking: Track current path and sum
# 1. Process Root -> check if current sum equals target
# 2. Process Choices -> explore each candidate from current index onwards
# 3. Backtrack -> remove last choice to try next candidate
# Result: all valid combinations appended to res
# Note:
# "Late Pruning": pruning happens after adding elements.
# Branches grow first, then are cut off if sum exceeds target.
# Creates unnecessary branches compared to early pruning.
res = []
path = []
def dfs_backtrack(start, currSum):
# Process Root -> check if sum reached target, shallow copy
if currSum == target:
res.append(path[:])
return
# Late Branch Pruning -> we can only prune branch after adding elements
if currSum > target:
return
# Process -> Choices : try each candidate from current index onward, prune when in branch
for i in range(start, len(candidates)):
# Build: Include candidates[i] to make a new path
path.append(candidates[i])
# Explore: recurse with new path with same i (allow reuse)
dfs_backtrack(i, currSum + candidates[i])
# Backtrack: remove last candidate, attempt curr path with next candidate
path.pop()
dfs_backtrack(0, 0)
# overall: time complexity
# overall: space complexity
return res
Solution 2: Recursive Sorted Early Candidate Pruning Forward Backtracking on Global Path - Backtracking/Generate While Constraint Satisfaction
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Sort candidates for early pruning
# 1. Process Root -> check if remaining sum equals zero
# 2. Process Choices -> try candidates <= remaining
# 3. Backtrack -> remove last choice after recursion
# Result: all valid combinations appended to res
# "Early Pruning": we can skip adding candidates that exceed
# the remaining sum, pruning branches from the root itself.
# Avoids creating unnecessary branches
res = []
path = []
# Sort ascending so pruning works
candidates.sort()
def dfs_backtrack(start, remaining):
# Process Root -> check if sum reached target, shallow copy
if remaining == 0:
res.append(path[:])
return
# Process -> Choices : prune invalid candidates
for i in range(start, len(candidates)):
# grab candidate
candidate = candidates[i]
# Early Candidate Pruning -> : skip early if candidate exceeds remaining,
if candidate > remaining:
break
# Build: include candidate in path
path.append(candidate)
# Explore: recurse with same index (allow reuse)
dfs_backtrack(i, remaining - candidate)
# Backtrack: remove last element for next iteration
path.pop()
dfs_backtrack(0, target)
return res
Solution 3: Recursive Sorted Early Pruning Backwards Backtracking on Global Path - Backtracking/Generate While Constraint Satisfaction
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Sort candidates ascending for effective early pruning
# Recursive DFS backwards: Track global path and remaining sum
# 1. Process Root -> check if remaining sum equals zero
# 2. Early Pruning -> skip "include" branch if candidate > remaining sum
# 3. Process Choices -> try each candidate from current index backwards
# 4. Backtrack -> remove last choice after recursion
# Result: all valid combinations appended to res
# "Early Pruning" + "Backward DFS": largest candidates are considered first,
# branches exceeding target are skipped immediately,
# allows unnecessary branches than forward early pruning.
res = []
path = []
# Sort ascending so pruning works (largest candidates explored first)
candidates.sort()
def dfs_backtrack(i, remaining):
# Process Root -> : check if remaining sum equals zero, shallow copy
if remaining == 0:
res.append(path[:])
return
# For Loop Exit: no more candidates
if i < 0:
return
# grab candidate
candidate = candidates[i]
# Early Candidate Pruning -> if candidate too large, skip
if candidate > remaining:
dfs_backtrack(i - 1, remaining)
return
# Build: add candidate into path
path.append(candidate)
# Explore: recurse with same index (reuse allowed), update remaining
dfs_backtrack(i, remaining - candidate)
# Backtrack: remove last included candidate
path.pop()
# For Loop Iteration: next candidate
dfs_backtrack(i - 1, remaining)
# recursion from last index
dfs_backtrack(len(candidates) - 1, remaining)
# overall: time complexity
# overall: space complexity
return res
Solution 4: Iterative DFS Early Pruning with Explicit Stack - Backtracking/Generate While Constraint Satisfaction
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Iterative DFS using explicit stack to simulate recursion
# 1. Process Root -> push initial state
# 2. Process Choices -> expand from current index forward
# 3. Build -> include candidate into new path
# 4. Explore -> push new state with reduced remaining sum
# 5. Backtrack -> implicit when stack pops, no in-place undo needed
# Result: collect all valid paths where remaining == 0
res = []
# Sort candidates ascending for pruning
candidates.sort()
# tuples (start, path, remaining)
stack = [(0, [], target)]
# Iterative DFS expansion
while stack:
# Pop last state (DFS order FIFO)
(start, path, remaining) = stack.pop()
# Process Root -> if remaining == 0, valid combination found
if remaining == 0:
res.append(path)
continue
# Process Choices -> try each candidate from current index forward
for i in range(start, len(candidates)):
# grab candidate
candidate = candidates[i]
# Early Candidate Pruning -> skip if candidate exceeds remaining
if candidate > remaining:
break
# Build -> include candidate into path (need new path obj, order not guaranteed in iterative)
new_path = path + [candidate]
# Explore -> push new state, allow reuse of same candidate i
stack.append((i, new_path, remaining - candidate))
# Backtrack -> implicit, no need to pop since previous path objs still exit
# overall: time complexity
# overall: space complexity
return res
40. Combination Sum II ::2:: - Medium
Topics: Array, Backtracking
Intro
Given a collection of candidate numbers (candidates) and a target number (target), find all unique combinations in candidates where the candidate numbers sum to target. Each number in candidates may only be used once in the combination. Note: The solution set must not contain duplicate combinations.
Example Input | Output |
---|---|
candidates = [10,1,2,7,6,1,5], target = 8 | [[1,1,6], [1,2,5], [1,7], [2,6]] |
candidates = [2,5,2,1,2], target = 5 | [[1,2,2], [5]] |
Constraints:
1 ≤ candidates.length ≤ 100
2 ≤ candidates[i] ≤ 30
1 ≤ target ≤ 30
Abstraction
Find all combinations that add up to target with no guaranteed unique values.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive Sorted Early Pruning Forward Backtracking on Current Combination - Backtracking/Generate While Constraint Satisfaction
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Sort candidates ascending to align duplicates for pruning
# Recursive DFS backtracking: Track current path and remaining sum
# 1. Process Root -> check if remaining == 0 to record valid combination
# 2. Process Choices -> explore each candidate from current index onward
# 3. Early Pruning -> skip candidates larger than remaining sum
# 4. Duplicate Pruning -> skip candidates equal to previous at same level
# 5. Backtrack -> remove last choice to try next candidate
# Result: all valid combinations appended to res
res = []
path = []
# Sort for duplicate and early candidate pruning
candidates.sort()
def dfs_backtrack(start: int, remaining: int):
# Process Root -> : check if valid combination, shallow copy
if remaining == 0:
res.append(path[:])
return
# Process Choices -> : explore numbers from current index onward
for i in range(start, len(candidates)):
# Duplicate Pruning -> : matching values only allowed when i == start,
# ex: candidates = [1, 1, 1, 2, 3], target = 3
#
# Root call: dfs(start=0, remain=3, path=[])
# i = 0 -> 1 recurse dfs(start=1, remain=2, path=[1])
# i = 1 -> 1 skipped (i > start and dup of i=0)
# i = 2 -> 1 skipped (dup of i=1)
# i = 3 -> 2 recurse dfs(start=4, remain = 1, path=[2])
# i = 4 -> 3 recurse dfs(start=5, remain = 0, path=[3])
# Forward rule: 'allow the first copy in a group, i == start'
if i > start and candidates[i] == candidates[i - 1]:
continue
# Early Pruning -> : candidate exceeds remaining sum
if candidates[i] > remaining:
break
# Build: include candidate[i]
path.append(candidates[i])
# Explore -> move to next index (cannot reuse same element)
dfs_backtrack(i + 1, remaining - candidates[i])
# Backtrack -> remove last included candidate
path.pop()
dfs_backtrack(0, target)
# overall: time complexity
# overall: space complexity
return res
Solution 2: Recursive Sorted Early Pruning Backwards Backtracking on Current Combination - Backtracking/Generate While Constraint Satisfaction
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
# Note:
# Sort ascending for duplicates and pruning
# Recursive DFS backwards: Track path and remaining sum
# 1. Process Root -> check if remaining == 0 to record valid combination
# 2. Process Choices -> explore each candidate from current index backwards
# 3. Early Pruning -> skip candidates larger than remaining
# 4. Duplicate Pruning -> skip duplicates at same decision level
# 5. Backtrack -> remove last choice after recursion
# Result: all valid combinations appended to res
res = []
path = []
candidates.sort()
def dfs_backtracking(i, remaining):
# Process Root -> remaining sum zero
if remaining == 0:
res.append(path[:])
return
# No more candidates
if i < 0:
return
# Early Pruning -> candidate too large
if candidates[i] > remaining:
dfs_backtracking(i - 1, remaining)
return
# Duplicate Pruning -> : matching values only allowed when i == len(candidates)-1,
# ex: candidates = [1, 1, 1, 2, 3], target = 3
#
# Root call: dfs(i=4, remain=3, path=[])
# i = 4 -> 3 recurse dfs(i=3, remain=0, path=[3])
# i = 3 -> 2 recurse dfs(i=2, remain=1, path=[2])
# i = 2 -> 1 recurse dfs(i=1, remain=0, path=[1])
# i = 1 -> 1 skipped (i < len-1 and dup of i=2)
# i = 0 -> 1 skipped (i < len-1 and dup of i=1)
# Backwards rule: 'allow the last copy in a group, i == len(candidates)-1'
if i < len(candidates) - 1 and candidates[i] == candidates[i + 1]:
dfs_backtracking(i - 1, remaining)
return
# Build -> include candidate[i]
path.append(candidates[i])
# Explore -> move backward without reusing same element
dfs_backtracking(i - 1, remaining - candidates[i])
# Backtrack -> remove last
path.pop()
# Explore -> skip candidate[i], move to next smaller index
dfs_backtracking(i - 1, remaining)
# Start recursion from last index
dfs_backtracking(len(candidates) - 1, target)
return res
46. Permutations ::1:: - Medium
Topics: Array, Backtracking
Intro
Given an array nums of distinct integers, return all the possible . You can return the answer in any order. A permutation is a rearrangement of all the elements of an array.
Example Input | Output |
---|---|
nums = [1,2,3] | [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]] |
nums = [0,1] | [[0,1],[1,0]] |
nums = [1] | [[1]] |
Constraints:
1 ≤ nums.length ≤ 6
2 ≤ nums[i] ≤ 30
All the integers of nums are unique.
Abstraction
Find all permutations of an array.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive DFS Backtracking on Current Permutation Path with Element Usage Tracking - Backtracking/Generate While Constraint Satisfaction
def permute(self, nums: List[int]) -> List[List[int]]:
# Note:
# Recursive DFS backtracking: Track current permutation path
# 1. Process Root -> : check if path length == n to record full permutation
# 2. Process Choices -> : grab remaining unused elements
# 3. Early Pruning -> skip used elements
# 4. Backtrack -> remove last choice from path and mark as unused
# Result: all valid permutations appended to res
res = []
path = []
# Track elements in the current path
used = [False] * len(nums)
def backtrack():
# Process Root -> : check if valid permutation
if len(path) == len(nums):
res.append(path[:])
return
# Process -> Choices : grab remaining unused number
for i in range(n):
# Early Candidate Pruning -> : skip used elements
if used[i]:
continue
# Build -> : add nums[i] to path and used tracker
path.append(nums[i])
used[i] = True
# Explore: recurse new path
backtrack()
# Backtrack: remove last choice
path.pop()
used[i] = False
backtrack()
# overall: time complexity
# overall: space complexity
return res
90. Subsets II ::1:: - Medium
Topics: Array, Backtracking, Bit Manipulation
Intro
Given an integer array nums that may contain duplicates, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Return the solution in any order.
Example Input | Output |
---|---|
nums = [1,2,2] | [[],[1],[1,2],[1,2,2],[2],[2,2]] |
nums = [0] | [[],[0]] |
Constraints:
1 ≤ nums.length ≤ 10
-10 ≤ nums[i] ≤ 10
Abstraction
Find all unique subsets of an array.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive DFS Backtracking on Current Subset Path with Duplicate Skipping - Backtracking/Generate While Constraint Satisfaction
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
# Note:
# Recursive DFS backtracking: Track current subset path
# 1. Process Root -> : record current subset path
# 2. Process Choices -> : iterate elements from current index onward
# 3. Prune Duplicates -> skip duplicates at same recursion level
# 4. Backtrack -> remove last element before next choice
# Result: all unique subsets appended to res
res = []
path = []
# Sort to bring duplicates together
nums.sort()
def backtrack(start):
# Process Root -> : record current subset
res.append(path[:])
# Process -> Choices : explore elements from current index onward
for i in range(start, len(nums)):
# Early Candidate Pruning -> : skip duplicates
if i > start and nums[i] == nums[i-1]:
continue
# Build -> : include nums[i] in path
path.append(nums[i])
# Explore -> : recurse to next index
backtrack(i + 1)
# Backtrack -> : remove last element before trying next choice
path.pop()
backtrack(0)
# overall: time complexity
# overall: space complexity
return res
79. Word Search ::2:: - Medium
Topics: Array, String, Backtracking, Depth First Search, Matrix
Intro
Given an m x n grid of characters board and a string word, return true if word exists in the grid. The word can be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once.
Example Input | Output |
---|---|
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED" | true |
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE" | true |
Constraints:
m == board.length
n = board[i].length
1 ≤ m, n ≤ 6
1 ≤ word.length ≤ 15
board and word consists of only lowercase and uppercase English letters.
Follow up: Could you use search pruning to make your solution faster with a larger board?
Abstraction
Find if word exists in grid.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: DFS Backtracking on Current Path - Backtracking/Path Finding in Search Space
def exist(self, board: List[List[str]], word: str) -> bool:
# Note:
# Recursive DFS backtracking: Track current path matching word
# 1. Process Root : check if current path matches word
# 2. Process Choices -> : explore all 4 directions from current cell
# 3. Prune -> : skip cells out of bounds, mismatched letters, or previously visited
# 4. Backtrack -> : restore cell after recursion
# Result: return True if any path matches the word
rows, cols = len(board), len(board[0])
def backtrack(r, c, i):
# Process Root -> : full word matched
if i == len(word):
return True
# Early Candidate Prune -> : out of bounds
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
# Early Candidate Prune -> : letter mismatch
if board[r][c] != word[i]:
return False
# Build -> : mark current cell as visited
tmp, board[r][c] = board[r][c], "#"
# Explore -> : recurse in all 4 directions
found = (backtrack(r+1, c, i+1) or
backtrack(r-1, c, i+1) or
backtrack(r, c+1, i+1) or
backtrack(r, c-1, i+1))
# Backtrack -> : restore cell as unvisited
board[r][c] = tmp
return found
# Try starting DFS from each cell
for r in range(rows):
for c in range(cols):
if backtrack(r, c, 0):
return True
return False
Solution 2: Word Reversal Optimization DFS Backtracking on Current Path - Backtracking/Path Finding in Search Space
def exist(self, board: List[List[str]], word: str) -> bool:
# Note:
# Recursive DFS backtracking with search pruning:
# 1. Count letters on board and work to prune impossible searches
# 2. Reverse word if last letter is rarer than first
# 3. Process Root -> : check if current path matches word
# 4. Process Choices -> : explore all 4 directions
# 5. Prune -> : skip out of bounds, mismatched letters, previously visited
# 6. Backtrack -> : restore board cell after recursion
# Result: return True if any valid path matches word
rows, cols = len(board), len(board[0])
word_count = Counter(word)
# counter for optimization
board_count = Counter(c for row in board for c in row)
# Early Word Prune -> : board doesn't contain enough letters for word
for char, count in word_count.items():
if board_count[char] < count:
return False
# Early Recurse Optimization -> : reverse word if last letter rarer than first
if board_count[word[0]] > board_count[word[-1]]:
word = word[::-1]
def backtrack(r, c, i):
# Process Root -> : reached end of word
if i == len(word):
return True
# Early Candidate Prune -> : out of bounds
if r < 0 or c < 0 or r >= rows or c >= cols:
return False
# Early Candidate Prune : letter mismatch
if board[r][c] != word[i]:
return False
# Build -> : mark cell as visited
tmp, board[r][c] = board[r][c], "#"
# Explore -> : recurse in 4 directions
found = (backtrack(r+1, c, i+1) or
backtrack(r-1, c, i+1) or
backtrack(r, c+1, i+1) or
backtrack(r, c-1, i+1))
# Backtrack -> : restore cell
board[r][c] = tmp
return found
# Process -> Choices : start DFS only from cells matching first letter
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
131. Palindrome Partitioning ::2:: - Medium
Topics: String, Dynamic Programming, Backtracking
Intro
Given a string s, partition s such that every substring of the partition is a palindrome. Return all possible palindrome partitioning of s.
Example Input | Output |
---|---|
s = "aab" | [["a","a","b"],["aa","b"]] |
s = "a" | [["a"]] |
Constraints:
1 ≤ s.length ≤ 16
s contains only lowercase English letters.
Abstraction
Split string s in a way such that every substring of the partition is a palindrome.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: DFS Backtracking on Current Path to Match Word - Backtracking/Path Finding in Search Space
def partition(self, s: str) -> List[List[str]]:
# Note:
# Recursive DFS backtracking: Track current partition path
# 1. Process Root -> : check if end of string reached, record valid partition
# 2. Process Choices -> : try all substrings starting from current index
# 3. Prune -> : skip substring if not palindrome
# 4. Backtrack -> : remove last substring before next choice
# Result: all possible palindrome partitions appended to res
res = []
path = []
# Check if s[l:r+1] substring is a palindrome
def is_palindrome(l, r) -> bool:
while l < r:
if s[l] != s[r]:
return False
l += 1
r -= 1
return True
def backtrack(start):
# Process Root -> : reached end of string, valid partition
if start == len(s):
res.append(path[:])
return
# Process -> Choices : try all substrings starting from current index
for end in range(start, len(s)):
# Prune -> : skip if substring is not palindrome
if is_palindrome(start, end):
# Build -> : add palindromic substring
path.append(s[start:end+1])
# Explore -> : recurse to next starting index
backtrack(end+1)
# Backtrack -> : remove last substring and try next partition
path.pop()
backtrack(0)
# overall: time complexity
# overall: space complexity
return res
Solution 2: DFS Backtracking on Current Path to Match Word - Backtracking/Path Finding in Search Space
def partition(self, s: str) -> List[List[str]]:
# Note:
# Recursive DFS backtracking with precomputed palindrome table:
# 1. Precompute palindromes in O(n^2) to avoid repeated checks
# 2. Process Root -> : check if end of string reached
# 3. Process Choices -> : explore all substring end positions
# 4. Prune -> : skip substrings that are not palindromes
# 5. Backtrack -> : remove last substring before next choice
# Result: all palindrome partitions appended to res
n = len(s)
res = []
path = []
# Palindrome Table: palindromes: True if s[i:j+1] is palindrome, False if
is_pal = [[False]*n for _ in range(n)]
# backwards -> forwards:
# To compute is_pal[0][4], we need is_pal[1][3].
# Since we loop i backwards, by the time we get to i = 0,
# the row i = 1 (all substrings starting at index 1) is already
# filled in.
# iterate backwards
for i in range(n-1, -1, -1):
# iterate forward
for j in range(i, n):
# substring length:
# j - i + 1 <= 3 : if substring length is <= 3
# ex: "a", "aa", "aba"
# inner substrings:
# is_pal[i+1][j-1] : relying on inner substrings
# If the inside s[i+1:j] is a palindrome, and the outer chars match, then s[i:j+1] is also a palindrome.
if s[i] == s[j] and ((j - i + 1) <= 3 or is_pal[i+1][j-1]):
is_pal[i][j] = True
def backtrack(start):
# Process Root -> : reached end of string
if start == n:
res.append(path[:])
return
# Process -> Choices : explore all substring end positions
for end in range(start, n):
# Prune -> : skip if not palindrome
if is_pal[start][end]:
# Build -> : append substring
path.append(s[start:end+1])
# Explore -> : move to next index
backtrack(end+1)
# Backtrack -> : remove last substring
path.pop()
backtrack(0)
# overall: time complexity
# overall: space complexity
return res
17. Letter Combinations of a Phone Number ::1:: - Medium
Topics: Hash Table, String, Backtracking
Intro
Given a string containing digits from 2-9 inclusive, return all possible letter combinations that the number could represent. Return the answer in any order. A mapping of digits to letters (just like on the telephone buttons) is given below. Note that 1 does not map to any letters.
Example Input | Output |
---|---|
digits = "23" | ["ad","ae","af","bd","be","bf","cd","ce","cf"] |
digits = "" | [] |
digits = "2" | ["a","b","c"] |
Constraints:
0 ≤ digits.length ≤ 4
digits[i] is a digit in the range ['2', '9'].
Abstraction
Given a mini phone number, find all the possible letter combinations.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive DFS Backtracking on Current Letter Path - Backtracking/Generate All Combinations
def letterCombinations(self, digits: str) -> List[str]:
# Note:
# Recursive DFS backtracking: Track current letter path
# 1. Process Root -> : path length equals digits length, record combination
# 2. Process Choices -> : letters corresponding to current digit
# 3. Build -> : append letter to path
# 4. Explore -> : recurse to next digit
# 5. Backtrack -> : remove last letter before trying next option
# Result: all possible letter combinations appended to res
# Empty check
if not digits:
return []
# Map digits to letters
digit_map = {
"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
"6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
}
res = []
path = []
def backtrack(index):
# Process Root -> : current path has full length
if index == len(digits):
res.append("".join(path))
return
# Process -> Choices : letters corresponding to current digit
for char in digit_map[digits[index]]:
# Build -> : choose current letter
path.append(char)
# Explore -> : move to next digit
backtrack(index + 1)
# Backtrack -> : remove last letter to try next option
path.pop()
backtrack(0)
# overall: time complexity
# overall: space complexity
return res
51. N Queens ::1:: - Hard
Topics: Array, Backtracking
Intro
The n-queens puzzle is the problem of placing n queens on an n x n chessboard such that no two queens attack each other. Given an integer n, return all distinct solutions to the n queens puzzle. You may return the answer in any order. Each solution contains a distinct board configuration of the n queens' placement, where 'Q' and '.' both indicate a queen and an empty space, respectively.
Example Input | Output |
---|---|
n = 4 | [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]] |
n = 1 | [["Q"]] |
Constraints:
1 ≤ n ≤ 9
Abstraction
Find all the possible ways to possible n queens on a board of n x n size.
Space & Time Complexity
Solution | Time Complexity | Space Complexity | Time Remark | Space Remark |
---|---|---|---|---|
Bug | Error |
---|---|
Brute Force:
Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
---|---|---|---|---|
Find the Bug:
Solution 1: Recursive DFS Backtracking on Current Row with Column/Diagonal Tracking - Backtracking/Combinatorial Optimization
def solveNQueens(self, n: int) -> List[List[str]]:
# Note:
# Recursive DFS backtracking: Track current row placement
# 1. Process Root -> : all rows filled, record board configuration
# 2. Process Choices -> : iterate columns in current row
# 3. Prune -> : skip if column or diagonals threatened
# 4. Build -> : place queen
# 5. Explore -> : recurse to next row
# 6. Backtrack -> : remove queen, free column/diagonals
# Result: all valid board configurations appended to res
res = []
# n x n size board
board = [["."] * n for _ in range(n)]
# 3 Sets to track threatened positions:
# Columns occupied
cols = set()
# Rows occupied
# rows = set() <- inherently tracked by recursion, no need for this
# r - c diagonals: all cells on this diagonal have same value of row - col
diag1 = set()
# r + c diagonals: all cells on this diagonal have same value of row + col
diag2 = set()
def dfs_backtrack(row: int):
# Process Root -> : all queens placed successfully, append board
if row == n:
res.append(["".join(r) for r in board])
return
# Process -> Choices : columns in current row
for col in range(n):
# Prune -> : skip if column or diagonals threatened
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue
# Build -> : place queen at (row, col)
board[row][col] = "Q"
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
# Explore -> : move to next row
# due to (row+1), we will never have overlapping rows
dfs_backtrack(row + 1)
# Backtrack -> : remove queen and free column/diagonals
board[row][col] = "."
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
dfs_backtrack(0)
# overall: time complexity
# overall: space complexity
return res
Solution 2: Recursive DFS Backtracking Using Bitmasking for Columns/Diagonals - Backtracking/Combinatorial Optimization
def solveNQueens(self, n: int) -> List[List[str]]:
# Note:
# Recursive DFS with bitmasking: Track available columns/diagonals
# 1. Process Root -> : all rows filled, convert path to board
# 2. Process Choices -> : available positions using bitmask
# 3. Build -> : pick rightmost available position
# 4. Explore -> : recurse with updated masks and path
# 5. Backtrack -> : implicit via new path list
# Result: all valid board configurations appended to res
res = []
path = []
def backtrack(row, cols, diag1, diag2):
# Process Root -> : all queens placed successfully
if row == n:
board = []
for r in path:
row_str = ["." for _ in range(n)]
row_str[r] = "Q"
board.append("".join(row_str))
res.append(board)
return
# Process -> Choices : calculate available positions with bitmasking
available = ((1 << n) - 1) & (~(cols | diag1 | diag2))
while available:
# Build -> : pick rightmost available position
pos = available & -available
available &= available - 1 # Remove chosen position
col = (pos - 1).bit_length()
path.append(col)
# Explore -> : recurse to next row with updated masks
backtrack(
row + 1,
cols | pos,
(diag1 | pos) << 1,
(diag2 | pos) >> 1
)
# Backtrack -> : remove last
path.pop()
backtrack(0, 0, 0, 0)
# overall: time complexity
# overall: space complexity
return res