“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
Time:O(n · 2^n) — there are 2^n subsets, each up to length n to write out.
Space:O(n · 2^n) output. Recursion stack O(n).
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?
answer
With leaf-only emit (append when i == len(nums) instead
of on entry), every emitted "subset" has length exactly
len(nums) — because that's the only way to bottom out.
For [1, 2, 3] you'd get
[[1, 2, 3], [1, 3], [2, 3], [3], [1, 2], [1], [2], []]?
No wait — only paths that reach the bottom-level call get emitted,
and "bottom" requires i == 3. So you'd get exactly the
empty set (the one path that takes no elements and reaches i=3) plus
any maximal path that walks through all elements. Most subsets get
skipped because their corresponding path bails out partway.
The real lesson: append-on-entry is what makes subsets DIFFERENT
from permutations and combinations. In Permutations, every
permutation has the same length, so emitting at leaves makes sense.
In Subsets, the answer set contains subsets of all lengths
from 0 to n — each one is a partial path in the
recursion tree. Emitting on entry is the structural choice that
captures every partial path.
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?
answer
Every element of out would be the SAME list — Python
appends a reference, not a copy. After the recursion finishes, that
single shared list is [] (because all append/
pop operations are eventually balanced). So
out ends up as [[], [], [], []] — four
empty lists, all aliasing each other.
The .copy() isn't decoration. It's the line that
snapshots the path at the moment of emission, preserving the value
against future mutation. This is a universal gotcha in any
backtracking template where you mutate a single accumulator. The
equivalent fix in some languages: use a persistent / immutable
data structure instead of a mutable list, and the bug becomes
unrepresentable.
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.
answer
At the top of the for-loop inside back(i):
path is the list of elements chosen along the current
DFS path from the root to this call — equivalently, one element
from each "include this index?" decision made at strictly smaller
i values, taken with strict index-ordering. The
length of path equals the recursion depth at this
moment. The constraint j >= i in the for-loop is
what enforces "strict index-ordering" — it's why each subset is
generated exactly once instead of k! times for a
subset of size k.
That last point is worth pausing on. If you wrote
for j in range(len(nums)) instead, you'd generate
every PERMUTATION of every subset — n! / (n-k)! orderings
each — and you'd need to dedup at the end. The j >= i
constraint replaces dedup with non-generation. Always cheaper.
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?
answer
The backtracking output corresponds to the masks (with bit 0 = a):
000, 001, 011, 111, 101, 010, 110, 100. Compare to
numerical order: 000, 001, 010, 011, 100, 101, 110, 111
— different. The backtracking order is DFS order on the implicit
binary tree of decisions, which differs from the numerical
increment order.
Both are valid enumerations of all 2n subsets — they
just visit them in different orders. The bitmask enumeration is
more cache-friendly (sequential access pattern); the backtracking
is more flexible (you can prune branches without enumerating
their descendants). For Subsets specifically the two orders are
interchangeable. For Combination Sum or N-Queens, pruning matters
and backtracking wins.
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.
Without the pop, path accumulates monotonically — every
new branch is built on top of the previous one rather than backtracking
to a sibling. The "back" in "backtracking" is exactly the pop. Take
it out and the algorithm becomes "forwardtracking," which doesn't
enumerate the right thing.
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?
hint
Each leaf in the recursion tree corresponds to a maximal path that
walks through all the index decisions. Many subsets — most, in fact
— are partial paths that emit on entry but never reach a leaf.
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.
hint
Subsets: at each node, the choice is "include this index or skip
and try the next." Branching factor diminishes with depth (only
forward indices). Permutations: at each node, the choice is "pick
any of the still-unused elements." Branching factor diminishes
also, but starting at n, then n-1, then
n-2... — the product is n!.
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.
hint
Add an early return when len(path) == k (and emit
there, not at the root). Branches that would extend path beyond
length k never get explored — they're pruned at
depth k rather than depth n, which
saves the exponential difference.
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?
hint
Replace mask in range(1 << n) with
(i ^ (i >> 1)) for i in range(1 << n)
in the comprehension. Gray code matters when computing a function
on every subset is expensive but updating it incrementally (one
element added or removed) is cheap — e.g., subset-sum queries,
determinant updates in numerical linear algebra, certain physics
simulations over spin configurations.
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?
hint
Replace out.append(path.copy()) with
yield path.copy(); remove the out
variable and the final return. The recursion calls become
yield from back(j + 1). Memory drops from
O(n · 2n) to O(n) (just the path and the recursion
stack). Lets you do next(it) a million times on a
50-element input without materializing 250 lists.
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?
hint
Bitmask rank/unrank is trivial because the position IS the mask
(interpret subset as binary). Backtracking order is DFS order on
a different tree — to rank in DFS order you'd need to count
subtree sizes as you descend, which is a separate combinatorial
calculation. This is the same gap that distinguishes "lex rank"
from "co-lex rank" in combinatorics.
Variations worth knowing
Subsets II (duplicates): sort the input and
inside the for-loop skip nums[j] == nums[j − 1] on
sibling branches (but allow descending into them when
j == i). Suppresses duplicate subsets.
Permutations: swap "for j in i..n" for "for each
unused element". Emit only at leaves.
Combinations C(n, k): add an early return when
len(path) == k (and emit there, not at the root).
Combination Sum: at each step add an element and
recurse with the same i (reuse allowed). Prune when
remaining target goes negative.
Power-set in lex order: the bitmask enumeration
gives a specific natural order; the backtracking version gives a
DFS order.