A clear explanation of finding unique combinations that sum to a target when each array element may be used at most once.
Problem Restatement
We are given an array candidates and an integer target.
We need to return all unique combinations where the chosen numbers sum to target.
Each number in candidates may be used at most once.
The input may contain duplicate values, but the answer must not contain duplicate combinations. The result may be returned in any order. The constraints are 1 <= candidates.length <= 100, 1 <= candidates[i] <= 50, and 1 <= target <= 30.
Input and Output
| Item | Meaning |
|---|---|
| Input | candidates and target |
| Output | A list of unique combinations |
| Candidate reuse | Each element can be used once |
| Duplicate values | May exist in candidates |
| Duplicate answers | Must not appear |
Function shape:
def combinationSum2(candidates: list[int], target: int) -> list[list[int]]:
...Examples
Example 1:
candidates = [10, 1, 2, 7, 6, 1, 5]
target = 8Valid combinations are:
[1, 1, 6]
[1, 2, 5]
[1, 7]
[2, 6]Return:
[[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]Example 2:
candidates = [2, 5, 2, 1, 2]
target = 5Valid combinations are:
[1, 2, 2]
[5]Return:
[[1, 2, 2], [5]]First Thought: Backtracking Over All Choices
A direct idea is to try every subset.
For each element, we either use it or skip it.
When the current sum reaches target, we save the current path.
This works in principle, but duplicate values create duplicate answers.
For example:
candidates = [1, 1, 2]
target = 3The first 1 plus 2 gives:
[1, 2]The second 1 plus 2 also gives:
[1, 2]These are the same combination, so we need a duplicate-skipping rule.
Key Insight
Sort the array first.
After sorting, equal values become adjacent.
Then during backtracking, if we are choosing among candidates at the same recursion level, we skip duplicate values after the first one.
The rule is:
if i > start and candidates[i] == candidates[i - 1]:
continueThis means:
If this value is the same as the previous value, and both are choices for the same slot in the combination, skip the later one.
This avoids duplicate combinations.
But we still allow duplicates when they come from different recursion levels.
For example, [1, 1, 6] is valid when there are two 1s in the input. The second 1 is allowed after choosing the first one because it is used at a deeper level, not as a duplicate sibling choice.
Algorithm
Sort candidates.
Use backtracking with:
| Variable | Meaning |
|---|---|
start | First index that can still be used |
remain | Remaining sum needed |
path | Current partial combination |
ans | Final list of combinations |
Recursive function:
backtrack(start, remain)Inside it:
- If
remain == 0, copypathintoans. - Loop
ifromstartto the end. - If
i > startandcandidates[i] == candidates[i - 1], skip it. - If
candidates[i] > remain, break. - Choose
candidates[i]. - Recurse with
i + 1, because each element may be used once. - Undo the choice.
The key difference from LeetCode 39 is this recursive call:
backtrack(i + 1, remain - candidates[i])In LeetCode 39, we used i because candidates could be reused.
Here, we use i + 1 because each element can be used at most once.
Correctness
The algorithm only adds a path to the answer when remain == 0. Since remain starts as target and every chosen value is subtracted from it, this means the chosen values sum exactly to target.
No element is used more than once. After choosing index i, the recursive call starts at i + 1, so index i cannot be chosen again.
Every generated path follows increasing index order. Therefore, each subset of indices has only one possible generation order.
The duplicate-skipping rule removes repeated sibling choices. When two equal values appear next to each other, choosing the later one as the first choice at the same recursion level would create the same value combination as choosing the earlier one. Skipping it removes that duplicate branch.
The rule does not block valid repeated values in a combination. If we choose the first duplicate value, the next recursive level starts after it, where the second duplicate value may still be chosen.
Therefore, the algorithm returns every valid unique combination exactly once.
Complexity
| Metric | Value | Why |
|---|---|---|
| Time | O(2^n * n) | In the worst case, we explore many subsets and copy paths |
| Space | O(n) excluding output | Recursion depth and current path can grow to n |
Sorting costs O(n log n), which is dominated by the backtracking search in the worst case.
Implementation
class Solution:
def combinationSum2(self, candidates: list[int], target: int) -> list[list[int]]:
candidates.sort()
ans = []
path = []
def backtrack(start: int, remain: int) -> None:
if remain == 0:
ans.append(path.copy())
return
for i in range(start, len(candidates)):
if i > start and candidates[i] == candidates[i - 1]:
continue
value = candidates[i]
if value > remain:
break
path.append(value)
backtrack(i + 1, remain - value)
path.pop()
backtrack(0, target)
return ansCode Explanation
We sort first:
candidates.sort()This makes duplicates adjacent and enables early stopping.
The answer stores complete combinations:
ans = []The path stores the current partial combination:
path = []The recursive function receives the first index still available:
def backtrack(start: int, remain: int) -> None:When remain == 0, the path is complete.
if remain == 0:
ans.append(path.copy())
returnLoop through available candidates:
for i in range(start, len(candidates)):Skip duplicate values at the same recursion level:
if i > start and candidates[i] == candidates[i - 1]:
continueIf the value is larger than the remaining target, stop.
if value > remain:
breakChoose the value:
path.append(value)Move to i + 1, so this element cannot be reused.
backtrack(i + 1, remain - value)Undo the choice:
path.pop()Testing
def normalize(result: list[list[int]]) -> list[list[int]]:
return sorted(sorted(row) for row in result)
def check(candidates: list[int], target: int, expected: list[list[int]]) -> None:
actual = Solution().combinationSum2(candidates, target)
assert normalize(actual) == normalize(expected), (candidates, target, actual, expected)
def run_tests():
check(
[10, 1, 2, 7, 6, 1, 5],
8,
[[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]],
)
check(
[2, 5, 2, 1, 2],
5,
[[1, 2, 2], [5]],
)
check([1, 1, 1, 1], 2, [[1, 1]])
check([3, 4, 5], 2, [])
check([1], 1, [[1]])
check([1], 2, [])
check([1, 2, 2, 2, 5], 5, [[1, 2, 2], [5]])
print("all tests passed")
run_tests()Test meaning:
| Test | Why |
|---|---|
[10,1,2,7,6,1,5], target 8 | Standard mixed example |
[2,5,2,1,2], target 5 | Duplicate-heavy example |
[1,1,1,1], target 2 | Many duplicate values, one unique answer |
[3,4,5], target 2 | No candidate can be used |
[1], target 1 | Single element success |
[1], target 2 | Single element failure |
[1,2,2,2,5], target 5 | Duplicate skipping with repeated valid value |