Backtrack
Last updated: Jul 28, 2026Table of Contents
- Overview
- Key Properties
- The 3 things every backtrack tracks
- Universal Template
- Time Complexity by Problem Type
- References
- 0) Concept
- 0-0) start_idx — When & Why?
- ✅ Problems that NEED start_idx
- ❌ Problems that DO NOT use start_idx
- 🧭 Summary Table
- 0-1) i vs i + 1 as the next start_idx in the recursive call
- 0-2) Problem Types
- 0-3) Pruning & Partitioning Patterns
- 0-4) Advanced Backtracking Patterns
- 1) General form
- 1-1) Basic OP
- 1-2) Trick
- 1-3) if true, return true right after recursive call
- 2) LC Example
- 2-1) Letter Combinations of a Phone Number — LC 17
- 2-2) combination-sum — LC 39
- 2-3) Word Search — LC 79
- 2-4) Subsets — LC 78
- 2-4’) Subsets II — LC 90
- 2-5) Combinations — LC 77
- 2-6) Permutations — LC 46
- 2-7) Generate Parentheses — LC 22
- 2-8) Palindrome Partitioning — LC 131
- 2-9) Restore IP Addresses — LC 93
- 2-10) Word Break — LC 139
- 2-11) Word Break II — LC 140
- 2-12) Course Schedule — LC 207
- 2-13) Path Sum II — LC 113
- 3) Constraint-Satisfaction Patterns (Interview Favorites)
- 3-1) N-Queens — LC 51
- 3-2) Sudoku Solver — LC 37
- 3-3) Constraint Propagation (Early Termination)
- 3-4) Backtracking Complexity Cheat Sheet
- 3-5) Termination Condition Patterns
- 3-6) Interview Signal → Pattern
Backtracking
Overview
Backtracking is a brute-force search over a decision tree: at each step we make a choice, recurse deeper, then undo the choice (“backtrack”) to try the next one. It is the go-to pattern for generating all subsets / permutations / combinations, or finding any/one valid configuration under constraints (N-Queens, Sudoku, word search).
Key Properties
- Core idea:
choose → explore (recurse) → un-choose (undo) - Time Complexity: exponential —
O(b^d), whereb= branching factor,d= decision-tree depth - Space Complexity:
O(d)recursion depth (excluding the output list) - When to Use: the problem asks for all / every / how many configurations, or to place / fill / partition under constraints
- Optimization path: Backtrack (brute force) → add pruning → often → DP (memoize overlapping subproblems)
The 3 things every backtrack tracks
| Element | Meaning |
|---|---|
| Route (路徑) | choices made so far (the current path) |
| Choice list (選擇清單) | choices available right now |
| End condition | leaf of the decision tree — record the route and return |

