Facebook Pixel

40. Combination Sum II

Problem Description

You are given an array of candidate numbers (candidates) and a target number (target). Your task is to find all unique combinations of numbers from candidates that sum up to the target.

The key constraints are:

  • Each number in candidates can only be used once in each combination
  • The solution set must not contain duplicate combinations

For example, if candidates = [10, 1, 2, 7, 6, 1, 5] and target = 8, the valid combinations would be:

  • [1, 1, 6]
  • [1, 2, 5]
  • [1, 7]
  • [2, 6]

Note that even though the array might contain duplicate values (like two 1's in the example), each element at a specific index can only be used once. However, if there are multiple occurrences of the same value, they can appear together in a combination (like [1, 1, 6] using both 1's from different positions).

The challenge involves:

  1. Finding all valid combinations that sum to the target
  2. Ensuring each element is used at most once
  3. Avoiding duplicate combinations in the result set

This is a classic backtracking problem where you need to explore all possible combinations while pruning invalid paths and handling duplicates appropriately.

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 candidates, not linked lists.

Does the problem have small constraints?

  • Yes: This is a combination problem where we need to explore all possible combinations. The nature of finding all unique combinations suggests that the constraints would be reasonably small (typical constraints for such problems are array length ≤ 100 and values ≤ 50), as the number of combinations can grow exponentially.

Brute force / Backtracking?

  • Yes: We need to systematically explore all possible combinations by:
    • Including or excluding each element
    • Tracking the current sum
    • Backtracking when we exceed the target or complete a valid combination
    • Handling duplicates to ensure unique combinations

Conclusion: The flowchart correctly leads us to use a Backtracking approach. This makes sense because we need to:

  1. Explore all possible combinations systematically
  2. Build combinations incrementally
  3. Abandon paths that exceed the target (pruning)
  4. Backtrack to try different combinations
  5. Handle duplicate elements to avoid duplicate combinations in the result
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we need to find all combinations that sum to a target, we're essentially building subsets of the array where each subset's sum equals the target. Since we need all valid combinations, we must explore every possibility systematically - this naturally leads to a backtracking approach.

The key insight is that at each step, we have a choice: include the current element or skip it. If we include it, we reduce our remaining target by that element's value and move forward. If we skip it, we simply move to the next element. This creates a decision tree where each path represents a potential combination.

