Skip to content

39. Combination Sum (Medium)

Problem

Given an array of distinct positive integers candidates and a target integer, return all unique combinations where the chosen numbers sum to target. You may use each candidate unlimited times. Combinations are unique if the multiset of numbers chosen is unique; order doesn’t matter.

Example

  • candidates = [2,3,6,7], target = 7[[2,2,3], [7]]
  • candidates = [2,3,5], target = 8[[2,2,2,2], [2,3,3], [3,5]]

LeetCode 39 · Link · Medium

Approach 1: Brute force, try all orderings, dedup

Recurse over every candidate (any number of times), deduplicate at the end by sorting each combination into a canonical form.

def combination_sum(candidates, target):
found = set()
def rec(remaining, path):
if remaining == 0:
found.add(tuple(sorted(path)))
return
if remaining < 0:
return
for c in candidates:
path.append(c)
rec(remaining - c, path)
path.pop()
rec(target, [])
return [list(t) for t in found]

Complexity

  • Time: Exponential, every ordered sequence is explored, then collapsed.
  • Space: Same.

Wasteful; every combination is re-found under every permutation.

Approach 2: Backtracking with start index (canonical)

Enforce an ordering: once you “use” index i, later recursive calls can only consider indices >= i. This guarantees each combination is built in sorted candidate order exactly once.

Because we can reuse the same candidate, we pass i (not i + 1) when recursing after including.

def combination_sum(candidates, target):
result = []
path = []
def backtrack(start, remaining):
if remaining == 0:
result.append(path[:]) # L1: O(k) copy at leaf
return
if remaining < 0:
return
for i in range(start, len(candidates)):
path.append(candidates[i]) # L2: O(1) push
backtrack(i, remaining - candidates[i]) # L3: recurse (reuse allowed)
path.pop() # L4: O(1) pop
backtrack(0, target)
return result

Where the time goes, line by line

Variables: n = len(candidates), k = solution length (target / min(candidates)).

LinePer-call costTimes executedContribution
L1 (copy path)O(k)one per solutionO(solutions · k)
L2/L4 (push/pop)O(1)one per nodeO(tree nodes)
L3 (recurse)O(1) dispatchtree nodesO(n^k) ← dominates

The recursion tree has branching factor n and maximum depth k = target / min(candidates). The number of nodes is bounded by O(n^k).

Complexity

  • Time: Exponential in the depth of the search tree, bounded by O(n^(target / min(candidates))).
  • Space: O(k) recursion, where k = target / min(candidates).

Approach 3: Sort + early termination (optimization)

Sort candidates ascending. When iterating, break the loop as soon as candidates[i] > remaining, every subsequent candidate is too large.

def combination_sum(candidates, target):
candidates.sort() # L1: O(n log n) sort once
result = []
path = []
def backtrack(start, remaining):
if remaining == 0:
result.append(path[:]) # L2: O(k) copy at leaf
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # L3: O(1) prune entire suffix
path.append(candidates[i]) # L4: O(1) push
backtrack(i, remaining - candidates[i]) # L5: recurse
path.pop() # L6: O(1) pop
backtrack(0, target)
return result

Where the time goes, line by line

Variables: n = len(candidates), k = solution length (target / min(candidates)).

LinePer-call costTimes executedContribution
L1 (sort)O(n log n)1O(n log n)
L3 (prune)O(1)pruned nodesO(pruned)
L5 (recurse)O(1) dispatchsurviving nodesO(n^k) worst ← dominates

Sorting enables L3 to cut entire subtrees. The asymptotic bound is the same, but the practical speedup on mixed-size inputs is substantial.

Complexity

  • Time: Same worst-case Big-O as Approach 2; practical speedup from pruning is substantial on inputs with mixed sizes.
  • Space: O(k) recursion, where k = target / min(candidates).

Summary

ApproachTimeSpace
All orderings + deduphugehuge
Backtracking with startO(n^(target/min))O(target/min)
Sort + prunesame Big-O, faster in practicesame

The start index is the critical idea, same template solves Combination Sum II (40) and Subsets II (90) with a small tweak for duplicates.

Test cases

def combination_sum(candidates, target):
candidates.sort()
result = []
path = []
def backtrack(start, remaining):
if remaining == 0:
result.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break
path.append(candidates[i])
backtrack(i, remaining - candidates[i])
path.pop()
backtrack(0, target)
return result
def _run_tests():
r = combination_sum([2, 3, 6, 7], 7)
assert sorted(map(tuple, r)) == sorted([tuple([2,2,3]), tuple([7])])
r2 = combination_sum([2, 3, 5], 8)
assert sorted(map(tuple, r2)) == sorted([tuple([2,2,2,2]), tuple([2,3,3]), tuple([3,5])])
# single candidate
assert combination_sum([3], 9) == [[3, 3, 3]]
# no solution
assert combination_sum([5], 3) == []
print("all tests pass")
if __name__ == "__main__":
_run_tests()
  • Arrays, candidate list; sorted for pruning