Problem overview
The method combinationSum2 receives candidates, an Array[Int] that may contain repeated numbers, and a target. It returns every distinct combination of candidates that adds up to target, as a List[List[Int]]. Each candidate can be used at most once, so a number that appears twice in the array can appear up to twice in a combination.
Two combinations are the same when they hold the same numbers the same number of times, whichever copies they use. In [1,1,7] with target = 8, the first 1 with the 7 and the second 1 with the 7 are the same combination, [1,7], and it is listed once. The numbers of each combination are in ascending order, and the combinations are in lexicographic order. When nothing adds up to the target, the result is an empty list.
For [10,1,2,7,6,1,5] with target = 8 the answer is List(List(1, 1, 6), List(1, 2, 5), List(1, 7), List(2, 6)). For [2,3] with target = 4 it is empty: 2 + 3 is 5, and the 2 can't be used twice.
Approach: sorted backtracking with a skip rule
Trying every subset of the candidates and summing it would take up to 2^n subsets with up to 100 candidates, and it would still need a way to drop repeated combinations. Backtracking builds combinations one number at a time and abandons a partial combination as soon as it can't succeed.
First sort candidates. Sorting makes equal numbers neighbours, gives the required order, and allows an early stop: once a candidate is bigger than what the combination still needs, every later one is bigger too.
A call receives start, the first index it may use, remaining, how much the combination still needs, and current, the combination so far. When remaining is 0, the combination is complete and a copy is saved. Otherwise a loop tries each index i from start as the next number: add candidates(i), call again with i + 1 and remaining - candidates(i), then remove the number.
Two details separate this from the version where numbers may be reused. The recursive call starts at i + 1 rather than at i, so a candidate is used at most once. And the loop skips candidates(i) when it equals candidates(i - 1) and i > start: this call already tried that value as its next number, so a second copy would build the same combinations again. The copy at i == start is still taken, which is how [1,1,6] gets its second 1.
Step by step
- Sort
candidatesin place withscala.util.Sorting.quickSort(candidates). - Create
result, an emptyArrayBuffer[List[Int]](). - Start the search with
backtrack(candidates, 0, target, ArrayBuffer[Int](), result): nothing chosen, all of the target still needed. - Return
result.toListas the last expression. - Write the helper
backtrackwith the array,start,remaining,currentandresult, returningUnit. - Check for a complete combination:
if (remaining == 0). - Save a copy and return.
result += current.toListstores a snapshot. All candidates are at least 1, so adding more would overshoot the target, and the call can end. - Open a
breakableblock and, inside it, aforloop withioverstart until candidates.length. Scala has no built-inbreak;break()jumps to the end of thebreakableblock. - Skip repeats with
if (i == start || candidates(i) != candidates(i - 1)). Scala has nocontinueeither, so the rest of the body goes inside thisif. - Stop early: if
candidates(i) > remaining, callbreak(). The array is sorted, so no later number fits. - Choose the number:
current += candidates(i). - Recurse with
i + 1andremaining - candidates(i). - Undo with
current.remove(current.length - 1).
The solution in Scala
class Solution {
def combinationSum2(candidates: Array[Int], target: Int): List[List[Int]] = {
scala.util.Sorting.quickSort(candidates)
val result = ArrayBuffer[List[Int]]()
backtrack(candidates, 0, target, ArrayBuffer[Int](), result)
result.toList
}
def backtrack(candidates: Array[Int], start: Int, remaining: Int, current: ArrayBuffer[Int], result: ArrayBuffer[List[Int]]): Unit = {
if (remaining == 0) {
result += current.toList
return
}
breakable {
for (i <- start until candidates.length) {
if (i == start || candidates(i) != candidates(i - 1)) {
if (candidates(i) > remaining) {
break()
}
current += candidates(i)
backtrack(candidates, i + 1, remaining - candidates(i), current, result)
current.remove(current.length - 1)
}
}
}
}
}
Scala details worth a look:
breakableandbreak()come fromscala.util.control.Breaks. They are ordinary library code:break()throws a control exception that the enclosingbreakablecatches. In a file of your own, addimport scala.util.control.Breaks.*.break()leaves the loop, not the call. After thebreakableblock ends, the method returns normally to its caller, which then removes its own number and tries the next one.current.toListis a snapshot. The buffer keeps changing during the search, and the list is a copy of its numbers at that moment.remove(index)takes an index, not a value, so removing the last number usescurrent.length - 1.- The
returninsideif (remaining == 0)is a plain return from the method. It is outside thebreakableblock, so it does not interfere withbreak(). quickSortsorts the caller's array in place, as in the lesson.
Complexity
Time: O(n * 2^n) in the worst case, where n is the number of candidates: at most 2^n ways to pick some of them, and a copy takes up to n steps. In practice it is far smaller, because the search only extends combinations that are distinct and whose sum is at most target, and it stops at the first candidate that is too big.
Space: O(n) beyond the answer: the combination being built and one call per number in it.
Edge cases and common mistakes
- Recursing with
iinstead ofi + 1. That allows reusing a number. For[2,3]with target 4 it returnsList(List(2, 2))instead of an empty list. - No duplicate skip. For
[1,1,7]with target 8 the result isList(List(1, 7), List(1, 7)): the same combination twice. The correct result isList(List(1, 7)). - Skipping with
i == 0instead ofi == start. Then a repeated number is skipped even when it is the first choice of its call. For[1,1,6]with target 8 the result is empty, because[1,1,6]needs both 1s. - No combination.
[2,3]with target 4 gives an empty list. - A candidate equal to the target.
[3]with target 3 givesList(List(3)). - Not sorting. The early
break()and the neighbour comparison both rely on sorted input.
Testing
[10,1,2,7,6,1,5],target = 8givesList(List(1, 1, 6), List(1, 2, 5), List(1, 7), List(2, 6)).[2,5,2,1,2],target = 5givesList(List(1, 2, 2), List(5)).[2,3],target = 4givesList().[1,1,7],target = 8givesList(List(1, 7)).[3],target = 3givesList(List(3)).
