Facebook Pixel

39. Combination Sum

Problem Description

You are given an array of distinct integers called candidates and a target integer target. Your task is to find all unique combinations of numbers from candidates that sum up to target.

Key points about this problem:

  1. Numbers can be reused: You can use the same number from candidates multiple times in a single combination. For example, if candidates = [2, 3, 5] and target = 8, then [2, 2, 2, 2] is a valid combination.

  2. All numbers are distinct: The candidates array contains no duplicate values.

  3. Unique combinations: Two combinations are considered different if at least one number appears a different number of times. For example, [2, 2, 3] and [2, 3, 3] are different combinations.

  4. Order doesn't matter: [2, 3] and [3, 2] are considered the same combination, so you should only include one of them in your result.

  5. Return format: Return a list of lists, where each inner list represents one valid combination. The combinations can be returned in any order.

For example:

  • If candidates = [2, 3, 6, 7] and target = 7, the output would be [[2, 2, 3], [7]]
    • [2, 2, 3] sums to 7 (using 2 twice and 3 once)
    • [7] sums to 7 (using 7 once)

The problem guarantees that the number of unique combinations will be less than 150 for any given input.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem involves finding combinations from an array that sum to a target value. This is not about nodes and edges or graph traversal.

Need to solve for kth smallest/largest?

  • No: We're not looking for the kth smallest or largest element. We need to find all valid combinations that sum to the target.

Involves Linked Lists?

  • No: The problem works with an array of integers, not linked lists.

Does the problem have small constraints?

  • Yes: The problem statement explicitly mentions that the number of unique combinations is less than 150 for any given input. This is a relatively small constraint, suggesting that exploring all possibilities is feasible.

Brute force / Backtracking?

  • Yes: Since we need to find all possible combinations and the constraints are small, backtracking is the appropriate approach. We need to explore different combinations by:
    • Including an element (potentially multiple times)
    • Backtracking when the sum exceeds the target
    • Recording valid combinations when the sum equals the target
    • Trying different elements at each decision point

Conclusion: The flowchart correctly leads us to use a Backtracking approach. This makes sense because we need to systematically explore all possible combinations of numbers that can sum to the target, with the ability to reuse numbers and backtrack when a path doesn't lead to a valid solution.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

Think of this problem like making change with coins, but you have unlimited coins of each denomination and need to find all possible ways to make the exact amount.

The key insight is that at each step, we face a decision: which number should we try next? Since we can reuse numbers, after picking a number, we have the same set of choices available again. This naturally leads to a recursive approach where we:

  1. Pick a number from the available candidates
  2. Subtract it from our remaining target
  3. Recursively solve for the new remaining target