However, there are two main challenges to address:

  1. Avoiding duplicate combinations: Since the array might contain duplicate values, we could end up with identical combinations through different paths. For example, if we have [1, 1, 2] and target 3, we might get [1, 2] twice using different 1s. To solve this, we first sort the array. Then, when we're at the same recursion level (same position in our decision-making), we skip over duplicate values after using them once. This ensures we don't create the same combination multiple times.

  2. Efficient pruning: Since we sorted the array, if our current element is already larger than the remaining target, all subsequent elements will also be too large (they're equal or greater). We can immediately stop exploring this path, significantly reducing unnecessary computations.

The backtracking pattern emerges naturally: we build our combination step by step, adding elements to our current path (t). When we reach our target sum (success!) or determine we can't proceed (too large or out of elements), we backtrack by removing the last added element and trying the next possibility. This systematic exploration with pruning ensures we find all valid combinations efficiently without repetition.

Learn more about Backtracking patterns.

Solution Approach

The implementation follows a sorting + pruning + backtracking strategy. Let's walk through how the solution works:

1. Initial Setup and Sorting

First, we sort the candidates array. This serves two crucial purposes:

  • It allows us to skip duplicate values efficiently
  • It enables early termination when elements become too large
candidates.sort()
ans = []  # stores all valid combinations
t = []    # current path/combination being built

2. The DFS Function Design

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

  • i represents the current index in the candidates array
  • s represents the remaining target sum we need to achieve

3. Base Cases

if s == 0:
    ans.append(t[:])  # Found valid combination, add copy to answer
    return
if i >= len(candidates) or s < candidates[i]:
    return  # Out of bounds or current element too large

When s == 0, we've found a valid combination. We append a copy of the current path t[:] to our answer.

When i >= len(candidates) or s < candidates[i], we can't proceed further. The second condition is our pruning optimization - since the array is sorted, if the current element is larger than the remaining sum, all subsequent elements will also be too large.

4. Exploration with Duplicate Handling

for j in range(i, len(candidates)):
    if j > i and candidates[j] == candidates[j - 1]:
        continue  # Skip duplicates at the same recursion level
    t.append(candidates[j])
    dfs(j + 1, s - candidates[j])
    t.pop()

The loop explores all possibilities starting from index i. The critical duplicate-handling logic is:

  • j > i and candidates[j] == candidates[j - 1]: This condition ensures we skip duplicate values at the same recursion level
  • When j == i, we're using the first occurrence of a value, which is allowed
  • When j > i and the current value equals the previous one, we skip it because we've already explored combinations starting with that value

5. Backtracking Pattern

The backtracking happens through:

  1. Add: t.append(candidates[j]) - add current element to path
  2. Explore: dfs(j + 1, s - candidates[j]) - recursively explore with updated index and remaining sum
  3. Remove: t.pop() - remove the element to try next possibility

Note that we pass j + 1 (not i + 1) to ensure each element is used only once.

Time and Space Complexity

  • Time Complexity: O(2^n × n) in the worst case, where n is the length of candidates. Each element has two choices (include/exclude), and we might need to copy paths of length up to n. However, pruning significantly reduces the actual time.
  • Space Complexity: O(n) for the recursion stack and temporary path storage.

The elegance of this solution lies in how sorting enables both duplicate handling and pruning, while the backtracking pattern systematically explores all valid combinations without repetition.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's trace through a small example with candidates = [2, 1, 2, 1] and target = 3.

Step 1: Sort the array After sorting: candidates = [1, 1, 2, 2]

Step 2: Start DFS exploration

We'll track the recursion tree, where each call is dfs(i, s) with current path t:

Initial: dfs(0, 3), t = []
├─ j=0: Choose candidates[0]=1
dfs(1, 2), t = [1]
│  │
│  ├─ j=1: Choose candidates[1]=1
│  │  dfs(2, 1), t = [1, 1]
│  │  │
│  │  ├─ j=2: Choose candidates[2]=2
│  │  │  dfs(3, -1), t = [1, 1, 2]
│  │  │  s < 0, backtrack
│  │  │
│  │  └─ j=3: s=1 < candidates[3]=2
│  │     Skip (pruning)
│  │  
│  ├─ j=2: Choose candidates[2]=2
│  │  dfs(3, 0), t = [1, 2]
│  │  s = 0! Found combination [1, 2] ✓
│  │
│  └─ j=3: Skip (duplicate of candidates[2])
├─ j=1: Skip (duplicate of candidates[0] at same level)
├─ j=2: Choose candidates[2]=2
│  dfs(3, 1), t = [2]
│  │
│  └─ j=3: Choose candidates[3]=2
dfs(4, -1), t = [2, 2]
s < 0, backtrack
└─ j=3: Skip (duplicate of candidates[2] at same level)

Key observations from the walkthrough:

  1. Duplicate handling: At the root level, we skip j=1 because candidates[1]=1 equals candidates[0]=1. Similarly, we skip j=3 because it equals candidates[2]=2.

  2. Different recursion levels: Inside the first branch (after choosing candidates[0]=1), we CAN choose candidates[1]=1 because it's at a different recursion level (j=i condition).

  3. Pruning: When exploring [1, 1] with remaining sum 1, we skip candidates[3]=2 because it's larger than our remaining target.

  4. Backtracking: After finding [1, 2], we backtrack (pop 2, then pop 1) to explore other possibilities.

Final Result: [[1, 2]]

This example demonstrates how the algorithm:

  • Uses sorting to group duplicates together
  • Skips duplicates at the same recursion level to avoid duplicate combinations
  • Prunes branches early when elements are too large
  • Systematically explores all valid paths through backtracking

Solution Implementation

1class Solution:
2    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
3        """
4        Find all unique combinations where the candidate numbers sum to target.
5        Each number in candidates may only be used once in the combination.
6      
7        Args:
8            candidates: List of candidate numbers
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 to start searching from
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: stop if we've exhausted candidates or remaining sum is too small
29            if start_index >= len(candidates) or remaining_sum < candidates[start_index]:
30                return
31          
32            # Try each candidate starting from start_index
33            for idx in range(start_index, len(candidates)):
34                # Skip duplicates to avoid duplicate combinations
35                # Only skip if it's not the first element in this recursion level
36                if idx > start_index and candidates[idx] == candidates[idx - 1]:
37                    continue
38              
39                # Include current candidate in the combination
40                current_combination.append(candidates[idx])
41              
42                # Recurse with next index and reduced sum
43                backtrack(idx + 1, remaining_sum - candidates[idx])
44              
45                # Backtrack: remove the current candidate for next iteration
46                current_combination.pop()
47      
48        # Sort candidates to handle duplicates and enable early termination
49        candidates.sort()
50      
51        # Initialize result list and temporary combination tracker
52        result = []
53        current_combination = []
54      
55        # Start the backtracking process
56        backtrack(0, target)
57      
58        return result
59
1class Solution {
2    // Store all valid combinations that sum to target
3    private List<List<Integer>> result = new ArrayList<>();
4    // Store current combination being explored
5    private List<Integer> currentCombination = new ArrayList<>();
6    // Store the input candidates array
7    private int[] candidates;
8
9    /**
10     * Find all unique combinations where candidate numbers sum to target.
11     * Each number in candidates may only be used once in the combination.
12     * 
13     * @param candidates array of candidate numbers (may contain duplicates)
14     * @param target target sum to achieve
15     * @return list of all unique combinations that sum to target
16     */
17    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
18        // Sort array to handle duplicates and enable early termination
19        Arrays.sort(candidates);
20        this.candidates = candidates;
21      
22        // Start DFS from index 0 with initial target sum
23        backtrack(0, target);
24      
25        return result;
26    }
27
28    /**
29     * Recursive backtracking to find all valid combinations.
30     * 
31     * @param startIndex current index in candidates array to start from
32     * @param remainingSum remaining sum needed to reach target
33     */
34    private void backtrack(int startIndex, int remainingSum) {
35        // Base case: found a valid combination
36        if (remainingSum == 0) {
37            result.add(new ArrayList<>(currentCombination));
38            return;
39        }
40      
41        // Pruning: stop if we've exhausted all candidates or 
42        // remaining sum is less than smallest available candidate
43        if (startIndex >= candidates.length || remainingSum < candidates[startIndex]) {
44            return;
45        }
46      
47        // Try each candidate starting from startIndex
48        for (int index = startIndex; index < candidates.length; index++) {
49            // Skip duplicates to avoid duplicate combinations
50            // Only skip if it's not the first element in this recursion level
51            if (index > startIndex && candidates[index] == candidates[index - 1]) {
52                continue;
53            }
54          
55            // Include current candidate in the combination
56            currentCombination.add(candidates[index]);
57          
58            // Recursively explore with updated parameters
59            // Move to next index (index + 1) since each element can only be used once
60            backtrack(index + 1, remainingSum - candidates[index]);
61          
62            // Backtrack: remove the last added element to try other possibilities
63            currentCombination.remove(currentCombination.size() - 1);
64        }
65    }
66}
67
1class Solution {
2public:
3    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
4        // Sort the candidates array to handle duplicates and enable early termination
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        function<void(int, int)> backtrack = [&](int startIndex, int remainingTarget) {
15            // Base case: found a valid combination
16            if (remainingTarget == 0) {
17                result.emplace_back(currentCombination);
18                return;
19            }
20          
21            // Pruning: stop if we've exhausted all candidates or remaining target is too small
22            if (startIndex >= candidates.size() || remainingTarget < candidates[startIndex]) {
23                return;
24            }
25          
26            // Try each candidate starting from startIndex
27            for (int currentIndex = startIndex; currentIndex < candidates.size(); ++currentIndex) {
28                // Skip duplicates at the same recursion level
29                // Allow the first occurrence, skip subsequent duplicates
30                if (currentIndex > startIndex && candidates[currentIndex] == candidates[currentIndex - 1]) {
31                    continue;
32                }
33              
34                // Include current candidate in the combination
35                currentCombination.emplace_back(candidates[currentIndex]);
36              
37                // Recursively search with updated parameters
38                // Move to next index since each element can only be used once
39                backtrack(currentIndex + 1, remainingTarget - candidates[currentIndex]);
40              
41                // Backtrack: remove the current candidate for next iteration
42                currentCombination.pop_back();
43            }
44        };
45      
46        // Start the backtracking process from index 0 with the full target
47        backtrack(0, target);
48      
49        return result;
50    }
51};
52
1/**
2 * Find all unique combinations in candidates where the candidate numbers sum to target.
3 * Each number in candidates may only be used once in the combination.
4 * @param candidates - Array of candidate numbers
5 * @param target - Target sum to achieve
6 * @returns Array of all unique combinations that sum to target
7 */
8function combinationSum2(candidates: number[], target: number): number[][] {
9    // Sort candidates in ascending order for optimization and duplicate handling
10    candidates.sort((a, b) => 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 - Current index in candidates array to start searching from
21     * @param remainingSum - Remaining sum needed to reach target
22     */
23    const backtrack = (startIndex: number, remainingSum: number): void => {
24        // Base case: found a valid combination
25        if (remainingSum === 0) {
26            result.push([...currentCombination]);
27            return;
28        }
29      
30        // Pruning: stop if we've exhausted candidates or remaining sum is too small
31        if (startIndex >= candidates.length || remainingSum < candidates[startIndex]) {
32            return;
33        }
34      
35        // Try each candidate starting from startIndex
36        for (let currentIndex = startIndex; currentIndex < candidates.length; currentIndex++) {
37            // Skip duplicates to avoid duplicate combinations
38            // Only skip if it's not the first element in this recursion level
39            if (currentIndex > startIndex && candidates[currentIndex] === candidates[currentIndex - 1]) {
40                continue;
41            }
42          
43            // Include current candidate in the combination
44            currentCombination.push(candidates[currentIndex]);
45          
46            // Recursively search with updated parameters
47            // Move to next index since each element can only be used once
48            backtrack(currentIndex + 1, remainingSum - candidates[currentIndex]);
49          
50            // Backtrack: remove the current candidate to try other possibilities
51            currentCombination.pop();
52        }
53    };
54  
55    // Start the search from index 0 with the full target sum
56    backtrack(0, target);
57  
58    return result;
59}
60

Time and Space Complexity

Time Complexity: O(2^n)

The algorithm explores all possible combinations of candidates. In the worst case where all elements can be used and the target is large, we might explore nearly all subsets of the candidates array. For each element, we have two choices: include it or skip it. However, due to pruning (when s < candidates[i] or duplicates are skipped), the actual number of recursive calls is reduced, but the upper bound remains O(2^n) where n is the length of the candidates array.

Additionally, sorting the array takes O(n log n) time. Since O(2^n) dominates O(n log n), the overall time complexity is O(2^n).

Space Complexity: O(n)

The space complexity consists of:

  • Recursion stack: The maximum depth of the recursion tree is O(n) in the worst case when we include all elements one by one.
  • Temporary array t: This array stores the current combination being built and can have at most n elements.
  • Output array ans: While this stores all valid combinations, it's typically not counted in space complexity analysis as it's part of the required output.

Therefore, the space complexity is O(n) for the recursion stack and temporary storage, where n is the length of the candidates array.

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

Common Pitfalls

1. Incorrect Duplicate Handling - Using Wrong Index Comparison

One of the most common mistakes is incorrectly handling duplicates by using the wrong condition for skipping duplicate elements.

Incorrect Implementation:

# WRONG: This skips ALL duplicates, even valid ones
for idx in range(start_index, len(candidates)):
    if idx > 0 and candidates[idx] == candidates[idx - 1]:  # Bug: idx > 0
        continue

The Problem: Using idx > 0 instead of idx > start_index will skip duplicates across different recursion levels. This causes valid combinations to be missed. For example, with candidates = [1, 1, 2] and target = 3, the combination [1, 1, 1] would be incorrectly skipped.

Correct Implementation:

for idx in range(start_index, len(candidates)):
    if idx > start_index and candidates[idx] == candidates[idx - 1]:
        continue

The key insight is that we only want to skip duplicates within the same recursion level, not across different levels.

2. Forgetting to Sort the Array

Incorrect Implementation:

def combinationSum2(self, candidates, target):
    # WRONG: Forgot to sort
    # candidates.sort()  <- Missing this line
    result = []
    current_combination = []
    backtrack(0, target)
    return result

The Problem: Without sorting:

  • The duplicate-skipping logic fails (adjacent duplicates might not be detected)
  • The pruning optimization remaining_sum < candidates[start_index] becomes ineffective
  • You might get duplicate combinations in the result

Solution: Always sort the candidates array before starting the backtracking process.

3. Using the Same Element Multiple Times

Incorrect Implementation:

def backtrack(start_index, remaining_sum):
    for idx in range(start_index, len(candidates)):
        current_combination.append(candidates[idx])
        # WRONG: Passing idx instead of idx + 1
        backtrack(idx, remaining_sum - candidates[idx])  # Bug: should be idx + 1
        current_combination.pop()

The Problem: Passing idx instead of idx + 1 allows the same element to be used multiple times, which violates the problem constraint. This would turn it into a "Combination Sum I" problem where elements can be reused.

Correct Implementation:

backtrack(idx + 1, remaining_sum - candidates[idx])

4. Appending Reference Instead of Copy

Incorrect Implementation:

if remaining_sum == 0:
    # WRONG: Appending reference to the mutable list
    result.append(current_combination)  # Bug: should be current_combination[:]
    return

The Problem: Python lists are mutable objects. Appending current_combination directly adds a reference to the same list object that continues to be modified during backtracking. This results in the final answer containing multiple references to the same (empty) list.

Correct Implementation:

if remaining_sum == 0:
    result.append(current_combination[:])  # Create a copy
    return

5. Missing Early Termination Optimization

Suboptimal Implementation:

def backtrack(start_index, remaining_sum):
    if remaining_sum == 0:
        result.append(current_combination[:])
        return
    if start_index >= len(candidates):  # Missing the pruning condition
        return
  
    for idx in range(start_index, len(candidates)):
        # No check if candidates[idx] > remaining_sum
        current_combination.append(candidates[idx])
        backtrack(idx + 1, remaining_sum - candidates[idx])
        current_combination.pop()

The Problem: Without checking remaining_sum < candidates[start_index], the algorithm explores branches that cannot possibly lead to valid solutions. Since the array is sorted, if the current smallest available element is larger than the remaining sum, all subsequent elements will also be too large.

Optimized Implementation:

if start_index >= len(candidates) or remaining_sum < candidates[start_index]:
    return

This pruning can significantly reduce the search space and improve performance, especially for large inputs with many elements greater than the target.

Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?


Recommended Readings

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

Load More