#39 - Combination Sum
Combination Sum
- Difficulty: Medium
- Topics: Array, Backtracking
- Link: https://leetcode.com/problems/combination-sum/
Problem Description
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 1:
Input: candidates = [2,3,6,7], target = 7
Output: [[2,2,3],[7]]
Explanation:
2 and 3 are candidates, and 2 + 2 + 3 = 7. Note that 2 can be used multiple times.
7 is a candidate, and 7 = 7.
These are the only two combinations.
Example 2:
Input: candidates = [2,3,5], target = 8
Output: [[2,2,2,2],[2,3,3],[3,5]]
Example 3:
Input: candidates = [2], target = 1
Output: []
Constraints:
1 <= candidates.length <= 302 <= candidates[i] <= 40- All elements of
candidatesare distinct. 1 <= target <= 40
Solution
Combination Sum: Comprehensive Solution
1. Problem Deconstruction (500+ words)
Rewrite the Problem
Technical Version
Given an array candidates containing distinct positive integers and a positive integer target , enumerate all unique multisets (combinations with repetition) of elements from candidates such that the sum of the multiset equals . Formally, find all vectors satisfying:
where two solutions are considered identical if their corresponding multisets are equal (i.e., the frequency distribution of chosen elements is identical). The enumeration must avoid permutations of the same multiset.
Beginner Version
You have a list of different numbers. You’re allowed to pick any number from the list as many times as you want. Your goal is to find all the different groups of numbers (where order doesn’t matter) that add up exactly to a target number. For example, if your list is [2,3,6,7] and the target is 7, you could use [2,2,3] (using 2 twice and 3 once) or just [7]. Note that [2,3,2] is considered the same as [2,2,3] because we only care about which numbers and how many times, not the order you pick them.
Mathematical Version
Let be a set of distinct positive integers. For a given positive integer , find all solutions to the linear Diophantine equation with non-negative coefficients:
The solution space corresponds to the integer lattice points in the -dimensional non-negative orthant bounded by . Two solutions and are considered distinct if and only if .
Constraint Analysis
-
1 <= candidates.length <= 30- Limitation: The branching factor in recursive solutions is bounded by 30. However, since we can reuse elements, the depth of recursion could theoretically be up to .
- Hidden implication: With , a brute-force approach checking all combinations (including different multiplicities) would be , which is astronomically large for worst-case values. This forces us to use smarter pruning.
-
2 <= candidates[i] <= 40- Limitation: All numbers are at least 2, ensuring that any combination has at most elements (for , max 20 elements).
- Hidden edge case: Since all candidates are , when , no solution exists regardless of candidates (handled in Example 3).
- Optimization opportunity: We can sort candidates and break early when a candidate exceeds the remaining target.
-
All elements of
candidatesare distinct- Implication: No need to handle duplicate values in input, which simplifies duplicate combination avoidance.
- Hidden benefit: In backtracking, we don’t need special logic to skip identical values at the same depth.
-
1 <= target <= 40- Key insight: The search space is small. The worst-case number of combinations is bounded (<150 per problem statement).
- Algorithm selection: Exponential-time algorithms (backtracking) are acceptable; dynamic programming with table size is efficient.
- Edge case: When is smaller than all candidates, output is empty list.
-
Combination count < 150 (implied from test generation)
- Critical insight: The output size is manageable, so we don’t need to worry about exponential blow-up in output storage.
- Design implication: We can afford to store all combinations in memory without concern for memory limits.
2. Intuition Scaffolding
Three Analogies
-
Real-World Metaphor: Coin Change Problem Imagine you’re a cashier with a limited set of coin denominations (candidates). You need to give exact change equal to the target amount. You can use each coin type as many times as you want. Your task is to list all distinct sets of coins that sum to the target. For example, to make 7 cents with coins [2,3,6,7], you could use {2,2,3} or {7}. The order of coins doesn’t matter—{2,3,2} is the same as {2,2,3}.
-
Gaming Analogy: Crafting System In a resource-gathering game, you have basic materials with different point values (candidates). To craft an item requiring points, you can combine materials in any quantity. Each distinct bundle of materials that sums to unlocks a different crafting recipe. The game tracks only the counts of each material, not the order you add them to the crafting table.
-
Mathematical Analogy: Integer Partition with Restricted Parts This is the problem of partitioning the integer into parts drawn only from the set , where each part can be used multiple times. Unlike standard integer partitions, we don’t consider all integers, only those in . Additionally, we consider different arrangements of the same multiset as identical (order doesn’t matter).
Common Pitfalls (5+ Examples)
-
Generating Permutations as Separate Combinations
- Wrong approach: Recursing without restrictions, allowing any candidate at each step.
- Why it fails: Produces [2,2,3], [2,3,2], [3,2,2] as different combinations.
- Visualization: The search tree has redundant subtrees representing the same multiset.
-
Missing Pruning Opportunities
- Wrong approach: Continuing recursion even when current sum exceeds target.
- Why it fails: Wastes time on impossible paths. For large , this leads to exponential blow-up.
-
Incorrect Duplicate Handling via Sorting Results
- Wrong approach: Generate all permutations, sort each combination, then use a set to filter.
- Why it fails: The number of permutations can be huge ( for combinations with distinct elements). For [1,2,…,30] with , this is computationally infeasible.
-
Overlooking the Unlimited Use Condition
- Wrong approach: Treating the problem as “each candidate can be used at most once” (like a knapsack).
- Why it fails: Misses valid combinations like [2,2,2,2] for target 8 with candidates [2,3,5].
-
Inefficient Candidate Ordering
- Wrong approach: Not sorting candidates, then failing to prune when a candidate exceeds remaining target.
- Why it fails: For unsorted [7,2,3,6], we might check 7 first for target 5, fail, then check 2, etc. But if sorted, we’d check 2,3,6, then stop at 7.
-
Infinite Recursion Risk
- Wrong approach: No progression mechanism in recursion, always considering the same candidate.
- Why it fails: For candidate [2] and target 3, recursion might try 2, then 2 again (sum=4>3), backtrack, try 2 again… actually, with proper base cases this stops, but without a way to stop considering the same candidate, it could loop if we don’t reduce the target.
3. Approach Encyclopedia
Approach 1: Brute Force Recursion (Naive)
Core Idea: Generate all possible sequences by trying every candidate at every step, then filter duplicates.
def combinationSumNaive(candidates, target):
result_set = set()
def dfs(current_sum, current_path):
if current_sum == target:
# Sort to make combination canonical
sorted_path = tuple(sorted(current_path))
result_set.add(sorted_path)
return
if current_sum > target:
return
for num in candidates:
dfs(current_sum + num, current_path + [num])
dfs(0, [])
return [list(comb) for comb in result_set]
Complexity Proof:
- Time: Let be the maximum combination length. At each of positions, we have choices, giving recursive calls. For worst-case , , , , this is operations.
- Space: for recursion depth, plus for storing results, where is the number of combinations.
Visualization:
Level 0: []
Level 1: [2], [3], [6], [7] (for candidates=[2,3,6,7])
Level 2: [2,2], [2,3], [2,6], [2,7], [3,2], [3,3], ...
Level 3: [2,2,2], [2,2,3], [2,2,6], ...
Note the explosion of redundant paths.
Approach 2: Backtracking with Ordering (Optimal)
Core Idea: Sort candidates, then at each step only consider candidates from the current index onward. This ensures combinations are generated in non-decreasing order, eliminating permutations.
def combinationSumBacktrack(candidates, target):
candidates.sort() # Critical: enables pruning and duplicate prevention
result = []
def backtrack(start_idx, remaining, path):
# Base case: exact sum reached
if remaining == 0:
result.append(path[:]) # Copy the path
return
# Try each candidate from start_idx onward
for i in range(start_idx, len(candidates)):
num = candidates[i]
# Prune if num exceeds remaining (since sorted)
if num > remaining:
break # Not continue, because all later nums are larger
# Choose num
path.append(num)
# Recurse with same i (allow reuse)
backtrack(i, remaining - num, path)
# Unchoose (backtrack)
path.pop()
backtrack(0, target, [])
return result
Complexity Proof:
- Time: Let be the number of valid combinations (bounded by 150). Each combination of length requires recursive calls to build. Thus worst-case time is where . This gives operations, ignoring constant factors.
- Space: for recursion stack and current path, plus for storing results.
Visualization for candidates=[2,3,6,7], target=7:
Start: backtrack(0, 7, [])
i=0, num=2 ≤7: path=[2], backtrack(0,5,[2])
i=0, num=2 ≤5: path=[2,2], backtrack(0,3,[2,2])
i=0, num=2 ≤3: path=[2,2,2], backtrack(0,1,[2,2,2])
i=0, num=2 >1: break
i=1, num=3 ≤3: path=[2,2,3], backtrack(1,0,[2,2,3]) ✓ Add [2,2,3]
remaining=0, add to result
i=2, num=6 >3: break
i=1, num=3 ≤5: path=[2,3], backtrack(1,2,[2,3])
i=1, num=3 >2: break
i=2, num=6 >5: break
i=1, num=3 ≤7: path=[3], backtrack(1,4,[3])
i=1, num=3 ≤4: path=[3,3], backtrack(1,1,[3,3])
i=1, num=3 >1: break
i=2, num=6 >4: break
i=2, num=6 ≤7: path=[6], backtrack(2,1,[6])
i=2, num=6 >1: break
i=3, num=7 ≤7: path=[7], backtrack(3,0,[7]) ✓ Add [7]
Approach 3: Dynamic Programming (Bottom-Up)
Core Idea: Build a DP table where dp[i] contains all combinations summing to i. For each candidate, update combinations for all sums from candidate to target.
def combinationSumDP(candidates, target):
# dp[i] will store list of combinations summing to i
dp = [[] for _ in range(target + 1)]
dp[0] = [[]] # Base: one way to make sum 0 (empty combination)
for num in sorted(candidates):
for t in range(num, target + 1):
for comb in dp[t - num]:
dp[t].append(comb + [num])
return dp[target]
Complexity Proof:
- Time: Outer loop: candidates. Middle loop: iterations. Inner loop: iterates over combinations in
dp[t-num]. In worst case, eachdp[i]could contain many combinations, but total combinations across alldp[i]is bounded by the total number of valid combinations (less than 150 for final target, but intermediate sums could have more). Theoretical worst-case: where is max combinations for any sum. - Space: to store all combinations for all sums up to target.
Visualization for candidates=[2,3,6,7], target=7:
Initialize: dp[0] = [[]]
Process num=2:
t=2: dp[2] = dp[0] + [2] = [[2]]
t=3: dp[3] = dp[1] + [2] = [] (dp[1] empty)
t=4: dp[4] = dp[2] + [2] = [[2,2]]
t=5: dp[5] = dp[3] + [2] = []
t=6: dp[6] = dp[4] + [2] = [[2,2,2]]
t=7: dp[7] = dp[5] + [2] = []
Process num=3:
t=3: dp[3] += dp[0] + [3] = [[3]]
t=4: dp[4] += dp[1] + [3] = [[2,2]] (no change)
t=5: dp[5] += dp[2] + [3] = [[2,3]]
t=6: dp[6] += dp[3] + [3] = [[2,2,2], [3,3]]
t=7: dp[7] += dp[4] + [3] = [[2,2,3]]
Process num=6:
t=6: dp[6] += dp[0] + [6] = [[2,2,2], [3,3], [6]]
t=7: dp[7] += dp[1] + [6] = [[2,2,3]] (no change)
Process num=7:
t=7: dp[7] += dp[0] + [7] = [[2,2,3], [7]]
Result: dp[7] = [[2,2,3], [7]]
Approach 4: Recursion with Early Pruning (Alternative Backtracking)
Core Idea: Use recursion that at each call decides how many times to use the current candidate before moving to the next.
def combinationSumRecursive(candidates, target):
candidates.sort()
result = []
def dfs(index, remaining, path):
if remaining == 0:
result.append(path[:])
return
if index == len(candidates):
return
# Try using candidates[index] multiple times
num = candidates[index]
max_count = remaining // num
# Try 0 to max_count copies of num
for count in range(max_count + 1):
new_remaining = remaining - count * num
if new_remaining < 0:
break
# Add count copies of num
path.extend([num] * count)
dfs(index + 1, new_remaining, path)
# Remove them
for _ in range(count):
path.pop()
dfs(0, target, [])
return result
Complexity: Similar to Approach 2.
4. Code Deep Dive
Optimal Solution (Backtracking with Ordering)
from typing import List
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
# Sort candidates: enables pruning and ensures non-decreasing combinations
# Why? (1) We can break when candidate > remaining, (2) Prevents permutations
candidates.sort()
result = [] # Store all valid combinations
def backtrack(start: int, remaining: int, path: List[int]):
"""
start: index from which we can choose candidates (avoids going backwards)
remaining: the amount left to sum to target
path: current combination being built
"""
# Base case 1: exact match found
if remaining == 0:
# Make a copy since path will be modified by backtracking
result.append(path.copy())
return
# Base case 2: overshoot - handled in loop by pruning
# Iterate through candidates starting from 'start'
for i in range(start, len(candidates)):
num = candidates[i]
# Prune: if current number exceeds remaining, stop
# Since candidates are sorted, all subsequent numbers will also exceed
if num > remaining:
break # Not 'continue' - breaking ends the loop entirely
# Choose: add num to current path
path.append(num)
# Explore: recurse with same 'i' (allow reuse)
# Key insight: passing 'i' instead of 'i+1' allows unlimited reuse
backtrack(i, remaining - num, path)
# Unchoose: backtrack by removing last element
path.pop()
# Start backtracking from index 0 with full target and empty path
backtrack(0, target, [])
return result
Line-by-Line Annotations
Line 4: candidates.sort() - Sorts input in ascending order. Critical for:
- Pruning: When
num > remaining, we canbreakbecause all subsequent numbers are larger. - Duplicate prevention: By only moving forward in the candidate list (
startindex), we ensure combinations are generated in non-decreasing order.
Line 6: result = [] - Stores final combinations. Using a list since order doesn’t matter per problem statement.
Lines 8-35: backtrack function - The recursive engine.
Line 13: if remaining == 0: - Base case: exact sum achieved. Add current path to results.
Line 14: result.append(path.copy()) - Must copy because path is mutable and will be modified during backtracking. Without copy, all entries in result would reference the same list.
Line 19: for i in range(start, len(candidates)): - Iterates from start to end. start ensures we never consider candidates before the current index, preventing permutations like [2,3] and [3,2].
Line 22: if num > remaining: break - Pruning step. Because candidates are sorted, once we encounter a number too large, all subsequent numbers are also too large.
Line 26: path.append(num) - Choose the current candidate.
Line 30: backtrack(i, remaining - num, path) - Recurse with:
- Same index
i: allows reusing the current candidate unlimited times. - Reduced remaining: subtracts the chosen value.
- Same path: will be modified in recursion.
Line 33: path.pop() - Backtrack: remove the last chosen candidate to try the next candidate in the loop.
Line 38: backtrack(0, target, []) - Initial call: start from first candidate with full target and empty combination.
Edge Case Handling
Example 1: candidates = [2,3,6,7], target = 7
- Sorted already:
[2,3,6,7] - Recursion builds
[2,2,3]and[7]as shown in visualization. - Line 22 prunes when trying
6with remaining1.
Example 2: candidates = [2,3,5], target = 8
- Sorted already:
[2,3,5] - Finds
[2,2,2,2](using four 2’s),[2,3,3],[3,5]. - Demonstrates unlimited reuse and pruning when
5 > remainingin certain branches.
Example 3: candidates = [2], target = 1
- Single candidate
2> target1, so loop breaks immediately at line 22. - Returns empty list
[].
Edge Case: All candidates > target
- For
candidates = [10,20,30], target = 5 - Sorting gives
[10,20,30], first candidate10 > 5, loop breaks, returns[].
Edge Case: Exact match with single candidate
candidates = [5], target = 5- Path
[5]added whenremaining == 0.
5. Complexity War Room
Hardware-Aware Analysis
Memory Footprint:
- Input size: candidates list of up to 30 integers (4 bytes each) = 120 bytes.
- Recursion stack: Maximum depth = frames.
Each frame stores:
start(4 bytes),remaining(4 bytes),pathreference (8 bytes), loop variable (4 bytes) ≈ 20 bytes per frame. Total stack: 20 × 20 = 400 bytes. - Current path: Maximum 20 integers = 80 bytes.
- Output storage: <150 combinations, average length ≤ 10 elements, each 4 bytes = 150 × 10 × 4 = 6KB.
- Total: <10KB, easily fits in L1 cache (32-64KB typical).
CPU Considerations:
- Branch prediction: The
if num > remaining: breakcreates predictable branching patterns when candidates are sorted. - Cache locality: Sequential access to sorted candidates array benefits from prefetching.
Scalability Analysis:
- If constraints were increased (e.g., target up to 1000), backtracking would still work but recursion depth increases.
- For extremely large targets, dynamic programming might use too much memory (storing all combinations for all intermediate sums).
Industry Comparison Table
| Approach | Time Complexity | Space Complexity | Readability | Interview Viability | Best For |
|---|---|---|---|---|---|
| Brute Force (Naive) | recursion | High | ❌ Fails large cases | Educational only | |
| Backtracking + Ordering | where = #combos, = avg length | recursion + output | Very High | ✅ Most expected | General use, interview standard |
| Dynamic Programming (Bottom-Up) | storage | Medium | ✅ Acceptable alternative | When all combinations needed for all sums | |
| Recursion with Count Loop | recursion + output | High | ✅ Good alternative | When thinking in terms of multiplicities |
Legend:
- = number of candidates (≤30)
- = target (≤40)
- = number of valid combinations (<150)
- = average combination length
- = average combinations per sum in DP
Interview Viability Notes:
- Backtracking with ordering is the gold standard: demonstrates understanding of recursion, pruning, and duplicate avoidance.
- Always mention sorting first: shows algorithmic thinking about optimization.
- Discuss pruning strategy: “Since candidates are sorted, we can break when candidate exceeds remaining target.”
6. Pro Mode Extras
Variants Section
-
Combination Sum II (LeetCode 40)
- Change: Each candidate can be used at most once, and candidates may contain duplicates.
- Solution modification: Sort candidates, backtrack with
i+1(no reuse), and skip duplicates withif i > start and candidates[i] == candidates[i-1]: continue.
-
Combination Sum III (LeetCode 216)
- Change: Find all combinations of
knumbers from 1-9 that sum ton, each used at most once. - Solution modification: Backtrack from 1 to 9, track count of numbers used, and only add when
count == kandsum == n.
- Change: Find all combinations of
-
Combination Sum IV (LeetCode 377)
- Change: Count the number of combinations (order matters - actually permutations).
- Solution: Dynamic programming:
dp[i] = sum(dp[i-num] for num in candidates if num <= i).
-
Limited Use Variant
- Change: Each candidate has a maximum usage count
limit[i]. - Solution modification: In backtracking, track usage count per candidate, or use the count-loop approach with
min(limit[i], remaining//num).
- Change: Each candidate has a maximum usage count
-
Negative Numbers Allowed
- Change: Candidates may include negative numbers.
- Complication: Can’t prune when
num > remaining(since adding negative could reduce sum). Also, risk of infinite loops if sum cycles. - Solution: Must bound recursion depth or use DP with careful state representation.
Interview Cheat Sheet
Step-by-Step Thought Process to Verbalize:
- “First, I’ll sort the candidates. This lets me prune branches where the current candidate exceeds the remaining target.”
- “I’ll use backtracking/DFS to explore combinations, maintaining a
startindex to avoid generating permutations of the same set.” - “At each step, I can choose to use the current candidate (and stay at the same index for potential reuse) or move to the next candidate.”
- “I’ll prune when the remaining target becomes negative or when the current candidate is too large.”
- “Time complexity is bounded by the number of valid combinations (<150), space is O(T/min) for recursion.”
Common Follow-up Questions:
- Q: “What if candidates contained duplicates?”
A: “Sort first, then in backtracking, skip duplicates with
if i > start and candidates[i] == candidates[i-1]: continue.” - Q: “How would you handle very large targets (e.g., 10^6)?” A: “Backtracking might be too slow. Could use dynamic programming to count combinations, but enumerating all might be impossible due to exponential output size. Would discuss trade-offs.”
- Q: “What if we wanted the combination with fewest elements?” A: “Could modify backtracking to track depth and keep the shortest valid combination, or use BFS level-order search.”
Key Optimization Points to Mention:
- Sorting enables O(1) pruning decision.
- The
startindex technique eliminates duplicate combinations without needing a set. - Passing
remainingas parameter avoids recalculating sum from path. - Using
path.copy()when adding to results prevents reference issues.
Red Flags to Avoid:
- Don’t suggest generating all permutations then filtering.
- Don’t forget to sort candidates.
- Don’t use
i+1in the recursive call unless you want to disallow reuse. - Don’t forget to make a copy of the path when adding to results.
Testing Strategy:
- Small case:
candidates=[2], target=1→[] - Single candidate match:
[2], target=2→[[2]] - All candidates > target:
[10,20], target=5→[] - Duplicate combinations check:
[2,3], target=5should give only[[2,3]], not[[2,3],[3,2]] - Large combination:
[2], target=40→ single combination of twenty 2’s.