The beauty of backtracking here is that it allows us to explore all paths systematically. When we pick a number like 2 from [2, 3, 6, 7] to reach target 7, we continue using 2 until either:

  • We reach exactly 7 (found a valid combination!)
  • We exceed 7 (this path won't work, backtrack)
  • We realize we can't reach 7 with the current path

To avoid duplicate combinations like [2, 3] and [3, 2], we use a clever trick: always pick numbers in non-decreasing order. We do this by maintaining an index i that tells us the minimum position in the candidates array we can pick from. If we picked candidates[3], our next pick must be from index 3 or later, never from indices 0, 1, or 2.

Sorting the candidates array first gives us an additional optimization. If the smallest available candidate is already larger than our remaining target, we know that all subsequent candidates will also be too large (since the array is sorted). This allows us to prune entire branches of our search tree early, significantly improving performance.

The recursive nature of the problem becomes clear when you realize that after picking a number and subtracting it from the target, you're left with the exact same type of problem - just with a smaller target value. This self-similar structure is what makes backtracking with recursion such an elegant solution.

Learn more about Backtracking patterns.

Solution Approach

The implementation uses a Depth-First Search (DFS) with backtracking approach. Let's walk through the key components:

1. Sorting for Optimization

candidates.sort()

We first sort the candidates array. This enables early termination when we encounter a candidate larger than the remaining target, as all subsequent candidates will also be too large.

2. DFS Function Design

The core logic is in the dfs(i, s) function where:

  • i: The starting index in the candidates array (ensures we don't create duplicate combinations)
  • s: The remaining sum we need to reach

3. Base Cases

if s == 0:
    ans.append(t[:])
    return
if s < candidates[i]:
    return
  • Success case: When s == 0, we've found a valid combination. We add a copy of the current path t[:] to our answer.
  • Pruning case: When s < candidates[i], the smallest available candidate is too large, so we can stop exploring this branch.

4. Backtracking Logic

for j in range(i, len(candidates)):
    t.append(candidates[j])
    dfs(j, s - candidates[j])
    t.pop()

This is the heart of the backtracking algorithm:

  • Iterate from index i: This ensures we only consider candidates at position i or later, preventing duplicates
  • Make a choice: Add candidates[j] to our current path t
  • Explore: Recursively call dfs(j, s - candidates[j]). Note we pass j (not j+1) because we can reuse the same number
  • Backtrack: Remove the last element with t.pop() to try other possibilities

5. Data Structures

  • t = []: A temporary list that maintains the current combination being built
  • ans = []: Stores all valid combinations found

Why This Works

The algorithm systematically explores all possible combinations:

  1. For each candidate, it tries using it 0, 1, 2, ... times until the sum exceeds the target
  2. By starting each recursive call from index i or later, we ensure combinations like [2,3] and [3,2] aren't both generated
  3. The sorting optimization allows us to stop early when we know no valid combinations can be formed

Time Complexity Analysis

  • In the worst case, we might explore O(2^n) combinations (where each element can be included or not)
  • For each valid combination, we need O(n) time to copy it to the answer
  • Overall: O(2^n × n), though pruning significantly reduces the actual runtime

Space Complexity

  • The recursion depth can go up to target/min(candidates) in the worst case
  • We also store the temporary path t which can have at most target/min(candidates) elements
  • Overall: O(n) where n is the maximum depth of recursion

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's trace through the algorithm with candidates = [2, 3, 5] and target = 8.

Initial Setup:

  • Sort candidates: [2, 3, 5] (already sorted)
  • Start with empty path t = [] and call dfs(0, 8)

Execution Tree:

dfs(0, 8) - Can use candidates from index 0 onwards
├─ Choose 2: t = [2], dfs(0, 6)
│  ├─ Choose 2: t = [2,2], dfs(0, 4)
│  │  ├─ Choose 2: t = [2,2,2], dfs(0, 2)
│  │  │  ├─ Choose 2: t = [2,2,2,2], dfs(0, 0) ✓ Found [2,2,2,2]
│  │  │  ├─ Choose 3: t = [2,2,2,3], dfs(1, -1) ✗ (negative sum)
│  │  │  └─ Choose 5: t = [2,2,2,5], dfs(2, -3) ✗ (negative sum)
│  │  ├─ Choose 3: t = [2,2,3], dfs(1, 1)
│  │  │  └─ 1 < 3, return ✗ (remaining sum too small)
│  │  └─ Choose 5: t = [2,2,5], dfs(2, -1) ✗ (negative sum)
│  ├─ Choose 3: t = [2,3], dfs(1, 3)
│  │  ├─ Choose 3: t = [2,3,3], dfs(1, 0) ✓ Found [2,3,3]
│  │  └─ Choose 5: t = [2,3,5], dfs(2, -2) ✗ (negative sum)
│  └─ Choose 5: t = [2,5], dfs(2, 1)
│     └─ 1 < 5, return ✗ (remaining sum too small)
├─ Choose 3: t = [3], dfs(1, 5)
│  ├─ Choose 3: t = [3,3], dfs(1, 2)
│  │  └─ 2 < 3, return ✗ (remaining sum too small)
│  └─ Choose 5: t = [3,5], dfs(2, 0) ✓ Found [3,5]
└─ Choose 5: t = [5], dfs(2, 3)
   └─ 3 < 5, return ✗ (remaining sum too small)

Step-by-step for finding [2,2,2,2]:

  1. Start with dfs(0, 8), choose candidates[0] = 2, add to path: t = [2]
  2. Call dfs(0, 6), choose candidates[0] = 2 again, add to path: t = [2,2]
  3. Call dfs(0, 4), choose candidates[0] = 2 again, add to path: t = [2,2,2]
  4. Call dfs(0, 2), choose candidates[0] = 2 again, add to path: t = [2,2,2,2]
  5. Call dfs(0, 0), sum is 0 - we found a valid combination! Add [2,2,2,2] to answer
  6. Backtrack by popping elements and trying other branches

Key Observations:

  • When we choose index j, the next recursive call uses j (not j+1) as the starting index, allowing number reuse
  • The algorithm avoids duplicates like [3,2,3] because once we choose 3 at index 1, we can't go back to choose 2 at index 0
  • Early termination happens when remaining sum is less than the smallest available candidate

Final Result: [[2,2,2,2], [2,3,3], [3,5]]

Solution Implementation

1class Solution:
2    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
3        """
4        Find all unique combinations of candidates that sum to target.
5        Each candidate can be used unlimited times.
6      
7        Args:
8            candidates: List of distinct positive integers
9            target: Target sum to achieve
10          
11        Returns:
12            List of all unique combinations that sum to target
13        """
14      
15        def backtrack(start_index: int, remaining_sum: int) -> None:
16            """
17            Recursive helper function to explore all valid combinations.
18          
19            Args:
20                start_index: Current index in candidates array to avoid duplicates
21                remaining_sum: Remaining sum needed to reach target
22            """
23            # Base case: found a valid combination
24            if remaining_sum == 0:
25                result.append(current_combination[:])
26                return
27          
28            # Pruning: if remaining sum is less than smallest available candidate
29            if remaining_sum < candidates[start_index]:
30                return
31          
32            # Try each candidate from start_index onwards
33            for index in range(start_index, len(candidates)):
34                # Include current candidate in the combination
35                current_combination.append(candidates[index])
36              
37                # Recursively explore with same index (allowing reuse)
38                backtrack(index, remaining_sum - candidates[index])
39              
40                # Backtrack: remove the last added candidate
41                current_combination.pop()
42      
43        # Sort candidates for optimization (early termination)
44        candidates.sort()
45      
46        # Initialize variables
47        current_combination = []  # Tracks current combination being built
48        result = []  # Stores all valid combinations
49      
50        # Start the backtracking process
51        backtrack(0, target)
52      
53        return result
54
1class Solution {
2    // Store all valid combinations that sum to target
3    private List<List<Integer>> result = new ArrayList<>();
4    // Store current combination being built
5    private List<Integer> currentCombination = new ArrayList<>();
6    // Store the candidate numbers array
7    private int[] candidates;
8
9    /**
10     * Find all unique combinations where the candidate numbers sum to target.
11     * The same number may be chosen from candidates an unlimited number of times.
12     * 
13     * @param candidates Array of distinct integers
14     * @param target Target sum to achieve
15     * @return List of all unique combinations that sum to target
16     */
17    public List<List<Integer>> combinationSum(int[] candidates, int target) {
18        // Sort candidates to enable early termination in DFS
19        Arrays.sort(candidates);
20        this.candidates = candidates;
21      
22        // Start DFS from index 0 with the initial target
23        dfs(0, target);
24      
25        return result;
26    }
27
28    /**
29     * Depth-first search to find all combinations starting from index startIndex
30     * 
31     * @param startIndex Starting index in candidates array to avoid duplicates
32     * @param remainingTarget Remaining sum needed to reach the target
33     */
34    private void dfs(int startIndex, int remainingTarget) {
35        // Base case: found a valid combination
36        if (remainingTarget == 0) {
37            result.add(new ArrayList<>(currentCombination));
38            return;
39        }
40      
41        // Early termination: remaining target is less than smallest available candidate
42        if (remainingTarget < candidates[startIndex]) {
43            return;
44        }
45      
46        // Try each candidate starting from startIndex
47        for (int i = startIndex; i < candidates.length; ++i) {
48            // Add current candidate to the combination
49            currentCombination.add(candidates[i]);
50          
51            // Recursively search with the same starting index (allowing reuse)
52            // and reduced target
53            dfs(i, remainingTarget - candidates[i]);
54          
55            // Backtrack: remove the last added candidate
56            currentCombination.remove(currentCombination.size() - 1);
57        }
58    }
59}
60
1class Solution {
2public:
3    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
4        // Sort candidates in ascending order for optimization
5        sort(candidates.begin(), candidates.end());
6      
7        // Store all valid combinations
8        vector<vector<int>> result;
9      
10        // Current combination being built
11        vector<int> currentCombination;
12      
13        // Define recursive backtracking function using lambda
14        // Parameters: startIndex - current position in candidates array
15        //            remainingSum - remaining target value to achieve
16        function<void(int, int)> backtrack = [&](int startIndex, int remainingSum) {
17            // Base case: found a valid combination
18            if (remainingSum == 0) {
19                result.emplace_back(currentCombination);
20                return;
21            }
22          
23            // Pruning: remaining sum is less than smallest available candidate
24            if (remainingSum < candidates[startIndex]) {
25                return;
26            }
27          
28            // Try each candidate starting from current index
29            // Allow reuse by starting from same index (j) in recursive call
30            for (int j = startIndex; j < candidates.size(); ++j) {
31                // Choose current candidate
32                currentCombination.push_back(candidates[j]);
33              
34                // Explore with this choice (allow reuse by passing j, not j+1)
35                backtrack(j, remainingSum - candidates[j]);
36              
37                // Backtrack: remove current candidate for next iteration
38                currentCombination.pop_back();
39            }
40        };
41      
42        // Start backtracking from index 0 with full target sum
43        backtrack(0, target);
44      
45        return result;
46    }
47};
48
1/**
2 * Finds all unique combinations in candidates where the numbers sum to target.
3 * Each number in candidates may be used unlimited times in the combination.
4 * @param candidates - Array of distinct positive integers
5 * @param target - Target sum to achieve
6 * @returns Array of arrays containing all unique combinations that sum to target
7 */
8function combinationSum(candidates: number[], target: number): number[][] {
9    // Sort candidates in ascending order for optimization
10    candidates.sort((a: number, b: number) => a - b);
11  
12    // Store all valid combinations
13    const result: number[][] = [];
14  
15    // Current combination being built
16    const currentCombination: number[] = [];
17  
18    /**
19     * Depth-first search to explore all possible combinations
20     * @param startIndex - Starting index in candidates array to avoid duplicates
21     * @param remainingSum - Remaining sum needed to reach target
22     */
23    const depthFirstSearch = (startIndex: number, remainingSum: number): void => {
24        // Base case: found a valid combination
25        if (remainingSum === 0) {
26            // Add a copy of current combination to results
27            result.push([...currentCombination]);
28            return;
29        }
30      
31        // Pruning: if remaining sum is less than smallest available candidate
32        if (remainingSum < candidates[startIndex]) {
33            return;
34        }
35      
36        // Try each candidate starting from startIndex
37        for (let index: number = startIndex; index < candidates.length; index++) {
38            // Add current candidate to combination
39            currentCombination.push(candidates[index]);
40          
41            // Recursively search with same index (allowing reuse) and reduced sum
42            depthFirstSearch(index, remainingSum - candidates[index]);
43          
44            // Backtrack: remove the last added candidate
45            currentCombination.pop();
46        }
47    };
48  
49    // Start the search from index 0 with full target sum
50    depthFirstSearch(0, target);
51  
52    return result;
53}
54

Time and Space Complexity

Time Complexity: O(N^(T/M)) where N is the number of candidates, T is the target value, and M is the minimal value among the candidates.

The analysis breaks down as follows:

  • In the worst case, the recursion tree can have a maximum depth of T/M (when we keep choosing the smallest element repeatedly until we reach the target).
  • At each level of recursion, we can branch out to at most N different candidates (the for loop iterates through all candidates from index i to the end).
  • The number of nodes in the recursion tree represents the number of recursive calls, which is bounded by N^(T/M).
  • At each leaf node where we find a valid combination (s == 0), we append a copy of the current path to the answer, which takes O(T/M) time in the worst case.
  • However, the number of valid combinations is typically much smaller than the total number of nodes, so the dominant factor remains O(N^(T/M)).

Space Complexity: O(T/M)

The space complexity consists of:

  • Recursion stack depth: The maximum depth of the recursion is T/M (when we repeatedly choose the minimum value), requiring O(T/M) space for the call stack.
  • Temporary list t: This list stores the current combination being built and can have at most T/M elements, requiring O(T/M) space.
  • Output space: The space for storing the final answer ans is not counted as part of the auxiliary space complexity as it's required for the output.

Note that sorting the candidates takes O(N log N) time but only O(1) or O(log N) auxiliary space depending on the sorting algorithm used, which doesn't affect the overall space complexity dominated by the recursion depth.

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Creating Duplicate Combinations by Not Maintaining Start Index

The Pitfall: A common mistake is to iterate through ALL candidates in each recursive call instead of starting from the current index. This leads to duplicate combinations in different orders.

Incorrect Implementation:

def backtrack(remaining_sum):
    if remaining_sum == 0:
        result.append(current_combination[:])
        return
  
    # WRONG: Starting from 0 every time creates duplicates
    for i in range(len(candidates)):  
        if candidates[i] <= remaining_sum:
            current_combination.append(candidates[i])
            backtrack(remaining_sum - candidates[i])
            current_combination.pop()

Why it's wrong: This would generate both [2, 3] and [3, 2] as separate combinations when target = 5 and candidates = [2, 3].

The Fix: Always pass and use a start_index parameter to ensure you only consider candidates from the current position onwards:

def backtrack(start_index, remaining_sum):
    # ... base cases ...
  
    # Correct: Start from start_index to avoid duplicates
    for i in range(start_index, len(candidates)):
        current_combination.append(candidates[i])
        backtrack(i, remaining_sum - candidates[i])  # Pass i, not i+1
        current_combination.pop()

2. Forgetting to Copy the List When Adding to Results

The Pitfall: Appending the reference to current_combination instead of a copy means all results will point to the same list object, which gets modified throughout the recursion.

Incorrect Implementation:

if remaining_sum == 0:
    result.append(current_combination)  # WRONG: Appends reference
    return

Why it's wrong: Since current_combination is modified during backtracking, all entries in result will end up being empty lists or containing the same final state.

The Fix: Always create a copy of the current combination when adding it to results:

if remaining_sum == 0:
    result.append(current_combination[:])  # Correct: Creates a copy
    # Or use: result.append(list(current_combination))
    return

3. Incorrect Pruning Logic After Sorting

The Pitfall: When implementing the optimization with sorted candidates, accessing candidates[start_index] without checking bounds can cause an IndexError.

Incorrect Implementation:

def backtrack(start_index, remaining_sum):
    if remaining_sum == 0:
        result.append(current_combination[:])
        return
  
    # WRONG: No bounds checking before accessing candidates[start_index]
    if remaining_sum < candidates[start_index]:  
        return

Why it's wrong: If start_index >= len(candidates), this will throw an IndexError.

The Fix: Add proper bounds checking:

def backtrack(start_index, remaining_sum):
    if remaining_sum == 0:
        result.append(current_combination[:])
        return
  
    # Correct: Check bounds first
    if start_index >= len(candidates) or remaining_sum < candidates[start_index]:
        return

4. Using i+1 Instead of i in Recursive Call

The Pitfall: Passing i+1 instead of i to the recursive call prevents reusing the same number multiple times.

Incorrect Implementation:

for i in range(start_index, len(candidates)):
    current_combination.append(candidates[i])
    backtrack(i + 1, remaining_sum - candidates[i])  # WRONG: Can't reuse numbers
    current_combination.pop()

Why it's wrong: This would make it impossible to generate combinations like [2, 2, 3] where a number appears multiple times.

The Fix: Pass i (not i+1) to allow reusing the same candidate:

for i in range(start_index, len(candidates)):
    current_combination.append(candidates[i])
    backtrack(i, remaining_sum - candidates[i])  # Correct: Allows reuse
    current_combination.pop()
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of these pictures shows the visit order of a depth-first search?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More