DSA Subsets
A subset is a selection of elements from a set where each element is either in or out. Backtracking over one include-or-skip choice per element builds every subset.
The problem
Subsets asks for every selection of a list of distinct values. A selection may take any of the values, in their original order. It may also take none of them.
The collection of all subsets of a set is called its power set. This article lists the results in include-first order. The full list prints first, and the empty subset prints last.
The cases used in this article
cases = [
([5], [[5], []]),
([1, 2], [[1, 2], [1], [2], []]),
([1, 2, 3], [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]),
([], [[]]),
]
for nums, expected in cases:
print("nums", nums, "subsets:", expected)Read the counts first. One value gives two subsets. Two values give four, and three values give eight. Even an empty list gives one subset, the empty one.
Intuition: each value is a yes-or-no choice
Each value faces one choice. The value joins the subset, or it stays out.
The choices multiply across values. Two values with two choices each give four subsets. A third value doubles the count again, to eight.
The choices for [1, 2], listed in full
nums = [1, 2]
built = []
for take_first in (True, False):
for take_second in (True, False):
subset = []
if take_first:
subset.append(nums[0])
if take_second:
subset.append(nums[1])
built.append(subset)
print("1:", "in" if take_first else "out", "- 2:", "in" if take_second else "out", "->", subset)
print("subsets:", len(built))Stopping at familiar sizes is the trap in this problem. Pairs and triples are only part of the answer. The empty subset and the full list are subsets too, and a size-by-size search must still cover every size from zero to n.
Brute force: read the choices off a counter
A bit is one binary digit, 0 or 1. The brute force turns each count from 0 up to 2^n minus 1 into a set of choices. The bits of the count say which values are in, with a set bit meaning in and a clear bit meaning out.
The test uses a shift. Shifting 1 left by i places makes a number with only bit i set. The test mask & (1 << i) keeps just that bit of the count. It passes exactly when value i belongs to the subset.
The mask method, with its bit tests counted
def subsets_brute(nums):
results = []
masks = 0
bit_tests = 0
for mask in range(2 ** len(nums)):
masks += 1
subset = []
for i in range(len(nums)):
bit_tests += 1
if mask & (1 << i):
subset.append(nums[i])
results.append(subset)
return results, masks, bit_tests
result, masks, bit_tests = subsets_brute([1, 2, 3])
for row in result:
print(row)
print("masks read:", masks)
print("bit tests:", bit_tests)The mask method works, but it rebuilds every subset from an empty list. It also scans all n bits of every mask, even the bits that stay clear. The bit test count shows that repeated scanning plainly.
The optimal solution: one shared path, one choice per value
The state is one shared path plus the index of the value now facing its choice, and the search appends the value, explores, then removes it before exploring the skip branch. That one sentence is the whole method.
A copy of the path is stored only when the index runs off the end of the list. At that moment the path holds one complete subset. Removing the value after the include branch is the backtrack step, and it leaves the path clean for the skip branch.
The backtracking solution on every case
def subsets(nums):
results = []
path = []
def search(index):
if index == len(nums):
results.append(list(path))
return
path.append(nums[index])
search(index + 1)
path.pop()
search(index + 1)
search(0)
return results
cases = [
([5], [[5], []]),
([1, 2], [[1, 2], [1], [2], []]),
([1, 2, 3], [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]),
([], [[]]),
]
for nums, expected in cases:
result = subsets(nums)
print("nums", nums, "->", result, "match:", result == expected)Every case matches. The search holds one partial subset at a time, instead of rebuilding each subset from a count. Each value is appended once and removed once along the way.
Walkthrough: [1, 2], one choice at a time
The walk follows the path through both choices for both values. A take line adds a value. A drop line removes it again, which is the backtrack step made visible.
The path after each decision
nums = [1, 2]
path = []
results = []
def search(index):
if index == len(nums):
print("end of list - record", list(path))
results.append(list(path))
return
path.append(nums[index])
print("take", nums[index], "- path:", list(path))
search(index + 1)
path.pop()
print("drop", nums[index], "- path:", list(path))
search(index + 1)
search(0)
print("results:", results)Read the records in order. They are [1, 2], [1], [2] and the empty subset. Each drop line undoes one choice so the other branch can run with a clean path.
Complexity
Time is O(n times 2^n) for n values. The answer itself is that large, because the values across all subsets total n times 2^(n-1), so no method that lists every subset can grow more slowly.
Extra space is O(n) besides the stored results. The shared path never holds more than one value per list position.
Counting subsets and their total size
for n in [3, 4, 5]:
print("values", n, "- subsets:", 2 ** n, "- values across all subsets:", n * 2 ** (n - 1))Each added value doubles the subset count. The total size more than doubles, because the new value also joins half of the subsets that already existed.
Edge cases and pitfalls
The empty list has exactly one subset. It is the empty subset, reached by making no choices at all. A single value has two subsets, itself and the empty subset.
The method assumes distinct values. A repeated value faces its own choice like any other value, so repeated input produces repeated subsets. The last block shows that failure directly.
Edge cases, run
def subsets(nums):
results = []
path = []
def search(index):
if index == len(nums):
results.append(list(path))
return
path.append(nums[index])
search(index + 1)
path.pop()
search(index + 1)
search(0)
return results
print("empty list:", subsets([]))
print("one value:", subsets([9]))
print("four values, count:", len(subsets([1, 2, 3, 4])))
print("repeated value:", subsets([2, 2]))The repeated row prints [2] twice, once per copy of the value. Handling repeated input needs a skip rule for equal neighbours, like the one the Permutations lesson uses for repeated values.
Exercise
A set has 5 elements. How many subsets does its power set hold?