Loading learning content...
Imagine you're a cashier with an infinite supply of certain coin denominations, and a customer needs exact change. Or you're a project manager who needs to allocate a fixed budget across different cost options. Or you're designing a game where players must combine power-ups to reach an exact energy level. All of these scenarios share a common structure: finding combinations of numbers that sum to a target.
The Combination Sum problem family represents one of the most important and frequently encountered backtracking patterns. These problems ask us to find all ways to combine elements from a set to achieve a target sum, with varying constraints on how elements can be reused. Mastering this pattern equips you to handle a vast array of real-world optimization and search problems.
By the end of this page, you will master the core Combination Sum problem and its major variants, understand the subtle differences between allowing duplicate use vs. single use of elements, learn techniques to avoid generating duplicate combinations, and develop intuition for when and how to optimize these solutions.
The Combination Sum family consists of several closely related problems, each with slightly different constraints. Understanding the differences is crucial for applying the right solution.
Combination Sum I (LeetCode 39):
Combination Sum II (LeetCode 40):
Combination Sum III (LeetCode 216):
Combination Sum IV (LeetCode 377):
| Variant | Duplicates in Input? | Element Reuse | Key Challenge |
|---|---|---|---|
| Combination Sum I | No (distinct) | Unlimited | Allow same element repeatedly in path |
| Combination Sum II | Yes (may have) | At most once | Skip duplicate elements to avoid duplicate results |
| Combination Sum III | No (fixed 1-9) | At most once | Additional constraint on combination size |
| Combination Sum IV | No (distinct) | Unlimited | Count permutations (use DP instead) |
Combination Sum I-III generate combinations where order doesn't matter: [2,2,3] and [3,2,2] are the same combination. Combination Sum IV counts permutations where order matters: [2,2,3] and [3,2,2] are different. This fundamental distinction determines whether backtracking or DP is the right approach.
Let's start with the foundational variant where elements can be reused unlimited times.
Problem Statement:
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. The same number may be chosen an unlimited number of times. Two combinations are unique if the frequency of at least one of the chosen numbers is different.
Example:
candidates = [2,3,6,7], target = 7[[2,2,3], [7]]The Backtracking Insight:
The key insight is that at each step, we have multiple choices:
This creates a decision tree where each path represents a combination. We prune branches where the running sum exceeds the target.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
def combinationSum(candidates: list[int], target: int) -> list[list[int]]: """ Find all combinations that sum to target, with unlimited reuse. Time Complexity: O(N^(T/M)) where N = len(candidates), T = target, M = min(candidates) Space Complexity: O(T/M) for recursion depth """ result = [] def backtrack(start: int, remaining: int, path: list[int]) -> None: # Base case: found a valid combination if remaining == 0: result.append(path[:]) # Append a copy of the current path return # Base case: exceeded target, prune this branch if remaining < 0: return # Explore candidates starting from 'start' index # This ensures we don't generate permutations (e.g., [2,3] and [3,2]) for i in range(start, len(candidates)): candidate = candidates[i] # Optimization: if current candidate > remaining, skip # (Works only if candidates are sorted) if candidate > remaining: break # All subsequent candidates are larger too # CHOOSE: Add candidate to the path path.append(candidate) # EXPLORE: Recurse with 'i' (not i+1) to allow reuse backtrack(i, remaining - candidate, path) # UNCHOOSE: Remove candidate from path (backtrack) path.pop() # Sort to enable early termination optimization candidates.sort() backtrack(0, target, []) return result # Example usageprint(combinationSum([2, 3, 6, 7], 7))# Output: [[2, 2, 3], [7]] print(combinationSum([2, 3, 5], 8))# Output: [[2, 2, 2, 2], [2, 3, 3], [3, 5]]Notice we pass 'i' (not 'i+1') when recursing. This is the key difference enabling reuse—we can pick the same element again. But we still use 'start' to prevent generating reverse orderings. Starting from index 0 each time would give us both [2,3] and [3,2], which represent the same combination.
Combination Sum II introduces a significant complication: the input array may contain duplicate values, but each element can only be used once, and we must not return duplicate combinations.
Problem Statement:
Given a collection of candidate numbers candidates (which may contain duplicates) and a target number target, find all unique combinations where the candidate numbers sum to target. Each number may be used only once in the combination.
Example:
candidates = [10,1,2,7,6,1,5], target = 8[[1,1,6], [1,2,5], [1,7], [2,6]]The Duplicate Challenge:
Consider candidates = [1,1,2] with target 3. If we naively apply backtracking:
We'd generate [1,2] twice because there are two 1s in the input. The challenge is avoiding this without missing valid combinations like [1,1] that legitimately use both 1s.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
def combinationSum2(candidates: list[int], target: int) -> list[list[int]]: """ Find all unique combinations that sum to target, each element used once. Key insight: Sort the array, then skip duplicates at the same recursion level. Time Complexity: O(2^N) where N = len(candidates) Space Complexity: O(N) for recursion depth and path """ result = [] def backtrack(start: int, remaining: int, path: list[int]) -> None: # Base case: found a valid combination if remaining == 0: result.append(path[:]) return # Base case: exceeded target if remaining < 0: return for i in range(start, len(candidates)): candidate = candidates[i] # CRUCIAL: Skip duplicates at the same recursion level # If candidates[i] == candidates[i-1], we've already explored # all combinations starting with this value at this position if i > start and candidates[i] == candidates[i - 1]: continue # Optimization: early termination (sorted array) if candidate > remaining: break # CHOOSE path.append(candidate) # EXPLORE with i+1 (each element used at most once) backtrack(i + 1, remaining - candidate, path) # UNCHOOSE path.pop() # CRITICAL: Sort to group duplicates together candidates.sort() backtrack(0, target, []) return result # Example usageprint(combinationSum2([10, 1, 2, 7, 6, 1, 5], 8))# Output: [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]] print(combinationSum2([2, 5, 2, 1, 2], 5))# Output: [[1, 2, 2], [5]]Understanding the Duplicate-Skipping Logic:
The line if i > start and candidates[i] == candidates[i-1]: continue is subtle but crucial. Let's understand it deeply:
candidates = [1, 1, 2] (sorted), target = 3
First call: start=0
i=0, pick first 1 → recurse with start=1
i=1, pick second 1 → recurse with start=2
i=2, pick 2 → sum=4 > target, backtrack
i=2, pick 2 → sum=3 = target ✓ [1,2]
i=1, candidates[1]==candidates[0], but i>start, SKIP!
↑ This prevents picking the "second 1" as the first element
because we already explored "first 1" in that position
i=2, pick 2 → recurse with start=3
No more elements, backtrack
The key insight: at the same recursion level (same start), choosing the first occurrence of a value explores all combinations using that value. Choosing subsequent duplicates would regenerate the same combinations.
The condition 'i > start' is crucial. Without it, we'd skip valid uses of consecutive duplicates. When i == start, we're at a new recursion level—we haven't tried anything at this level yet, so we shouldn't skip. We only skip when i > start, meaning we've already processed at least one candidate at this level.
Understanding backtracking is enormously helped by visualizing the search tree. Let's trace through a complete example.
Example: Combination Sum I
[]
/ \
[2] [3]
/ \ \
[2,2] [2,3] [3,3]
/ \ \ X (9>6)
[2,2,2] [2,2,3] [2,3,3]
✓ X X
(=6) (7>6) (8>6)
Reading the tree:
Key observations:
Example: Combination Sum II with Duplicates
[]
/ | \
[1] [1] [2]
↓ ↓ SKIP! ↓
/ \ (no valid)
[1,1] [1,2]
↓ ↓
(=2) (=3)✓
↓
[1,1,2]
↓
(=4)X
Notice that the second [1] at the root level is SKIPPED because:
i=1 > start=0 (we've already processed something at this level)candidates[1] == candidates[0] (duplicate value)This ensures we don't generate duplicate combinations while still allowing [1,1] which uses both 1s at different recursion levels.
Combination Sum III adds an additional constraint: we must use exactly k numbers from the range 1-9, and each can be used at most once.
Problem Statement:
Find all valid combinations of k numbers that sum up to n such that:
Example:
Additional Constraint Handling:
This variant requires tracking two constraints simultaneously:
We can use both constraints for pruning, making the search more efficient.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
def combinationSum3(k: int, n: int) -> list[list[int]]: """ Find k numbers from 1-9 (each used once) that sum to n. Time Complexity: O(C(9,k) * k) ≈ O(9! / (k! * (9-k)!)) Space Complexity: O(k) for recursion depth """ result = [] def backtrack(start: int, remaining: int, path: list[int]) -> None: # Base case: found a valid combination if len(path) == k: if remaining == 0: result.append(path[:]) return # Don't continue—we have exactly k elements # Pruning: if we've exceeded the sum or can't pick enough numbers if remaining <= 0: return # Additional pruning: not enough numbers left to fill k slots if 9 - start + 1 < k - len(path): return for num in range(start, 10): # 1 to 9 # Optimization: if num > remaining, no point continuing if num > remaining: break # CHOOSE path.append(num) # EXPLORE (num+1 because each number used at most once) backtrack(num + 1, remaining - num, path) # UNCHOOSE path.pop() backtrack(1, n, []) # Start from 1 return result # Examplesprint(combinationSum3(3, 7)) # [[1, 2, 4]]print(combinationSum3(3, 9)) # [[1, 2, 6], [1, 3, 5], [2, 3, 4]]print(combinationSum3(2, 18)) # [] (max is 9+8=17)With multiple constraints, apply all of them for pruning. In Combination Sum III, we prune when: (1) sum exceeds target, (2) path length reaches k, (3) not enough numbers remain. Each additional constraint dramatically reduces the search space.
Understanding the complexity of Combination Sum variants helps you predict performance and choose appropriate optimizations.
Combination Sum I Complexity:
With unlimited reuse, the worst case is filling the target entirely with the smallest element. If candidates = [1,...], target = T, we might pick 1 repeatedly T times, with branching factor N at each level.
Combination Sum II Complexity:
Each element used at most once means maximum recursion depth is N (pick all elements).
Combination Sum III Complexity:
We're choosing k elements from 9, with various pruning.
For k=4 (the maximum useful value since 1+2+...+9=45), this is at most C(9,4)×4 = 126×4 = 504 operations.
| Variant | Time Complexity | Practical Bound | Notes |
|---|---|---|---|
| Combination Sum I | O(N^(T/M)) | Can be large for small M | Sorting + pruning crucial |
| Combination Sum II | O(2^N) | Manageable for N ≤ 30 | Duplicate skipping essential |
| Combination Sum III | O(C(9,k) × k) | At most ~500 ops | Fixed input range limits search |
Despite exponential worst-case complexity, these problems are practical because: (1) Input sizes are constrained (LeetCode limits N to ~30, target to ~500), (2) Sorting enables aggressive pruning, (3) Sum constraints eliminate most branches early. The theoretical exponential bound rarely manifests in practice.
The Combination Sum problems have several subtle gotchas that catch even experienced programmers. Let's catalog them.
Pitfall 1: Forgetting to Copy the Path
# WRONG
result.append(path) # Appends a reference!
# All entries in result point to the same list
# which gets modified as backtracking continues
# CORRECT
result.append(path[:]) # or list(path) or path.copy()
The path list is reused across recursive calls. Appending the reference means all results share the same list, which ends up empty after all backtracking completes.
Pitfall 2: Off-by-One in Duplicate Skipping
# WRONG
if candidates[i] == candidates[i-1]: # Missing i > start check!
continue
# This skips ALL duplicates, even valid ones at different levels
# CORRECT
if i > start and candidates[i] == candidates[i-1]:
continue
Pitfall 3: Using i+1 vs i for Recursion
i to allow reusing same elementi+1 to move past used elementMixing these up gives incorrect results silently—tests might pass for some cases.
What if target = 0? Depending on problem constraints, either the empty combination [] is valid, or 0 is not a valid input. Check problem constraints carefully. For most LeetCode variants, target ≥ 1, but in real interviews, clarify this edge case.
The Combination Sum family represents a fundamental backtracking pattern that appears throughout algorithmic problem-solving. Let's consolidate our learning:
What's next:
With the Combination Sum pattern mastered, we'll explore Palindrome Partitioning—a problem that combines string processing with backtracking to find all ways to split a string into palindromic substrings. This pattern introduces the concept of generating partitions and demonstrates backtracking on string structure.
You now have deep mastery of the Combination Sum problem family. These patterns—managing element reuse, handling duplicates, and controlling combination order—appear in countless variations. Practice implementing all three variants from scratch to solidify your understanding.