Universal Template
# python
result = []
def backtrack(route, choices):
if end_condition: # reached a leaf
result.append(route[:]) # snapshot (copy!) the route
return
for choice in choices:
route.append(choice) # 1) make choice
backtrack(route, choices) # 2) explore
route.pop() # 3) undo choice (backtrack)
Two knobs turn this template into every variant:
start_idx— controls the search space (combinations/subsets vs permutations)- early quit / pruning — cut branches that cannot lead to a valid answer
Time Complexity by Problem Type
| Problem type | Typical Time | Space (excl. output) | Example |
|---|---|---|---|
| Subsets | O(2^n · n) | O(n) | LC 78, 90 |
| Permutations | O(n! · n) | O(n) | LC 46, 47 |
| Combinations | O(C(n,k) · k) | O(k) | LC 77 |
| Combination Sum | exponential | O(target / min) | LC 39, 40 |
| Partitioning | O(2^n · n) | O(n) | LC 131 |
| N-Queens | O(n!) | O(n) | LC 51 |
The trailing
· n/· kis the cost of copying each valid path into the result. Pruning trims branches and the constant factor but does not change the worst-case class.
References
- labuladong — Backtracking framework
- labuladong — Two views of backtracking
- LeetCode — A general approach to backtracking (Java)
0) Concept
- Algorithm: DFS + recursion
- Common data structures:
dict(counter for dedup),set(visited / constraints),array/list(the route)
0-0) start_idx — When & Why?
start_idx (or index, or similar) is used to control the search space — to avoid duplicates and maintain order in the generated result.
-> Use start_idx when:
- You’re generating combinations/subsets
- You want to avoid duplicates
- You want to preserve order of choices
✅ Problems that NEED start_idx
These typically involve combinations, subsets, or multi-use elements, where:
- Order doesn’t matter (e.g.,
[2,3]is same as[3,2]) - You want to
AVOIDrevisiting earlier choices - You may reuse elements or pick each element once
🔹 Examples:
| Problem | Use of start_idx |
Why? |
|---|---|---|
Subsets (Leetcode 78) |
✅ Yes | To avoid duplicate subsets |
Combination Sum (Leetcode 39) |
✅ Yes | Reuse allowed, but in order |
Combination Sum II (Leetcode 40) |
✅ Yes | No reuse, skip duplicates |
Combinations (Leetcode 77) |
✅ Yes | Choose k out of n, in order |
Palindrome Partitioning |
✅ Yes | Explore substrings from start |
❌ Problems that DO NOT use start_idx
These are often permutation problems, where:
- Order does matter
- You want to try all possible orders
- You should revisit earlier choices (sometimes)
-> Don’t use start_idx when:
- You’re generating permutations
- You need all orderings
- Choices are not sequential (e.g., trying all positions)
🔹 Examples:
| Problem | Use of start_idx |
Why Not? |
|---|---|---|
Permutations (Leetcode 46) |
❌ No | All orderings are valid |
Permutations II (Leetcode 47) |
❌ No | Just skip duplicates smartly |
N-Queens |
❌ No | One row per recursion depth |
Word Break II |
❌ No | Choices depend on substring matches |
🧭 Summary Table
| Problem Type | Use start_idx? |
Example Problem |
|---|---|---|
| Subsets | ✅ Yes | Leetcode 78 |
| Combinations | ✅ Yes | Leetcode 77 |
| Combination Sum | ✅ Yes | Leetcode 39 |
| Permutations | ❌ No | Leetcode 46 |
| N-Queens | ❌ No | Leetcode 51 |
| Partitioning | ✅ Yes | Leetcode 131 |
0-1) i vs i + 1 as the next start_idx in the recursive call
Once you’ve decided you need a start_idx, the next question is what to pass as the
next start index — i (reuse the current element) or i + 1 (move past it).
| Pass | Meaning | Analogy | Examples |
|---|---|---|---|
i |
Reuse the same element again | Unbounded knapsack (infinite supply) | LC 39 (Combination Sum), LC 518 (Coin Change II), LC 377 |
i + 1 |
Use each element at most once | 0/1 knapsack, subsets | LC 40 (Combination Sum II), LC 78/90 (Subsets), LC 131, LC 494 |
Permutations use neither — they revisit earlier elements, so they track a
visited[]array /containscheck instead of astart_idx(see 0-2) Problem Types).
// LC 39 Combination Sum — reuse allowed → pass i
for (int i = start; i < candidates.length; i++) {
backtrack(i, remain - candidates[i]); // can pick candidates[i] again
}
// LC 40 Combination Sum II — each used once → pass i + 1
for (int i = start; i < candidates.length; i++) {
backtrack(i + 1, remain - candidates[i]); // move past candidates[i]
}
Key takeaway: reuse allowed →
i; use once →i + 1.
0-2) Problem Types
-
Conclusion:
-
NONEEDstart idx: 全排列 (Permutations)- Permutations (排列組合)
-
NEED
start idx: other backtrack problems- Subsets (子集)
- Combinations (組成)
- combination Sum (LC 39)
- partitioning
-
-
Problems types
-
Type 1) :
Subsets(子集)- Problems : LC 78, 90, 17
- 代碼隨想錄 - 0078.子集
- (for loop call help func) + start_idx + for loop + pop(-1)
- backtrack. find minumum case. transform the problem to
tree-problem. viastartremove already used numbers and return all cases - Need
!cur.contains(nums[i])-> to NOT add duplicated element
python# V1 # ... cur = [] res = [] def help(start_idx, cur): # .... """ NOTE !!! start_idx we need start_idx here to AVOID re-select previous element """ for i in range(start_idx, len(wordDict)): cur.append(wordDict[i]) help(i + 1, cur) """ NOTE !!! pop(-1) """ cur.pop(-1) # ...java// java public List<List<Integer>> subsets(int[] nums) { // ... this.getSubSet(start_idx, nums, cur, res); //System.out.println("(after) res = " + res); return res; } public void getSubSet(int start_idx, int[] nums, List<Integer> cur, List<List<Integer>> res){ if (!res.contains(cur)){ // NOTE !!! init new list via below res.add(new ArrayList<>(cur)); } if (cur.size() > nums.length){ return; } for (int i = start_idx; i < nums.length; i++){ /** * NOTE !!! * * for subset, * we need "!cur.contains(nums[i])" * -> to NOT add duplicated element */ if (!cur.contains(nums[i])){ cur.add(nums[i]); /** * NOTE !!! * * at LC 78 subset, we need to use `i+1` idx * in recursive call * * while at LC 39 Combination Sum, * we use `i` directly * * * e.g. next start_idx is ` i+1` */ this.getSubSet(i+1, nums, cur, res); // undo cur.remove(cur.size()-1); } } } -
Subsets I- LC 78
- start idx + backtrack (see the
Type 1) SubsetsJava code above, and the full walkthrough in 2-4) Subsets — LC 78)
-
Subsets II- LC 90
- start idx + backtrack + dedup (seen)
- dedup : can use dict counter or idx
java// java // LC 90 private void backtrack(List<List<Integer>> list, List<Integer> tempList, int [] nums, int start){ list.add(new ArrayList<>(tempList)); for(int i = start; i < nums.length; i++){ // skip duplicates /** * NOTE !!! * * below is the key shows how to simply skip duplicates * (instead of using hashmap counter) */ if(i > start && nums[i] == nums[i-1]){ continue; } tempList.add(nums[i]); backtrack(list, tempList, nums, i + 1); tempList.remove(tempList.size() - 1); } } -
Type 2) :
Permutations (排列組合)(全排列)- Problems : LC 46, 47
- (for loop call help func) + contains + pop(-1)
- backtrack. via
containsremove already used numbers and return all cases NO NEEDto use start_idx
python# ... res = [] cur = [] def help(cur): if len(cur) == len(s): res.append(cur[:]) return if len(cur) > len(s): return for i in nums: # NOTE this !!! if i not in cur: cur.append(i) help(cur) cur.pop(-1) # ... -
Permutations I (排列組合)- LC 46
python# python class Solution(object): def permute(self, nums): def help(cur): if len(cur) == n_len: if cur not in res: res.append(list(cur)) return if len(cur) > n_len: return for i in nums: #print ("i = " + str(i) + " cur = " + str(cur)) if i not in cur: cur.append(i) help(cur) """ NOTE !!! : we UNDO the last op we just made (pop last element we put into array) """ cur.pop(-1) # edge case if not nums: return [[]] n_len = len(nums) res = [] help([]) #print ("res = " + str(res)) return res ``` -
Permutations II (排列組合)- LC 47
python# python class Solution(object): def permuteUnique(self, nums): def help(res, cur, cnt): if len(cur) == len(nums): if cur not in res: res.append(cur[:]) return if len(cur) > len(nums): return for x in _cnt: #print ("i = " + str(i) + " cur = " + str(cur)) #if i not in cur: if _cnt[x] > 0: cur.append(x) _cnt[x] -= 1 help(res, cur, _cnt) """ NOTE !!! : we UNDO the last op we just made (pop last element we put into array) """ cur.pop(-1) _cnt[x] += 1 # edge case if not nums: return [[]] _cnt = Counter(nums) #print ("_cnt = " + str(_cnt)) res = [] cur = [] help(res, cur, _cnt) return res -
Type 3) :
Combinations (組成)- LC 77
- (for loop call help func) + start_idx + for loop + + check if len == k + pop(-1)
python# ... cur = [] res = [] def help(idx, cur): if len(cur) == k: res.append(cur) return for i in range(idx, n+1): cur.append(i) help(idx+1, cur) cur.pop(-1) # ... -
Type 4) :
Others -
Parentheses (括弧)
- LC 20, LC 22
-
Combination Sum
- LC 39
java// java // https://leetcode.com/problems/subsets/solutions/27281/a-general-approach-to-backtracking-questions-in-java-subsets-permutations-combination-sum-palindrome-partitioning/ public List<List<Integer>> combinationSum(int[] nums, int target) { List<List<Integer>> list = new ArrayList<>(); Arrays.sort(nums); backtrack(list, new ArrayList<>(), nums, target, 0); return list; } private void backtrack(List<List<Integer>> list, List<Integer> tempList, int [] nums, int remain, int start){ if(remain < 0) return; else if(remain == 0) list.add(new ArrayList<>(tempList)); else{ for(int i = start; i < nums.length; i++){ tempList.add(nums[i]); /** NOTE !!! * * use i, since we need to use start from current (i) index in recursion call * (reuse current index) */ backtrack(list, tempList, nums, remain - nums[i], i); tempList.remove(tempList.size() - 1); } } } -
Combination Sum II
- LC 40
java// java // https://leetcode.com/problems/subsets/solutions/27281/a-general-approach-to-backtracking-questions-in-java-subsets-permutations-combination-sum-palindrome-partitioning/ public List<List<Integer>> combinationSum2(int[] nums, int target) { List<List<Integer>> list = new ArrayList<>(); Arrays.sort(nums); backtrack(list, new ArrayList<>(), nums, target, 0); return list; } private void backtrack(List<List<Integer>> list, List<Integer> tempList, int [] nums, int remain, int start){ if(remain < 0) return; else if(remain == 0) list.add(new ArrayList<>(tempList)); else{ for(int i = start; i < nums.length; i++){ if(i > start && nums[i] == nums[i-1]) continue; // skip duplicates tempList.add(nums[i]); backtrack(list, tempList, nums, remain - nums[i], i + 1); tempList.remove(tempList.size() - 1); } } } -
Palindrome Partitioning
- LC 131
java// java // https://leetcode.com/problems/subsets/solutions/27281/a-general-approach-to-backtracking-questions-in-java-subsets-permutations-combination-sum-palindrome-partitioning/ public List<List<String>> partition(String s) { List<List<String>> list = new ArrayList<>(); backtrack(list, new ArrayList<>(), s, 0); return list; } public void backtrack(List<List<String>> list, List<String> tempList, String s, int start){ if(start == s.length()) list.add(new ArrayList<>(tempList)); else{ for(int i = start; i < s.length(); i++){ if(isPalindrome(s, start, i)){ tempList.add(s.substring(start, i + 1)); // NOTE !!! `i+1` backtrack(list, tempList, s, i + 1); tempList.remove(tempList.size() - 1); } } } } public boolean isPalindrome(String s, int low, int high){ while(low < high) if(s.charAt(low++) != s.charAt(high--)) return false; return true; }
-
0-3) Pruning & Partitioning Patterns
Pruning Techniques
Definition: Optimization methods to reduce the search space by eliminating branches that cannot lead to valid solutions.
Types of Pruning:
1. Constraint-based Pruning
- Early termination when constraints are violated
- Check validity before recursive calls
2. Bound-based Pruning
- Use upper/lower bounds to eliminate suboptimal paths
- Branch and bound technique
3. Symmetry Pruning
- Skip equivalent states to avoid duplicates
- Sort inputs to handle permutations
4. Memoization Pruning
- Cache results of subproblems
- Avoid recomputing same states
Common Pruning Patterns:
def backtrack_with_pruning(path, choices, target):
# Early termination (constraint pruning)
if current_sum > target:
return # No need to continue
# Bound pruning
if current_sum + min_remaining > target:
return # Cannot reach target
# Base case
if len(path) == target_length:
if is_valid(path):
result.append(path[:])
return
# Symmetry pruning
for i in range(start_idx, len(choices)):
# Skip duplicates (symmetry pruning)
if i > start_idx and choices[i] == choices[i-1]:
continue
# Make choice
path.append(choices[i])
# Recursive call with pruning
backtrack_with_pruning(path, choices, target)
# Undo choice
path.pop()
Pruning Examples:
1. Sum-based Pruning (LC 39 Combination Sum):
def combinationSum(candidates, target):
def backtrack(start, path, current_sum):
# Pruning: if current sum exceeds target
if current_sum > target:
return
if current_sum == target:
result.append(path[:])
return
for i in range(start, len(candidates)):
# Pruning: if adding current number exceeds target
if current_sum + candidates[i] > target:
break # Since array is sorted
path.append(candidates[i])
backtrack(i, path, current_sum + candidates[i])
path.pop()
candidates.sort() # Enable break pruning
result = []
backtrack(0, [], 0)
return result
2. Duplicate Pruning (LC 40 Combination Sum II):
def combinationSum2(candidates, target):
def backtrack(start, path, current_sum):
if current_sum == target:
result.append(path[:])
return
for i in range(start, len(candidates)):
# Pruning: skip duplicates at same level
if i > start and candidates[i] == candidates[i-1]:
continue
# Pruning: early termination
if current_sum + candidates[i] > target:
break
path.append(candidates[i])
backtrack(i + 1, path, current_sum + candidates[i])
path.pop()
candidates.sort()
result = []
backtrack(0, [], 0)
return result
3. Bound Pruning (LC 698 Partition to K Equal Sum Subsets):
def canPartitionKSubsets(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True) # Pruning: try larger numbers first
def backtrack(index, buckets):
if index == len(nums):
return all(bucket == target for bucket in buckets)
for i in range(k):
# Pruning: skip if adding exceeds target
if buckets[i] + nums[index] > target:
continue
# Pruning: avoid duplicate empty buckets
if i > 0 and buckets[i] == buckets[i-1]:
continue
buckets[i] += nums[index]
if backtrack(index + 1, buckets):
return True
buckets[i] -= nums[index]
return False
return backtrack(0, [0] * k)
Partitioning Patterns
Definition: Divide input into groups or segments based on certain criteria.
Common Partitioning Types:
1. Equal Sum Partitioning
- Divide array into groups with equal sums
- Examples: LC 416 (Partition Equal Subset Sum), LC 698 (K Equal Sum Subsets)
2. Palindromic Partitioning
- Split string into palindromic substrings
- Examples: LC 131 (Palindrome Partitioning), LC 132 (Palindrome Partitioning II)
3. Subset Partitioning
- Group elements based on constraints
- Examples: LC 90 (Subsets II), LC 47 (Permutations II)
Partitioning Templates:
1. String Partitioning Template:
def partition_string(s, is_valid_partition):
def backtrack(start, current_partition):
if start == len(s):
result.append(current_partition[:])
return
for end in range(start + 1, len(s) + 1):
substring = s[start:end]
if is_valid_partition(substring):
current_partition.append(substring)
backtrack(end, current_partition)
current_partition.pop()
result = []
backtrack(0, [])
return result
2. Array Partitioning Template:
def partition_array(nums, k, target_sum):
def backtrack(index, groups):
if index == len(nums):
return all(sum(group) == target_sum for group in groups)
for i in range(k):
if sum(groups[i]) + nums[index] <= target_sum:
groups[i].append(nums[index])
if backtrack(index + 1, groups):
return True
groups[i].pop()
# Pruning: if current group is empty, no need to try other empty groups
if not groups[i]:
break
return False
return backtrack(0, [[] for _ in range(k)])
Partitioning Examples:
1. Palindrome Partitioning (LC 131):
def partition(s):
def is_palindrome(string):
return string == string[::-1]
def backtrack(start, path):
if start == len(s):
result.append(path[:])
return
for end in range(start + 1, len(s) + 1):
substring = s[start:end]
if is_palindrome(substring):
path.append(substring)
backtrack(end, path)
path.pop()
result = []
backtrack(0, [])
return result
2. Partition Equal Subset Sum (LC 416):
def canPartition(nums):
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
def backtrack(index, current_sum):
if current_sum == target:
return True
if index >= len(nums) or current_sum > target:
return False
# Include current number
if backtrack(index + 1, current_sum + nums[index]):
return True
# Exclude current number
return backtrack(index + 1, current_sum)
return backtrack(0, 0)
3. Partition to K Equal Sum Subsets (LC 698):
def canPartitionKSubsets(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True) # Start with larger numbers
def backtrack(index, buckets):
if index == len(nums):
return True
for i in range(k):
# Pruning techniques
if buckets[i] + nums[index] > target:
continue
if i > 0 and buckets[i] == buckets[i-1]:
continue
buckets[i] += nums[index]
if backtrack(index + 1, buckets):
return True
buckets[i] -= nums[index]
return False
return backtrack(0, [0] * k)
Advanced Partitioning Techniques:
1. Memoized Partitioning:
def partition_with_memo(nums):
memo = {}
def backtrack(index, state_tuple):
if index == len(nums):
return check_valid_partition(state_tuple)
if state_tuple in memo:
return memo[state_tuple]
result = False
for choice in get_choices(index, state_tuple):
new_state = update_state(state_tuple, choice)
if backtrack(index + 1, new_state):
result = True
break
memo[state_tuple] = result
return result
return backtrack(0, initial_state)
2. Optimized Partitioning with Early Termination:
def optimized_partition(nums, k):
def backtrack(index, groups, remaining_sum):
if index == len(nums):
return remaining_sum == 0
# Pruning: if remaining sum is too small
if remaining_sum < 0:
return False
for i in range(len(groups)):
groups[i].append(nums[index])
if backtrack(index + 1, groups, remaining_sum - nums[index]):
return True
groups[i].pop()
# Important pruning: don't try other empty groups
if len(groups[i]) == 0:
break
return False
return backtrack(0, [[] for _ in range(k)], sum(nums))
Pruning and Partitioning Comparison:
| Technique | Purpose | When to Use | Complexity Impact |
|---|---|---|---|
| Constraint Pruning | Early termination | Invalid states | Reduces branches significantly |
| Bound Pruning | Eliminate suboptimal paths | Optimization problems | O(2^n) → O(n!) potential |
| Symmetry Pruning | Avoid duplicates | Permutation problems | Eliminates factorial duplicates |
| Equal Sum Partition | Divide into equal groups | Subset sum problems | Exponential to polynomial |
| String Partition | Split by criteria | String segmentation | O(2^n) worst case |
0-4) Advanced Backtracking Patterns
# python pseudo code 1
# https://leetcode.com/explore/learn/card/recursion-ii/472/backtracking/2793/
def backtrack(candidate):
if find_solution(candidate):
output(candidate)
return
# iterate all possible candidates.
for next_candidate in list_of_candidates:
if is_valid(next_candidate):
# try this partial candidate solution
place(next_candidate)
# given the candidate, explore further.
backtrack(next_candidate)
# backtrack
remove(next_candidate)
# python pseudo code 2
for choice in choice_list:
# do choice
routes.add(choice)
backtrack(routes, choice_list)
# undo choice
routes.remove(choice)
# python pseudo code 3
result = []
def backtrack(route, choice_list):
if end_condition:
result.add(route)
return
for choice in choice_list:
### core of backtrack
do_choice ### this one is necessary
backtrack(route, choice_list)
undo_choice ### this one is necessary
1) General form
1-1) Basic OP
The canonical choose → explore → un-choose skeleton, ready to adapt:
# python
def backtrack(start_idx, path):
if end_condition: # e.g. len(path) == k, or start_idx == len(s)
res.append(path[:]) # NOTE: copy the path, not the reference
return
for i in range(start_idx, n):
path.append(nums[i]) # 1) choose
backtrack(i + 1, path) # 2) explore (i -> reuse, i+1 -> use once)
path.pop() # 3) un-choose (undo)
res = []
backtrack(0, [])
// java
private void backtrack(int startIdx, List<Integer> path, int[] nums, List<List<Integer>> res) {
if (endCondition) { // e.g. path.size() == k
res.add(new ArrayList<>(path)); // NOTE: copy the path, not the reference
return;
}
for (int i = startIdx; i < nums.length; i++) {
path.add(nums[i]); // 1) choose
backtrack(i + 1, path, nums, res); // 2) explore (i -> reuse, i+1 -> use once)
path.remove(path.size() - 1); // 3) un-choose (undo)
}
}
1-2) Trick
1-2-1) append to cache with idx
# LC 131. Palindrome Partitioning
# ...
def help(s, res, path):
if not s:
res.append(path)
return
for i in range(1, len(s) + 1): # NOTE: +1 so the whole remaining string can be the last piece
if s[:i] == s[:i][::-1]:
"""
NOTE below !!!
-> we call help recursively with s[i:] subset
-> we append [s[:i]] to tmp cache (path)
"""
help(s[i:], res, path + [s[:i]])
# ...
1-2-2) avoid add duplicated element in same level (same recursion call)
// LC 40
// java
// ...
/**
* NOTE !!! skip a duplicate at the same recursive level.
*
* -> Key idea of the duplicate-skipping logic
* • We do not skip all duplicates.
* • We only skip a duplicate at the same recursive level.
*/
if (i > startIdx && candidates[i] == candidates[i - 1]) {
continue;
}
// ...
// LC 90
// java
// ...
for (int j = i; j < nums.length; j++) {
/**
* NOTE !!! below !!
*
* via below, we avoid add `duplicated` element
*
*/
if (j > i && nums[j] == nums[j - 1]) {
continue;
}
// ...
}
// ...
// java
// LC 47
// ...
// Skip duplicates in the same recursion layer
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])
continue;
// ...
1-2-3) NOT do undo on primary variable
// java
// LC 79
// https://github.com/yennanliu/CS_basics/blob/master/leetcode_java/src/main/java/LeetCodeJava/BackTrack/WordSearch.java#L133
// In Java, primitive types like int are passed by value. This means when you do:
// dfsFind(board, word, x+1, y, visited, start_idx + 1)
// 1) You're passing a copy of start_idx + 1 to the recursive function. So, inside the recursive call, start_idx is a new variable, and changes to it won't affect the start_idx in the calling function.
// 2) We don't need start_idx -= 1; because start_idx is passed by value, not by reference. So modifying it in the recursive call doesn't affect the caller's start_idx. We're already handling the correct index in each recursive call by passing start_idx + 1.
Important Note: When Backtracking is NOT Needed
// LC 1740
// NOTE !!! we don't need a `backtrack` below,
// since `int` is a `primitive dtype in java
// -> Each recursive call gets its own copy of move.
// if we use dtype such as Mutable shared state (e.g. List, Set)
// we need a backtrack (undo)
private int getPathLen(TreeNode root, int target, int dist) {
if (root == null) {
return -1; // not found
}
if (root.val == target) {
return dist;
}
int left = getPathLen(root.left, target, dist + 1);
if (left != -1) {
return left;
}
int right = getPathLen(root.right, target, dist + 1);
// NOTE !!! we don't need a `backtrack` below,
// since `int` is a `primitive dtype in java
// -> Each recursive call gets its own copy of move.
// if we use dtype such as Mutable shared state (e.g. List, Set)
// we need a backtrack (undo)
return right;
}
When to Use Backtracking (Undo):
| Data Type | Need Backtrack? | Reason |
|---|---|---|
Primitive types (int, char, boolean, etc.) |
❌ No | Passed by value; each recursive call gets its own copy |
Mutable objects (List, Set, Map, StringBuilder, etc.) |
✅ Yes | Passed by reference; modifications affect all recursive calls |
Immutable objects (String, Integer, etc.) |
❌ No | Modifications create new instances |
Example: When Backtracking IS Needed:
// Mutable shared state - NEEDS backtracking
void backtrack(List<Integer> path, int[] nums) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int num : nums) {
path.add(num); // modify shared state
backtrack(path, nums); // recursive call
path.remove(path.size()-1); // MUST undo - backtrack!
}
}
Example: When Backtracking is NOT Needed:
// Primitive types - NO backtracking needed
int dfs(TreeNode root, int depth) {
if (root == null) return depth;
int left = dfs(root.left, depth + 1); // depth + 1 creates a NEW value
int right = dfs(root.right, depth + 1); // depth is unchanged for right call
// No need to do: depth -= 1;
// because depth was passed by value
return Math.max(left, right);
}
Python equivalent — int accumulator vs list path (LC 113 Path Sum II) ⭐
The same rule holds in Python. In a DFS that carries both a running sum (cur_sum,
an int) and a path (cache, a list), we cache.pop() but never “un-add”
cur_sum:
cur_sum(int) — NO backtrack. Integers are immutable.cur_sum += root.valdoes NOT change the parent’s integer in place; it rebinds the localcur_sumto a brand-new int object. When the child frame ends, the parent’scur_sumis untouched.cache(list) — NEEDS backtrack. There is only one list instance shared across the whole recursion tree. A child’sappendis visible to the parent, so we MUSTpop()to restore state for the sibling branch.
# python
# LC 113 - Path Sum II
# https://github.com/yennanliu/CS_basics/blob/master/leetcode_python/Depth-First-Search/path-sum-ii.py
class Solution(object):
def pathSum(self, root, targetSum):
self.res = []
if not root:
return self.res
self.helper(root, targetSum, 0, [])
return self.res
def helper(self, root, targetSum, cur_sum, cache):
if not root:
return
cur_sum += root.val # int -> rebinds LOCAL name to a NEW int (immutable)
cache.append(root.val) # list -> mutates the ONE shared list
if not root.left and not root.right and cur_sum == targetSum:
self.res.append(cache[:]) # snapshot, else later pops corrupt it
self.helper(root.left, targetSum, cur_sum, cache)
self.helper(root.right, targetSum, cur_sum, cache)
cache.pop() # MUST backtrack the list ...
# NOTE: NO `cur_sum -= root.val` — the int never changed for the parent
Memory walk-through — parent at cur_sum = 5, cache = [5], step into a child of value 3:
| Going DOWN into child | Coming back UP to parent | |
|---|---|---|
cache (list) |
cache.append(3) → [5, 3] (same object) |
without pop() it stays [5, 3] → parent corrupted → backtrack required |
cur_sum (int) |
cur_sum + 3 → 8 (new int, local) |
child frame destroyed → parent’s cur_sum still 5 → no backtrack needed |
See also python_trick.md §1-54 —
str/tuple/int(immutable, no backtrack) vslist.append(mutable, needspop).
1-3) if true, return true right after recursive call
// java
// LC 698
// ...
if (backtrack_(nums, j + 1, k, subsetSum + nums[j], used)){
return true;
}
// ...
2) LC Example
2-1) Letter Combinations of a Phone Number — LC 17
// java
// LC 17
// V0
// IDEA: BACKTRACK + start_idx (on digit)
List<String> _res = new ArrayList<String>();
public List<String> letterCombinations(String _digits) {
if (_digits.length() == 0){
return new ArrayList<>();
}
HashMap<java.lang.String, java.lang.String> letters = new HashMap<>();
letters.put("2", "abc");
letters.put("3", "def");
letters.put("4", "ghi");
letters.put("5", "jkl");
letters.put("6", "mno");
letters.put("7", "pqrs");
letters.put("8", "tuv");
letters.put("9", "wxyz");
_letter_builder(letters, 0, _digits, new StringBuilder());
return this._res;
}
private void _letter_builder(HashMap<String, String> map, int start_idx, String digits, StringBuilder builder){
/**
* NOTE !!!
*
* if builder (StringBuilder) length equals digits length,
* -> means we first one of the `all digit visit`
* -> we should add this cur to our result
*/
if (builder.length() == digits.length()){
this._res.add(builder.toString()); // NOTE this
return;
}
/**
* NOTE !!!
*
*
* 1) the `start_idx` is for `digits` .
* e.g.
*
* -> if digits = "23",
* the start_idx is 0,
* and could become 1, ...
*
*
* 2) via `start_idx` we can focus on specific digit (e.g. "2" only, from "23")
* then we can loop over its `alphabet` in recursive call
* e.g. "abc" for "2"
*
* letters.put("2", "abc");
*
*/
String _digit = String.valueOf(digits.toCharArray()[start_idx]); // NOTE this
String _alphabets = map.get(_digit);
// backtrack
/**
* NOTE !!!
*
* we loop over `_alphabets` (digit with idx),
* (instead of digit)
*
* -> so we can build our cur string accordingly
*
*/
for (char a : _alphabets.toCharArray()){
builder.append(a);
_letter_builder(map, start_idx + 1, digits, builder);
// undo
// builder.deleteCharAt(0); // NOTE !!! in backtrack, we remove LAST element (idx = len - 1), instead of first element
builder.deleteCharAt(builder.toString().length() - 1);
// no need to `undo` start_idx, since it's primary type
// in java, it is copied as `new var` when pass the recursive call
// backtrack.html#1-2-3-not-do-undo-on-primary-variable
// start_idx -= 1; // this is WRONG!!!
}
}
# 017 Letter Combinations of a Phone Number
# V0
# IDEA : backtracking
class Solution(object):
def letterCombinations(self, digits):
# help func
def help(idx, cur):
if len(cur) == len(digits):
tmp = "".join(cur[:])
res.append(tmp)
cur = []
return
if len(cur) > len(digits):
cur = []
return
for a in d[digits[idx]]:
cur.append(a)
help(idx+1, cur)
cur.pop(-1) # NOTE this !!! : we pop last element
# edge case
if not digits:
return []
res = []
cur = []
idx = 0
d = {'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'}
help(idx, cur)
return res
# V0'
# IDEA : dfs + backtracking
class Solution(object):
def letterCombinations(self, digits):
def dfs(idx, tmp):
"""
NOTE : if idx == len(digits)
-> if tmp is not null, then we append tmp to our result (res)
-> and we out of the loop
"""
if idx == len(digits):
if tmp != "":
res.append(tmp)
return
### NOTE : we loop alphabets in d map per number rather than loop over number !!!
for alpha in d[digits[idx]]:
"""
NOTE !!!!
idx+1 : move to next digit
tmp+alpha : collect current update
"""
dfs(idx + 1, tmp + alpha)
# edge case
if not digits:
return []
d = {'2' : "abc", '3' : "def", '4' : "ghi", '5' : "jkl", '6' : "mno", '7' : "pqrs", '8' : "tuv", '9' : "wxyz"}
res = []
dfs(0, "")
return res # NOTE: return res (not None)
# V1
# idea : for loop
class Solution(object):
def letterCombinations(self, digits):
"""
:type digits: str
:rtype: List[str]
"""
if digits == "": return []
d = {'2' : "abc", '3' : "def", '4' : "ghi", '5' : "jkl", '6' : "mno", '7' : "pqrs", '8' : "tuv", '9' : "wxyz"}
res = ['']
for e in digits:
res = [w + c for c in d[e] for w in res]
return res
2-2) combination-sum — LC 39
V0 below is correct but wasteful: with no
start_idxit explores every ordering (e.g.[2,3]and[3,2]), then dedups viasort()+tmp not in res. Prefer V1 (start_idx, passito allow reuse) — it never generates duplicates.
# LC 039 combination-sum
# V0 (brute + dedup — correct but slow)
# IDEA : DFS + BACKTRACK
class Solution(object):
def combinationSum(self, candidates, target):
def dfs(tmp):
if sum(tmp) == target:
tmp.sort()
if tmp not in res:
res.append(tmp)
return
if sum(tmp) > target:
return
for c in candidates:
dfs(tmp + [c])
res = []
tmp = []
dfs(tmp)
return res
# V1 (start_idx — preferred)
# IDEA : DFS + BACKTRACK + start_idx (pass `i` to allow reuse)
class Solution(object):
def combinationSum(self, candidates, target):
def dfs(start, tmp, total):
if total == target:
res.append(tmp[:])
return
if total > target:
return
for i in range(start, len(candidates)):
tmp.append(candidates[i])
# NOTE: pass `i` (NOT i+1) -> candidates[i] can be reused
dfs(i, tmp, total + candidates[i])
tmp.pop()
res = []
dfs(0, [], 0)
return res
Visual trace (recursion tree) — candidates = [2, 3, 6, 7], target = 7 → answer [[2,2,3],[7]]
Each node is a call
dfs(start, path, total). We passi(noti+1) so a candidate can be reused. A branch is pruned (✗) as soon astotal > target; recorded (✅) whentotal == target.
dfs(0, [], 0)
├─ pick 2 → dfs(0, [2], 2)
│ ├─ pick 2 → dfs(0, [2,2], 4)
│ │ ├─ pick 2 → dfs(0, [2,2,2], 6)
│ │ │ ├─ pick 2 → total=8 ✗ prune
│ │ │ └─ pick 3 → total=9 ✗ prune
│ │ ├─ pick 3 → dfs(1, [2,2,3], 7) ✅ record [2,2,3]
│ │ ├─ pick 6 → total=10 ✗
│ │ └─ pick 7 → total=11 ✗
│ ├─ pick 3 → dfs(1, [2,3], 5)
│ │ ├─ pick 3 → total=8 ✗
│ │ ├─ pick 6 → total=11 ✗
│ │ └─ pick 7 → total=12 ✗
│ ├─ pick 6 → total=8 ✗
│ └─ pick 7 → total=9 ✗
├─ pick 3 → dfs(1, [3], 3)
│ ├─ pick 3 → dfs(1, [3,3], 6)
│ │ └─ (3→9 ✗, 6→12 ✗, 7→13 ✗) ✗
│ ├─ pick 6 → total=9 ✗
│ └─ pick 7 → total=10 ✗
├─ pick 6 → dfs(2, [6], 6)
│ └─ (6→12 ✗, 7→13 ✗) ✗
└─ pick 7 → dfs(3, [7], 7) ✅ record [7]
Reading the tree: depth = how many numbers are in
path; thestartindex (0/1/2/3) shrinks the choice list going down so we never revisit an earlier candidate → no duplicate combinations. Switching the recursive call toi + 1(use-once) turns this into LC 40.
2-3) Word Search — LC 79
# LC 079 Word Search
# V0
# IDEA : DFS + backtracking
class Solution(object):
def exist(self, board, word):
if not board or not board[0]:
return False
self.rows = len(board)
self.cols = len(board[0])
for r in range(self.rows):
for c in range(self.cols):
if self.dfs(board, word, r, c, 0):
return True
return False
def dfs(self, board, word, r, c, idx):
if r < 0 or r >= self.rows or c < 0 or c >= self.cols:
return False
if board[r][c] != word[idx]:
return False
if idx == len(word) - 1:
return True
# Mark current cell as visited
temp = board[r][c]
board[r][c] = "#"
# Explore 4 directions
found = (
self.dfs(board, word, r + 1, c, idx + 1) or
self.dfs(board, word, r - 1, c, idx + 1) or
self.dfs(board, word, r, c + 1, idx + 1) or
self.dfs(board, word, r, c - 1, idx + 1)
)
# NOTE !!! MUST save `found` first, THEN backtrack, THEN return found.
# -> The restore (backtrack) must happen before return.
# -> Returning directly from the recursive call skips the restore:
#
# WRONG pattern:
# board[r][c] = "#"
# return (self.dfs(...) or self.dfs(...) or ...)
# board[r][c] = temp # NEVER REACHED
#
# CORRECT pattern:
# board[r][c] = "#"
# found = (self.dfs(...) or ...) # collect result
# board[r][c] = temp # backtrack (restore)
# return found # return after restore
# Backtrack: restore original value
board[r][c] = temp
return found
# V0' (visited matrix variant)
# IDEA : DFS + backtracking
class Solution(object):
def exist(self, board, word):
### NOTE : construct the visited matrix
visited = [[False for j in range(len(board[0]))] for i in range(len(board))]
### NOTE : we visit every element in board and trigger the dfs
for i in range(len(board)):
for j in range(len(board[0])):
if self.dfs(board, word, 0, i, j, visited):
return True
return False
def dfs(self, board, word, cur, i, j, visited):
# if "not false" till cur == len(word), means we already found the wprd in board
if cur == len(word):
return True
### NOTE this condition
# 1) if idx out of range
# 2) if already visited
# 3) if board[i][j] != word[cur] -> not possible to be as same as word
if i < 0 or i >= len(board) or j < 0 or j >= len(board[0]) or visited[i][j] or board[i][j] != word[cur]:
return False
# mark as visited
visited[i][j] = True
### NOTE THIS TRICK (run the existRecu on 4 directions on the same time)
result = self.dfs(board, word, cur + 1, i + 1, j, visited) or\
self.dfs(board, word, cur + 1, i - 1, j, visited) or\
self.dfs(board, word, cur + 1, i, j + 1, visited) or\
self.dfs(board, word, cur + 1, i, j - 1, visited)
# mark as non-visited
visited[i][j] = False
return result
// java
// LC 079
// V0'
// IDEA : DFS + BACKTRACK (modified by GPT)
public boolean exist_0(char[][] board, String word) {
if (board == null || board.length == 0) {
return false;
}
int l = board.length;
int w = board[0].length;
boolean[][] visited = new boolean[l][w];
for (int i = 0; i < l; i++) {
for (int j = 0; j < w; j++) {
if (dfs_(board, i, j, 0, word, visited)) {
return true;
}
}
}
return false;
}
private boolean dfs_(char[][] board, int y, int x, int idx, String word, boolean[][] visited) {
if (idx == word.length()) {
return true;
}
int l = board.length;
int w = board[0].length;
if (y < 0 || y >= l || x < 0 || x >= w || visited[y][x] || board[y][x] != word.charAt(idx)) {
return false;
}
/** NOTE !!! we update visited on x, y here */
visited[y][x] = true;
int[][] dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
/**
* NOTE !!!
*
* instead of below structure:
*
* boolean didFindNextCharacter =
* dfs2(row + 1, col, word, lvl + 1, visited, board) ||
* dfs2(row - 1, col, word, lvl + 1, visited, board) ||
* dfs2(row, col + 1, word, lvl + 1, visited, board) ||
* dfs2(row, col - 1, word, lvl + 1, visited, board);
*
* we can use below logic as well:
*
* for (int[] dir : dirs) {
* if (dfs_(board, y + dir[0], x + dir[1], idx + 1, word, visited)) {
* return true;
* }
* }
*
*/
for (int[] dir : dirs) {
if (dfs_(board, y + dir[0], x + dir[1], idx + 1, word, visited)) {
return true;
}
}
/** NOTE !!! we undo (backtrack) updated x, y here */
visited[y][x] = false;
return false;
}
2-4) Subsets — LC 78
# LC 078 Subsets
# V0
# IDEA : Backtracking
class Solution:
def subsets(self, nums):
def backtrack(first = 0, curr = []):
# if the combination is done
if len(curr) == k:
output.append(curr[:])
return
for i in range(first, n):
# add nums[i] into the current combination
curr.append(nums[i])
# use next integers to complete the combination
backtrack(i + 1, curr)
# backtrack
curr.pop()
output = []
n = len(nums)
for k in range(n + 1):
backtrack()
return output
# V0'
# brack tracking
class Solution(object):
def subsets(self, nums):
def help(start, tmp, res):
tmp.sort()
if tmp not in res:
res.append(tmp)
for i in range(start, len(nums)):
if nums[i] not in tmp:
help(start+1, tmp + [nums[i]], res)
res = []
start = 0
tmp = []
if len(nums) == 1:
res = [[]]
res.append(nums)
return res
help(start, tmp, res)
return res
# V0''
# IDEA : DFS
class Solution(object):
def subsets(self, nums):
def dfs(layer, start, tmp):
if tmp not in res:
res.append(tmp)
if layer == len(nums):
return
### NOTE : we have if condition first, then for loop
for i in range(start, len(nums)):
### NOTE below can make loop start `start idx` updated each time
dfs(layer+1, i+1, tmp + [nums[i]])
nums.sort()
res = []
dfs(0, 0, [])
return res
Visual trace (recursion tree) — nums = [1, 2, 3] → 2^3 = 8 subsets
Node = a call
backtrack(start, path). Unlike combination/permutation problems, subsets record thepathat EVERY node (pre-order), not only at leaves.startonly ever moves forward (i + 1), so each element is used at most once and no duplicate subset appears.
backtrack(start=0, path=[]) record []
├─ i=0 pick 1 → (start=1, [1]) record [1]
│ ├─ i=1 pick 2 → (start=2, [1,2]) record [1,2]
│ │ └─ i=2 pick 3 → (start=3, [1,2,3]) record [1,2,3]
│ └─ i=2 pick 3 → (start=3, [1,3]) record [1,3]
├─ i=1 pick 2 → (start=2, [2]) record [2]
│ └─ i=2 pick 3 → (start=3, [2,3]) record [2,3]
└─ i=2 pick 3 → (start=3, [3]) record [3]
result = [] [1] [1,2] [1,2,3] [1,3] [2] [2,3] [3] → 8 subsets
Key contrast: no
end_conditiongate before recording — a subset is valid at every depth. The binary “include / exclude” view (see the Javahelperbelow) draws the same2^nleaves as a full binary tree of heightn.
// java
// LC 78
// V0'
// IDEA : Backtracking
// https://leetcode.com/problems/subsets/editorial/
List<List<Integer>> output = new ArrayList();
int n, k;
public void backtrack(int first, ArrayList<Integer> curr, int[] nums) {
// if the combination is done
if (curr.size() == k) {
output.add(new ArrayList(curr));
return;
}
/** NOTE HERE !!!
*
* ++i : i+1 first, then do op
* i++ : do op first, then i+1
*
* -> i++ or ++i is both OK here
*/
for (int i = first; i < n; i++) {
// add i into the current combination
curr.add(nums[i]);
// use next integers to complete the combination
backtrack(i + 1, curr, nums);
// backtrack
curr.remove(curr.size() - 1);
}
}
public List<List<Integer>> subsets(int[] nums) {
n = nums.length;
/** NOTE HERE !!!
*
* ++k : k+1 first, then do op
* k++ : do op first, then k+1
*
* -> k++ or ++k is both OK here
*/
for (k = 0; k < n + 1; k++) {
backtrack(0, new ArrayList<Integer>(), nums);
}
return output;
}
// V1
// IDEA : BACKTRACK
// https://www.youtube.com/watch?v=REOH22Xwdkk&t=4s
// https://github.com/neetcode-gh/leetcode/blob/main/java/0078-subsets.java
public List<List<Integer>> subsets_1_2(int[] nums) {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> list = new ArrayList<>();
helper(ans, 0, nums, list);
return ans;
}
public void helper(
List<List<Integer>> ans,
int start,
int[] nums,
List<Integer> list
) {
if (start >= nums.length) {
ans.add(new ArrayList<>(list));
} else {
// decision tree : add the element and start the recursive call
list.add(nums[start]);
helper(ans, start + 1, nums, list);
// decision tree : remove the element and do the backtracking call.
list.remove(list.size() - 1);
helper(ans, start + 1, nums, list);
}
}
// c++
// backtrack
// (algorithm book (labu) p.303)
// save all subset
vector<vector<int>> res;
/* main func */
vector<vector<int>> subsets(vector<int> & nums){
// record visited routes
vector<int> track;
backtrack(nums, 0, track);
return res;
}
/* use backtrack pattern */
void backtrack(vector<int> & nums, int start, vector<int> & track){
// pre-order tranverse
res.push_back(track);
// start from `start`, avoid duplivated subset
for (int i = start; i < nums.size(); i++){
// make choice
track.push_back(nums[i]);
// iteration backtrack
backtrack(nums, i+1, track);
// undo choice
track.pop_back();
}
}
2-4’) Subsets II — LC 90
# LC 90 Subsets II
# V0
# IDEA : BACKTRACKING + LC 078 Subsets
from collections import Counter
class Solution(object):
def subsetsWithDup(self, nums):
def help(start, tmp, _cnt):
tmp.sort()
if tmp not in res:
res.append(tmp)
if start >= len(nums):
return
for i in range(start, len(nums)):
if _cnt[nums[i]] > 0:
_cnt[nums[i]] -= 1
help(start+1, tmp + [nums[i]], _cnt)
"""
NOTE : here we "undo" the "_cnt[nums[i]] -= 1" op,
-> so next recursive can still have the "capacity" of such element
"""
_cnt[nums[i]] += 1
# edge case
if not nums:
return []
# edge case
if len(nums) == 1:
res = [[]]
res.append([nums[0]])
return res
res = [[]]
_cnt = Counter(nums)
help(0, [], _cnt)
print ("res = " + str(res))
return res
# V0
# IDEA : BRUTE FORCE
class Solution:
def subsetsWithDup(self, nums):
# small trick (init with a null array)
ans=[[]]
for i in nums:
for l in list(ans):
# sorted here, since we want to the "non-duplicated" power set
temp=sorted(l+[i])
# avoid duplicated
if temp not in ans:
ans.append(temp)
return ans
2-5) Combinations — LC 77
# LC 77. Combinations
# V0
# BACKTRACK
class Solution(object):
def combine(self, n, k):
def dfs(current, start):
if(len(current) == k):
"""
Both of below approach are OK
list(current) : transform current reference to list
current[:] : shallow copy
"""
result.append(list(current))
return
for i in range(start, n + 1):
current.append(i)
dfs(current, i + 1)
current.pop()
result = []
dfs([], 1)
return result
# V0'
# IDEA : BACKTRACK
class Solution:
def combine(self, n, k):
res=[]
def go(i,ma,ans):
if ma==k:
res.append(list(ans))
return
if i>n:
return
ans.append(i)
go(i+1,ma+1,ans)
ans.pop()
go(i+1,ma,ans)
go(1,0,[])
return res
// c++
// backtrack
// (algorithm book (labu) p.305)
// record all combinations
vector<vector<int>> res;
/* main func */
vector<vector<int>> combine(int n, int k){
if (k <= 0 || n <= 0) return res;
vector<int> track;
backtrack(n, k, 1, track);
return res;
}
/* use backtrack pattern */
void backtrack(int n, int k, int start, vector<int> & track){
// not update res till visit leaf node
if (k == track.size()){
res.push_back(track);
return;
}
// increase from i
for (int i = start; i <= n; i ++){
// do choice
track.push_back(i);
// backtrack
backtrack(n, k, i+1, track);
// undo choice
track.pop_back();
}
}
2-6) Permutations — LC 46
# LC 46. Permutations
# V0
# IDEA : BACKTRACK,
# similar idea as LC 77 -> difference : contains VS start
class Solution(object):
def permute(self, nums):
def help(cur):
if len(cur) == n_len:
if cur not in res:
res.append(list(cur))
return
if len(cur) > n_len:
return
for i in nums:
#print ("i = " + str(i) + " cur = " + str(cur))
if i not in cur:
cur.append(i)
help(cur)
cur.pop(-1)
# edge case
if not nums:
return [[]]
n_len = len(nums)
res = []
help([])
#print ("res = " + str(res))
return res
# V0' (visited[] variant)
# IDEA : BACKTRACK with a `visited` array (instead of `contains`)
class Solution(object):
def permute(self, nums):
res = []
visited = [False] * len(nums)
def dfs(path):
if len(path) == len(nums):
res.append(path[:]) # NOTE: copy the path
return
for i in range(len(nums)):
if visited[i]:
continue
visited[i] = True # choose
path.append(nums[i])
dfs(path) # explore
path.pop() # un-choose
visited[i] = False
dfs([])
return res
Visual trace (recursion tree) — nums = [1, 2, 3] → 3! = 6 permutations
Node = a call
dfs(path)carrying avisitedset. Permutations use nostart_idx— at every level we scan allnumsand only skip elements already invisited. Apathis recorded (✅) only at a leaf, wherelen(path) == len(nums).
dfs([]) visited={}
├─ 1 → dfs([1]) visited={1}
│ ├─ 2 → dfs([1,2]) visited={1,2}
│ │ └─ 3 → [1,2,3] ✅
│ └─ 3 → dfs([1,3]) visited={1,3}
│ └─ 2 → [1,3,2] ✅
├─ 2 → dfs([2]) visited={2}
│ ├─ 1 → [2,1] → 3 → [2,1,3] ✅
│ └─ 3 → [2,3] → 1 → [2,3,1] ✅
└─ 3 → dfs([3]) visited={3}
├─ 1 → [3,1] → 2 → [3,1,2] ✅
└─ 2 → [3,2] → 1 → [3,2,1] ✅
Key contrast with subsets: the branching factor shrinks each level (3 → 2 → 1) as
visitedgrows, and results appear only at leaves — givingn!leaves instead of2^nnodes.
// LC 46. Permutations
List<List<Integer>> ans = new ArrayList<>();
// V0
// IDEA : BACKTRACK
public List<List<Integer>> permute(int[] nums) {
if (nums.length == 1){
List<List<Integer>> _ans = new ArrayList<>();
List<Integer> cur = new ArrayList<>();
cur.add(nums[0]);
_ans.add(cur);
return _ans;
}
List<Integer> cur = new ArrayList<>();
/** NOTE !!! we don't need to set idx param */
helper(nums, cur);
return this.ans;
}
private void helper(int[] nums, List<Integer> cur){
if (cur.size() > nums.length){
return;
}
if (!this.ans.contains(cur) && cur.size() == nums.length){
/** NOTE !!! we use below to add current ArrayList instance to ans */
this.ans.add(new ArrayList<>(cur));
}
for (int i = 0; i < nums.length; i++){
int val = nums[i];
// input nums is array with distinct integers
/** NOTE !!! ONLY do recursive, backtrack when meet distinct element */
if(!cur.contains(val)){
cur.add(val);
// recursive call
helper(nums, cur);
// undo last op
cur.remove(cur.size()-1); // NOTE !!! remove last element
}
}
}
2-7) Generate Parentheses — LC 22
# python
# LC 022 Generate Parentheses
# V0
# IDEA : bracktrack + Valid Parentheses (LC 020)
class Solution(object):
def generateParenthesis(self, n):
# help func for backtracking
def help(tmp, res, n):
if len(tmp) == n * 2 and check(tmp):
res.append(tmp)
return
if len(tmp) == n * 2:
return
for l in _list:
print ("l = " + str(l))
help(tmp + l, res, n)
"""
LC 020 Valid Parentheses
"""
def check(s):
lookup = {"(":")", "[":"]", "{":"}"}
q = []
for i in s:
if i not in lookup and len(q) == 0:
return False
elif i in lookup:
q.append(i)
else:
tmp = q.pop()
if lookup[tmp] != i:
return False
return True if len(q) == 0 else False
_list = ['(', ')']
if n == 1:
return ["()"]
res = []
help("", res, n)
return res
# V0'
# https://blog.csdn.net/fuxuemingzhu/article/details/79362373
# IDEA: BACKTRACKING + DFS
# NOTE : KEEP DFS WHEN MEAT 2 CONDTIONS:
# 1) len(path) < n
# 2) # of "(" > # of ")" (means it's still possible to form a "paratheses" as expected)
class Solution(object):
def generateParenthesis(self, n):
res = []
self.dfs(res, n, n, '')
return res
def dfs(self, res, left, right, path):
if left == 0 and right == 0:
res.append(path)
return
if left > 0:
self.dfs(res, left - 1, right, path + '(')
if left < right:
self.dfs(res, left, right - 1, path + ')')
// c++
// LC 022 Generate Parentheses
// (algorithm book (labu) p.316)
/* main func */
vector<string> generateParentheses(int n){
if (n == 0) return {};
// record all legal collections
vector<string> res;
// backtrack the routes (in process)
string track;
// init : available left Parentheses and right Parentheses counts as n
backtrack(n, n, track, res);
return res;
}
/* remain left Parentheses count : left ;.. remain right Parentheses : right */
void backtrack(int left, int right, string& track, vector<string> & res){
// if count < 0 : illegal
if (left < 0 || right < 0) return;
// if remain left Parentheses count > right Parentheses count : illegal
if (right < left) return;
// if all Parentheses are used : legal, we got one OK solution
if (left == 0 && right == 0){
res.push_back(track);
return;
}
// add one more left Parentheses
track.push_back('('); // do choice
backtrack(left - 1, right, track, res);
track.pop_back(); // undo choice
// add one more right Parentheses
track.push_back(')'); // do choice
backtrack(left, right - 1, track, res);
track.pop_back(); // undo choice
}
// java
// V2
// IDEA : Backtracking, Keep Candidate Valid
// https://leetcode.com/problems/generate-parentheses/editorial/
public List<String> generateParenthesis_3(int n) {
List<String> answer = new ArrayList<>();
backtracking(answer, new StringBuilder(), 0, 0, n);
return answer;
}
private void backtracking(List<String> answer, StringBuilder curString, int leftCount, int rightCount, int n) {
if (curString.length() == 2 * n) {
answer.add(curString.toString());
return;
}
if (leftCount < n) {
curString.append("(");
backtracking(answer, curString, leftCount + 1, rightCount, n);
curString.deleteCharAt(curString.length() - 1);
}
if (leftCount > rightCount) {
curString.append(")");
backtracking(answer, curString, leftCount, rightCount + 1, n);
curString.deleteCharAt(curString.length() - 1);
}
}
2-8) Palindrome Partitioning — LC 131
// java
// LC 131
// V0-1
// IDEA: BACKTRACK + start_idx (fixed by gpt)
List<List<String>> partitionRes = new ArrayList<>();
public List<List<String>> partition_0_1(String s) {
if (s == null || s.isEmpty()) {
return new ArrayList<>();
}
backtrack(s, 0, new ArrayList<>());
return partitionRes;
}
private void backtrack(String s, int start, List<String> currentList) {
/**
*
* • This is the base case of the recursion.
*
* • It means: “If we’ve reached the end of the string,
* then the current list of substrings (currentList)
* forms a valid full partition of s into palindromes.”
*
* • -> So we add a copy of currentList into
* the final result list partitionRes.
*/
/**
* - Why start == s.length()?
*
* • Because start is the index from which
* we’re currently trying to partition.
*
* • If start == s.length(), it means we’ve
* used up all characters in s, and currentList is now a full,
* valid partition.
*/
if (start == s.length()) {
partitionRes.add(new ArrayList<>(currentList));
return;
}
/**
* NOTE !!!
*
* 1) we loop from `start + 1` to `s.length()`
* 2) get sub string via s.substring(a, b)
* 3) check if current sub string is Palindrome
* - if yes,
* - add sub string to current cache
* - recursive call backtrack
* - undo cache add
*/
for (int end = start + 1; end <= s.length(); end++) {
String sub = s.substring(start, end);
if (isPalindrome(sub)) {
currentList.add(sub);
backtrack(s, end, currentList);
currentList.remove(currentList.size() - 1); // undo
}
}
}
// helper func check if a string is `Palindrome`
public boolean isPalindrome(String x) {
int l = 0;
int r = x.length() - 1;
while (r > l) {
if (x.charAt(l) != x.charAt(r)) {
return false;
}
r--;
l++;
}
return true;
}
# LC 131 Palindrome Partitioning
# V0
# IDEA : BACKTRCK, similar as LC 046 permutations
class Solution(object):
def partition(self, s):
def help(s, res, path):
if not s:
res.append(path)
return
for i in range(1, len(s)+1):
if s[:i] == s[:i][::-1]:
help(s[i:], res, path + [s[:i]])
# edge case
if not s:
return
res = []
path = []
help(s, res, path)
return res
# V0'
# IDEA : BACKTRCK, similar as LC 046 permutations
class Solution(object):
def partition(self, s):
res = []
self.helper(s, res, [])
return res
def helper(self, s, res, path):
if not s:
res.append(path)
return
# beware of the start and the end index
for i in range(1, len(s) + 1):
if self.isPalindrome(s[:i]):
"""
### backtrcking
if s[:i] is palindrome, then check if there is palindrome in s[i:] as well
e.g.
a a b b a
=>
if 'aa' (<-) is palindrome, then check a b b a (->)
"""
self.helper(s[i:], res, path + [s[:i]])
def isPalindrome(self, x):
return x == x[::-1]
2-9) Restore IP Addresses — LC 93
# 093 Restore IP Addresses
# V0
# IDEA : DFS
class Solution(object):
def restoreIpAddresses(self, s):
# if not valid input form (ip address length should < 12)
if len(s) > 12:
return []
res = []
self.dfs(s, [], res)
return res
def dfs(self, s, path, res):
# if not remaining elments (not s) and path is in "xxx.xxx.xxx.xxx" form
if not s and len(path) == 4:
res.append('.'.join(path))
return
for i in [1,2,3]:
# avoid "out of index" error
if i > len(s):
continue
number = int(s[:i])
# str(number) == s[:i] for checking if digit is not starting from "0"
# e.g. 030 is not accepted form, while 30 is OK
if str(number) == s[:i] and number <= 255:
self.dfs(s[i:], path + [s[:i]], res)
2-10) Word Break — LC 139
# LC 139 Word Break
# V0
# IDEA : BFS
class Solution:
def wordBreak(self, s, wordDict):
if not s or not wordDict:
return
q = collections.deque()
q.append(0)
visited = [None]*len(s)
while q:
i = q.popleft()
if not visited[i]:
for j in range(i+1,len(s)+1):
if s[i:j] in wordDict:
if j == len(s):
return True
q.append(j)
visited[i]=True
2-11) Word Break II — LC 140
# LC 140 Word Break II
# NOTE : there is also dfs, dp approaches
# V0
# IDEA : BACKTRCK, LC 078 Subsets
class Solution(object):
def wordBreak(self, s, wordDict):
def help(cur):
"""
NOTE this !!! :
-> shallow copy cur[:]
"""
if "".join(cur[:]) == s:
res.append(" ".join(cur[:]))
return
if len("".join(cur[:])) > len(s):
return
for i in range(len(wordDict)):
cur.append(wordDict[i])
help(cur)
# NOTE this
cur.pop()
# edge case
if not wordDict:
return []
res = []
cur = []
cnt = 0
help(cur)
print ("res = " + str(res))
return res
# V1
# IDEA : RECURSION
# https://leetcode.com/problems/word-break-ii/discuss/1426014/Python-interview-friendly-simple-recursion
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:
def recur(s, path):
if not s:
out.append(' '.join(path))
return
for i in range(1,len(s)+1):
w,new_s = s[:i], s[i:]
if w in wordDict:
recur(new_s, path + [w])
wordDict, out = set(wordDict), []
recur(s,[])
return out
# V1'
# IDEA : BACKTRCK
# https://leetcode.com/problems/word-break-ii/discuss/44404/Python-backtracking
class Solution:
def wordBreak(self, s, dic):
if not dic:
return []
n = max(len(d) for d in dic)
stack, parents = [0], collections.defaultdict(set)
while stack:
parent = stack.pop()
for child in range(parent+1, parent+n+1):
if s[parent:child] in dic:
if child not in parents:
stack.append(child)
parents[child].add(parent)
stack, res = [[len(s)]], []
while stack:
r = stack.pop()
if r[0] == 0:
r = [s[i:j] for i, j in zip(r[:-1], r[1:])]
res.append(' '.join(r))
for parent in parents[r[0]]:
stack.append([parent]+r)
return res
2-12) Course Schedule — LC 207
// java
// LC 207
// V0
// IDEA : DFS (fix by gpt)
// NOTE !!! instead of maintain status (0,1,2), below video offers a simpler approach
// -> e.g. use a set, recording the current visiting course, if ANY duplicated (already in set) course being met,
// -> means "cyclic", so return false directly
// https://www.youtube.com/watch?v=EgI5nU9etnU
public boolean canFinish(int numCourses, int[][] prerequisites) {
// Initialize adjacency list for storing prerequisites
/**
* NOTE !!!
*
* init prerequisites map
* {course : [prerequisites_array]}
* below init map with null array as first step
*/
Map<Integer, List<Integer>> preMap = new HashMap<>();
for (int i = 0; i < numCourses; i++) {
preMap.put(i, new ArrayList<>());
}
// Populate the adjacency list with prerequisites
/**
* NOTE !!!
*
* update prerequisites map
* {course : [prerequisites_array]}
* so we go through prerequisites,
* then append each course's prerequisites to preMap
*/
for (int[] pair : prerequisites) {
int crs = pair[0];
int pre = pair[1];
preMap.get(crs).add(pre);
}
/** NOTE !!!
*
* init below set for checking if there is "cyclic" case
*/
// Set for tracking courses during the current DFS path
Set<Integer> visiting = new HashSet<>();
// Recursive DFS function
for (int c = 0; c < numCourses; c++) {
if (!dfs(c, preMap, visiting)) {
return false;
}
}
return true;
}
private boolean dfs(int crs, Map<Integer, List<Integer>> preMap, Set<Integer> visiting) {
/** NOTE !!!
*
* if visiting contains current course,
* means there is a "cyclic",
* (e.g. : needs to take course a, then can take course b, and needs to take course b, then can take course a)
* so return false directly
*/
if (visiting.contains(crs)) {
return false;
}
/**
* NOTE !!!
*
* if such course has NO preRequisite,
* return true directly
*/
if (preMap.get(crs).isEmpty()) {
return true;
}
/**
* NOTE !!!
*
* add current course to set (Set<Integer> visiting)
*/
visiting.add(crs);
for (int pre : preMap.get(crs)) {
if (!dfs(pre, preMap, visiting)) {
return false;
}
}
/**
* NOTE !!!
*
* remove current course from set,
* since already finish visiting
*
* e.g. undo changes
*/
visiting.remove(crs);
preMap.get(crs).clear(); // Clear prerequisites as the course is confirmed to be processed
return true;
}
2-13) Path Sum II — LC 113
- Tree DFS that collects all root-to-leaf paths whose sum equals
targetSum. - Great example of why backtrack (
cache.pop()) is needed for a mutable list, but NOT for the primitivecur_sum.cache(alist) is shared by reference across recursive calls → mustpop()to undo before returning to the parent.cur_sum(anint) is immutable / passed by value → each call gets its own copy, so NO undo needed.
- See also 1-2-3) NOT do
undoon primary variable and theWhen to Use Backtrackingtable.
# python
# LC 113 Path Sum II
# V0
# IDEA: DFS (post order) + backtrack
class Solution(object):
def pathSum(self, root, targetSum):
self.res = []
if not root:
return self.res
self.helper(root, targetSum, 0, [])
return self.res
def helper(self, root, targetSum, cur_sum, cache):
if not root:
return
# do choice
cur_sum += root.val # `int` -> immutable, gets a fresh copy per call
cache.append(root.val) # `list` -> mutable, SAME copy shared in recursion
# found a valid root-to-leaf path
if not root.left and not root.right and cur_sum == targetSum:
self.res.append(cache[:]) # NOTE !!! shallow copy `cache[:]`
self.helper(root.left, targetSum, cur_sum, cache)
self.helper(root.right, targetSum, cur_sum, cache)
# NOTE !!! Backtrack (undo)
# -> `cache` MUST be restored (mutable, shared by reference)
# -> `cur_sum` does NOT need restore (int is immutable / passed by value)
cache.pop()
Why cache.pop() is necessary
Suppose the tree is:
1
/ \
2 3
Without cache.pop():
visit 1: cache = [1]
visit 2: cache = [1,2]
return
visit 3: cache = [1,2,3] # Wrong! 2 leaked into 3's path
With cache.pop():
visit 1: cache = [1]
visit 2: cache = [1,2]
return -> pop() => [1]
visit 3: cache = [1,3] # Correct
This is the standard DFS backtracking pattern:
cache.append(...)
dfs(...) # left
dfs(...) # right
cache.pop()
| Variable | Type | Need undo (backtrack)? | Reason |
|---|---|---|---|
cache |
list (mutable) |
✅ Yes (cache.pop()) |
Shared by reference across recursive calls |
cur_sum |
int (immutable) |
❌ No | Passed by value; each call gets its own copy |
Alternative (no explicit pop): pass a new list each call (
path + [node.val]) so every branch owns its own copy — then nopop()is needed (seeV1/V1'in the source). The trade-off is extra copying vs. one shared list with backtrack.
3) Constraint-Satisfaction Patterns (Interview Favorites)
3-1) N-Queens — LC 51
Classic backtracking with O(n!) search space, pruned by column/diagonal tracking.
def solveNQueens(n):
result = []
cols = set()
diag1 = set() # row - col (top-left to bottom-right)
diag2 = set() # row + col (top-right to bottom-left)
def backtrack(row, board):
if row == n:
result.append(["".join(r) for r in board])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue
cols.add(col); diag1.add(row - col); diag2.add(row + col)
board[row][col] = 'Q'
backtrack(row + 1, board)
board[row][col] = '.'; cols.remove(col)
diag1.remove(row - col); diag2.remove(row + col)
backtrack(0, [['.']*n for _ in range(n)])
return result
Key pruning: Three O(1) sets replace the O(n) column/diagonal scans. Time: O(n!), Space: O(n).
3-2) Sudoku Solver — LC 37
Backtrack cell by cell; prune using row/col/box sets.
def solveSudoku(board):
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
empty = []
for r in range(9):
for c in range(9):
if board[r][c] != '.':
d = board[r][c]
rows[r].add(d); cols[c].add(d); boxes[(r//3)*3+c//3].add(d)
else:
empty.append((r, c))
def backtrack(idx):
if idx == len(empty): return True
r, c = empty[idx]
box = (r//3)*3 + c//3
for d in '123456789':
if d in rows[r] or d in cols[c] or d in boxes[box]: continue
board[r][c] = d
rows[r].add(d); cols[c].add(d); boxes[box].add(d)
if backtrack(idx + 1): return True
board[r][c] = '.'; rows[r].remove(d); cols[c].remove(d); boxes[box].remove(d)
return False
backtrack(0)
3-3) Constraint Propagation (Early Termination)
Beyond simple bound-checking, propagate constraints forward before recursing. This is the key insight separating O(n!) brute force from practical backtracking.
Standard backtracking: try → recurse → undo
With propagation: try → propagate constraints → if valid: recurse → undo
Example: In Sudoku, after placing a digit, immediately eliminate it from peer cells. If any cell has zero candidates, backtrack immediately without reaching deeper levels.
3-4) Backtracking Complexity Cheat Sheet
| Problem | Branching Factor | Depth | Pruning | Worst Case |
|---|---|---|---|---|
| Subsets | 2 | n | None | O(2^n) |
| Permutations | n, n-1, … | n | Used-set | O(n!) |
| Combinations | n-k+1 | k | Start index | O(C(n,k)) |
| N-Queens | n | n | 3 sets | O(n!) → much better in practice |
| Sudoku | 9 | 81 | Row/col/box | O(9^81) → O(1) per board in practice |
3-5) Termination Condition Patterns
if len(current) == target_length: # fixed-size result (permutations, combinations of size k)
result.append(current[:])
return
if sum(current) == target: # value-based result (subset sum, coin change)
result.append(current[:])
return
if index == len(input): # exhausted input (string partition, IP addresses)
if is_valid(current):
result.append(result_repr)
return
3-6) Interview Signal → Pattern
| When you hear… | Reach for… |
|---|---|
| “all possible combinations/permutations” | Standard backtracking + result.append(copy) |
| “place N non-attacking queens” | N-Queens with 3 pruning sets |
| “fill a grid with constraints” | Sudoku-style + row/col/box sets |
| “partition string into valid parts” | Index-based backtrack with is_valid check |
| “generate valid parentheses” | Track open/close counts as constraints |
| “too slow? prune harder” | Propagate constraints before recursing |