Problem overview
The method subsets receives an Array[Int] of distinct integers, nums, and returns a List[List[Int]] with every subset: every way to pick some of the numbers, from none (the empty subset) to all of them. Each subset keeps the order the numbers have in nums.
The order of the result is fixed too. The numbers are decided one at a time, skipping before taking: every subset without nums(0) comes before every subset with it, and inside each group the same rule applies to nums(1), then nums(2) and so on.
For nums = [1, 2, 3] the answer is [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]. The first four subsets skip 1 and the last four take it. The last four are the first four with 1 in front.
Approach: decide about one number at a time
A subset is a series of yes/no choices, one per number. With n numbers there are 2^n subsets, so any solution takes at least that long; the work is to produce each one exactly once, in the right order.
Backtracking does this. Walk through nums by index. At index i, first skip nums(i) and move on to index i + 1. Then take it: add it to the subset being built, move on to i + 1, and afterwards remove it again, so the caller finds the subset as it was. When the index reaches the end, every number has been skipped or taken, and the subset is complete.
Trying skip before take gives exactly the required order. It is the same as counting in binary: a subset is a string of n bits, 0 for skip and 1 for take, and the subsets come out in the order of those strings.
For nums = [1, 2] the calls go like this:
i=0 skip
i=1 skip
save List()
i=1 take 2 -> List(2)
save List(2)
i=0 take 1 -> List(1)
i=1 skip
save List(1)
i=1 take 2 -> List(1, 2)
save List(1, 2)
Step by step
- Create
resultas an emptyArrayBuffer[List[Int]](), a list that can grow. Each subset is itself a list, so it holds lists. - Start the search with
backtrack(nums, 0, ArrayBuffer[Int](), result). Nothing is decided yet, so the subset is empty and decisions begin at index 0. - Return
result.toList.backtrackfills the same buffer, andtoListgives theListthe method promises. - Write the helper
backtrackwith the parametersnums, the indexito decide about, the numbers taken so far incurrent, andresult. It returnsUnit. - Check the base case. When
i == nums.length, no number is left to decide about. - Save a snapshot with
result += current.toListand return.currentkeeps changing, so the result needs its own copy. - Skip
nums(i): callbacktrackwithi + 1andcurrentunchanged. This finds every subset without it, and doing it first gives the required order. - Take
nums(i): append it tocurrentwith+=. - Recurse again with
i + 1. This call finds every subset that containsnums(i). - Undo the choice with
current.remove(current.length - 1), socurrentis again what this call received.
The solution in Scala
In a file of your own, the code needs import scala.collection.mutable.ArrayBuffer at the top.
class Solution {
def subsets(nums: Array[Int]): List[List[Int]] = {
val result = ArrayBuffer[List[Int]]()
backtrack(nums, 0, ArrayBuffer[Int](), result)
result.toList
}
def backtrack(nums: Array[Int], i: Int, current: ArrayBuffer[Int], result: ArrayBuffer[List[Int]]): Unit = {
if (i == nums.length) {
result += current.toList
return
}
backtrack(nums, i + 1, current, result)
current += nums(i)
backtrack(nums, i + 1, current, result)
current.remove(current.length - 1)
}
}
The Scala details worth a closer look:
- Why the snapshot matters. An
ArrayBufferis mutable, and every recursive call shares the same one. Addingcurrentitself to the result stores a reference, not a copy. By the time the search ends,currentis empty again, so a result built that way is eight empty buffers for[1, 2, 3].current.toListcreates a new immutableListwith the current numbers, and later changes to the buffer can't touch it. removetakes an index.current.remove(current.length - 1)deletes the element at that position, the last one. It isn't a "remove this value" call: forArrayBuffer(5, 0, 7),remove(0)deletes the 5 at index 0 and leavesArrayBuffer(0, 7), while-= 0deletes the value 0 and leavesArrayBuffer(5, 7).+=on a buffer appends and changes the buffer in place. It is used both forresultand forcurrent.- Parentheses for indexing. An array element is
nums(i), notnums[i]. returnin aUnitmethod. The earlyreturnends the call when the subset is complete. The method has no value to hand back, so it is written without one.- The final line.
result.toListis the last expression ofsubsets, so it is the method's value.
Complexity
Time: O(n · 2^n). There are 2^n subsets, and copying one with toList takes up to n steps. The recursion tree has about 2^(n+1) calls, which is less than the copying cost.
Space: O(n) extra, not counting the answer. The buffer current holds at most n numbers, and the recursion is n + 1 calls deep.
Edge cases and common mistakes
- One number.
[0]gives[[], [0]]: without it, then with it. - The order inside a subset. For
[3, -1]the result is[[], [-1], [3], [3, -1]]. The last subset is[3, -1], in the order ofnums, not sorted. - Saving
currentwithout copying. The result then holds eight empty buffers for[1, 2, 3], as explained above. - Forgetting the undo. Without
remove, the taken number stays incurrentfor every later call, and the subsets that follow contain numbers they shouldn't. - Taking before skipping. Swapping the two calls still finds every subset, but in a different order, and the lesson's tests expect skip first.
- Counting. For ten distinct numbers the method returns 1024 subsets.
Testing
nums = [1, 2, 3]givesList(List(), List(3), List(2), List(2, 3), List(1), List(1, 3), List(1, 2), List(1, 2, 3)).nums = [0]givesList(List(), List(0)).nums = [3, -1]givesList(List(), List(-1), List(3), List(3, -1)).nums = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]gives a list of 1024 subsets.
