“Know how to solve every problem that has been solved.” “What I cannot create, I do not understand.” — Richard Feynman

Subsets (Backtracking)

Interview Prep

StandardBacktrackingbacktrackingrecursion

The problem

Given an array of distinct integers, return all 2n possible subsets, in any order.

Pattern: backtracking template

Subsets is the canonical backtracking warm-up. The decision at each step is "should this element be included in the current subset?". The skeleton — append to path, recurse, pop — is the same skeleton you'll use for permutations, combinations, combination sum, n-queens, sudoku, word search, and dozens more. Get this one into your fingers.

Every node in the recursion tree is a valid subset (we append(path.copy()) on entry, not at leaves). This is what makes Subsets different from Permutations or Combination Sum, where we only emit at the leaf.

Backtracking solution

def subsets(nums: list[int]) -> list[list[int]]:
    out, path = [], []
    def back(i: int):
        out.append(path.copy())          # every prefix-of-decisions is itself a valid subset
        for j in range(i, len(nums)):
            path.append(nums[j])
            back(j + 1)
            path.pop()                   # undo for the next branch
    back(0)
    return out

Trace

nums = [1, 2, 3]

back(0, path=[]):    out += [[]]
  j=0: path=[1], back(1):    out += [[1]]
    j=1: path=[1,2], back(2):    out += [[1,2]]
      j=2: path=[1,2,3], back(3):    out += [[1,2,3]]. no children.
      pop -> [1,2]
    pop -> [1]
    j=2: path=[1,3], back(3):    out += [[1,3]]. no children.
    pop -> [1]
  pop -> []
  j=1: path=[2], back(2):    out += [[2]]
    j=2: path=[2,3], back(3):    out += [[2,3]]. no children.
    pop -> [2]
  pop -> []
  j=2: path=[3], back(3):    out += [[3]]. no children.
  pop -> []

result: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

Alternative: bitmask enumeration

For small n (≤ ~20), every subset corresponds to an n-bit mask. Iterating 0..2^n − 1 and selecting the elements whose bits are set gives the same answer in one pass. Often the cleanest code; doesn't generalize to "decisions with more than two choices".

def subsets_bitmask(nums):
    """For small n (~20), iterate all 2^n bitmasks."""
    n = len(nums)
    return [
        [nums[i] for i in range(n) if mask & (1 << i)]
        for mask in range(1 << n)
    ]

Alternative: iterative doubling

The subsets of nums + [x] are exactly the subsets of nums, plus each of those with x appended. Start with [[]] and grow.

def subsets_iterative(nums):
    """Build by doubling: subsets(nums + [x]) = subsets(nums) ∪ {s+[x] for s in subsets(nums)}."""
    out = [[]]
    for x in nums:
        out += [s + [x] for s in out]
    return out

Complexity

Try First

Each prompt asks a checkable question about the working code or math above — predict an output, derive a sign, state an invariant, find a bug. Commit to an answer before clicking "reveal." That commitment is the whole point: if your answer matched, you understand the piece you were looking at; if it didn't, that's the part worth re-reading.

why does this work
The prose above states that every node in the recursion tree is a valid subset, because append(path.copy()) runs on entry rather than at leaves. Convince yourself this is correct. Then consider the natural-looking alternative: append only when there are no more children (the "leaf" version). Trace it on nums = [1, 2, 3]. What set of "subsets" do you get?
predict
Predict what happens if you remove .copy() from the line out.append(path.copy()), leaving just out.append(path). Run it mentally on [1, 2]. What's in out when the function returns?
invariant
Pause the backtracking function at the top of the for-loop, just before path.append(nums[j]). State precisely what path contains. Tie your answer to the recursion depth and the i parameter.
equivalence
Each subset of nums = [a, b, c] corresponds to a 3-bit mask: 000 → empty, 001 → [a], 010 → [b], ..., 111 → [a, b, c]. Look at the backtracking output above: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]. Write down the bitmask for each one. Is the backtracking order the same as numerical bitmask order (000, 001, 010, ...)? If not, what order IS it?
find the bug
I removed path.pop() from the backtracking function. The code still runs. What does it return for nums = [1, 2]? Trace by hand before answering.

Hack This

The code above works. Don't reinvent it — pull it into an editor, run it, and try the modifications below. Each one is small. Each one will change the behavior in a specific way; the question is which way.

trivial
Move out.append(path.copy()) from the top of the back function to a new branch that fires only when i == len(nums) (so you emit at leaves, not on entry). What does the function now return? Compare to the original output — which subsets are missing, and why?
small
Convert the backtracking solution to generate PERMUTATIONS instead of subsets. The structural changes are: (a) replace range(i, len(nums)) with a loop over UNUSED elements (you'll need a used set or boolean array); (b) move the emit from on-entry to at-leaf (when len(path) == len(nums)). Why does swapping (a) and (b) turn 2n outputs into n! outputs? Trace the recursion tree's shape change.
small
Generate combinations C(n, k) — all k-element subsets — by adding a single condition to the backtracking template. Then ask: why does this give O(C(n, k)) total work, not O(2n)? Walk the recursion tree and identify which branches get pruned by the modification.
small
Modify the bitmask enumeration to output subsets in Gray code order, where consecutive subsets differ by exactly one element added or removed. The Gray code for mask i is i ^ (i >> 1). Apply this transformation and verify that consecutive outputs differ by one element. Then: where would this property actually be useful?
small
Convert the backtracking solution into a Python generator that yields one subset at a time, instead of materializing the full out list. What changes structurally? Why is this useful when 2n is enormous but you only need the first few subsets that satisfy some external condition?
medium
Implement rank(subset) — given a subset, return its position in the bitmask enumeration order — and unrank(k) — given a position, return the subset. Verify unrank(rank(s)) == s for all subsets of [1, 2, 3, 4]. Then: how would you do the same thing for the BACKTRACKING order? Why is that meaningfully harder?

Variations worth knowing