Problem overview
The method combinationSum gets an Array[Int] of distinct numbers, candidates, and a target. It returns every combination of candidates that adds up to target, as a List[List[Int]]. A number can be used any number of times. Two combinations are the same when they use the same numbers the same number of times, so [2, 2, 3] and [3, 2, 2] count once. Each combination is written in ascending order, and the combinations are listed in lexicographic order: compare number by number, and the first difference decides.
For candidates = [2, 3, 6, 7] and target = 7, the answer is [[2, 2, 3], [7]]. For [5, 2, 3] and target 8 it is [[2, 2, 2, 2], [2, 3, 3], [3, 5]], even though the input isn't sorted. For [2] and target 1 it is empty.
Approach: build one number at a time
There is no formula for this: the answer has to be searched for. Backtracking does it by building a combination one number at a time. At each step, pick a candidate, subtract it from what is still needed, and ask the same question for the smaller amount. When the amount reaches exactly 0, the numbers chosen form a valid combination. Then undo the last choice and try the next candidate.
Two details keep the search small and the output correct:
- Only move forward in the sorted array. If a call is allowed to pick any candidate at any depth, it finds
[2, 2, 3],[2, 3, 2]and[3, 2, 2], which are one combination. Sortingcandidatesfirst and letting each call start at the index of the last number chosen, not at 0, makes every combination appear once, in ascending order. Starting at that same index, not the next one, lets a number repeat. - Stop early. With the candidates sorted, once
candidates(j)is bigger than what remains, every later candidate is bigger too, so the loop can stop.
Because the first number varies slowest, the combinations come out already in lexicographic order, with no sorting of the result.
The cost is exponential. A combination has at most t / m numbers, where t is the target and m the smallest candidate, and each is one of n candidates, so the time is O(n^(t/m)) in the worst case. Pruning with the sorted order cuts much of that in practice.
Step by step
combinationSum
- Sort the candidates in place with
scala.util.Sorting.quickSort(candidates). This is what gives the ascending combinations, the lexicographic order of the result and the early stop. - Create
result, an emptyArrayBuffer[List[Int]], to collect the combinations. Each combination is a list, so the buffer holds lists. - Start the search with all of
targetstill needed, index 0 as the first usable candidate, an emptyArrayBuffer[Int]as the combination so far, andresult. - Return
result.toList, the buffer converted to theListthe method promises.
backtrack
- Declare the helper with the candidates,
remaining(what the combination still needs),i(the first index it may use),current(the combination built so far) andresult. It returnsUnit: it reports its findings by adding toresult. - Check for success: if
remaining == 0, the numbers incurrentadd up to the target. - Save a snapshot: add
current.toListtoresult, thenreturn. The copy matters becausecurrentkeeps changing as the search goes on. Returning is safe because every candidate is at least 2, so adding more could only overshoot. - Open a loop for
jini until candidates.length. Scala has nobreakstatement, so the loop goes inside abreakableblock, andbreak()jumps to its end. - Stop when a candidate is too big: if
candidates(j) > remaining, callbreak(). - Choose it: add
candidates(j)tocurrentwith+=. - Recurse with
remaining - candidates(j)and the same indexj, notj + 1, so the number may be used again. - Undo the choice: remove the last element of
currentwithremove(current.length - 1), so the loop can try the next candidate from the same state.
The solution in Scala
class Solution {
def combinationSum(candidates: Array[Int], target: Int): List[List[Int]] = {
scala.util.Sorting.quickSort(candidates)
val result = ArrayBuffer[List[Int]]()
backtrack(candidates, target, 0, ArrayBuffer[Int](), result)
result.toList
}
def backtrack(candidates: Array[Int], remaining: Int, i: Int, current: ArrayBuffer[Int], result: ArrayBuffer[List[Int]]): Unit = {
if (remaining == 0) {
result += current.toList
return
}
breakable {
for (j <- i until candidates.length) {
if (candidates(j) > remaining) {
break()
}
current += candidates(j)
backtrack(candidates, remaining - candidates(j), j, current, result)
current.remove(current.length - 1)
}
}
}
}
The lesson's editor already has the imports this code needs. In a file of your own, add import scala.collection.mutable.ArrayBuffer and import scala.util.control.Breaks.* at the top.
The Scala details worth noticing:
quickSortsorts the array in place. The caller's array changes: after the call,Array(5, 2, 3)holds[2, 3, 5]. It returnsUnit, so there is no sorted copy to assign.toListmakes a copy.result += current.toListstores an immutable snapshot. Addingcurrentitself would store the same buffer many times, and every stored entry would change with it. Aftercurrent += 3on a buffer holding 1 and 2, the buffer showsArrayBuffer(1, 2, 3)while a snapshot taken earlier staysList(1, 2).remove(n)takes an index. On anArrayBuffer[Int],remove(current.length - 1)deletes the last element, whatever its value. ForArrayBuffer(2, 2, 3)it leavesArrayBuffer(2, 2). For a buffer of numbers,-=would instead remove the first element equal to its argument.breakableandbreak(). They come fromscala.util.control.Breaks.break()works by throwing a control exception that the enclosingbreakablecatches, so thebreakablemust surround the loop, not sit inside it.i until candidates.lengthis a range that excludes its end, sojruns over valid indexes only.- The last expression is the result.
result.toListneeds noreturn.
Complexity
Time: O(n^(t/m)) in the worst case, with n candidates, target t and smallest candidate m. The recursion is at most t / m levels deep, and each level can choose among n candidates. Stopping at the first candidate that is too big avoids many branches, and the real work also depends on how many combinations the answer holds, because each one is copied into the result.
Space: O(t / m) beyond the answer: current holds at most t / m numbers, and the call stack has one frame per number chosen.
Edge cases and common mistakes
- No combination.
[2]with target 1 returns an empty list: the first candidate is already too big, so the loop breaks at once.[3, 5]with target 7 also returns an empty list, because 3, 5, 6, 8 and 9 skip over 7. - Repeated numbers.
[2, 3]with target 6 returns[[2, 2, 2], [3, 3]]. Passingj + 1instead ofjwould forbid repeats and return an empty list. - Unsorted input.
[5, 2, 3]must be sorted first. Without sorting, the earlybreak()would stop too soon, and the result would not come out in order. - Starting every call at 0. This finds the same combination in every order and fills the answer with duplicates.
- Storing
currentinstead of a copy. All entries inresultwould alias one buffer. By the end it is empty, so every entry would be empty too. - Forgetting the undo. Without
remove, the numbers from one branch stay incurrentand corrupt the next. - Using
-=to undo. It removes the first matching value, which for a repeated number is not the one just added.
Testing
[2, 3, 6, 7], target 7 givesList(List(2, 2, 3), List(7)).[5, 2, 3], target 8 givesList(List(2, 2, 2, 2), List(2, 3, 3), List(3, 5)).[2], target 1 givesList().[3, 5], target 7 givesList().[2, 3], target 6 givesList(List(2, 2, 2), List(3, 3)).[7, 3, 2], target 1 givesList().
