Subsets (The Power Set)
Question
Given an integer array of unique elements, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Return the solution in any order.
Solution
Pattern
Backtracking (Decision Tree) The problem follows the "Pick or Don't Pick" pattern. For every element in the array, we have a binary choice: either include it in the current subset or exclude it.
How to Identify
- The problem asks for "All Possible" configurations (subsets, permutations, combinations).
- The input size is small (typically n < 20), as the result grows exponentially.
- The problem involves building a result incrementally by making a series of choices.
Description
We use a recursive helper function to explore a decision tree.
- The Choice: At each index in the array, we decide:
- Path A: Include in the current subset and move to .
- Path B: Do not include and move to .
- Base Case: When our current index reaches , it means we have made a choice for every element. We take a "snapshot" of our current subset and add it to our global result list.
- Backtracking: To ensure Path B doesn't contain elements from Path A, we must remove the element we just added before returning from the recursive call. This "cleans up" the state.
The Intuition
Think of the Power Set as a Binary String of length .
- Each bit represents an element in the array.
- A
1means "Included," and a0means "Excluded." - Since there are possible binary strings of length , there are subsets.
This mental model helps you realize that the subsets problem is actually a search through the space of all possible -bit combinations.
Complexity
| Label | Worst | Average |
|---|---|---|
| Time Complexity | ||
| Space Complexity |
Time Complexity
There are subsets. For each subset, we spend time to create a copy of the current list and add it to the result.
Space Complexity
The output requires space. The recursion stack depth is , and the temporary list used for backtracking also takes .
Code
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// Start backtracking from index 0
backtrack(result, new ArrayList<>(), nums, 0);
return result;
}
private void backtrack(List<List<Integer>> result, List<Integer> current, int[] nums, int index) {
// Base Case: We've considered all elements
if (index == nums.length) {
// Must create a new copy because current is modified in place
result.add(new ArrayList<>(current));
return;
}
// Decision 1: Include the current element
current.add(nums[index]);
backtrack(result, current, nums, index + 1);
// Decision 2: Exclude the current element (Backtrack)
current.remove(current.size() - 1);
backtrack(result, current, nums, index + 1);
}
}Concepts to Think About
- Bitmasking: You can generate subsets by iterating from to and using the bit representation of the counter to pick elements.
- Lexicographical Order: The recursive approach naturally generates subsets in a specific order (DFS order).
- Copying Overhead: copying at the base case is unavoidable if you want to return a list of lists.
- Space Optimization: If you only need to process subsets rather than return them, space drops to .
- Gray Code: A specialized sequence of bitstrings where each adjacent string differs by only one bit. This can be used to generate subsets by adding/removing exactly one element at a time.
Logical Follow-up
Question: Subsets II: What if the input array nums contains duplicates? How do you ensure the power set contains no duplicate subsets?
Solution:
- Sort the array: This brings duplicates together.
- Handle Duplicates in Backtracking: When choosing not to include an element, you must skip all subsequent occurrences of that same element.
- Logic:
while (index + 1 < nums.length && nums[index] == nums[index + 1]) index++; - This ensures you don't start a new decision branch with a value you've already rejected for that specific position.
- Logic:
Question: Memory Constraints: If , is over a billion. You cannot return this in a List. How would you handle this in a real system?
Solution: At a Google scale, we would use an Iterator pattern or a Generator. Instead of storing all subsets, we provide a next() method that calculates the next subset on the fly (perhaps using the bitmasking approach). This keeps space at .
Solution (Bit Masking Approach)
Pattern
Bit Manipulation (Binary Mapping) Treat each subset as a binary number of length , where the bit represents whether is included in the subset.
How to Identify
- You need to generate all combinations of a set.
- The total number of results is exactly .
- Each element has a simple binary state: Included () or Excluded ().
- You want to avoid the overhead of a recursive call stack.
Description
We iterate through every possible integer from up to .
- The Loop: For an array of size , there are total subsets. We run a loop from to .
- The Mask: Each value of is a "mask." For example, if , the mask is
101in binary. - Bit Checking: For each mask, we check every bit position (from to ):
- If the bit is set (), we include in the current subset.
- If the bit is not set (), we skip it.
- Efficiency: We use bitwise shifts
1 << nto calculate and(i >> j) & 1to check bits.
The Intuition
Imagine light switches. Each configuration of switches (on/off) represents a unique subset. Since each switch has states, switches have configurations. If we count from to in binary, we are essentially cycling through every possible configuration of those light switches. We are simply "reading" the binary state of the counter to decide what goes into our "suitcase."
Complexity
| Label | Worst | Average |
|---|---|---|
| Time Complexity | ||
| Space Complexity |
Time Complexity
The outer loop runs times. The inner loop runs times to check each bit. Total: .
Space Complexity
Excluding the space for the result list, the auxiliary space is because we only use a few integer variables. There is no recursion stack.
Code
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
int n = nums.length;
// Total subsets = 2^n
int totalSubsets = 1 << n;
for (int i = 0; i < totalSubsets; i++) {
List<Integer> currentSubset = new ArrayList<>();
// Check each bit of the current number 'i'
for (int j = 0; j < n; j++) {
// If the j-th bit is set, include nums[j]
if (((i >> j) & 1) == 1) {
currentSubset.add(nums[j]);
}
}
result.add(currentSubset);
}
return result;
}
}When to AVOID Bitmasking
Even though it's "cleaner," an experienced developer knows when to stick to backtracking:
- If : An
intonly has 31 usable bits. If , you need along. If , bitmasking is impossible. - If there are Duplicates: Handling "Subsets II" (unique subsets from a duplicate array) is very natural in backtracking but requires complex hash-sets or logic in bitmasking.
- If Pruning is needed: If the problem is "Subsets that sum to ," backtracking can stop early if the current sum exceeds . Bitmasking must check all possibilities regardless.
Concepts to Think About
- Power of Two:
1 << nis an extremely fast way to calculate . - Bitwise AND:
(i & (1 << j))is an alternative to(i >> j) & 1for checking if the bit is set. - Limit of int: This approach only works if . If , will overflow a 32-bit
int. You would need along(up to ) orBigInteger. - Lexicographical Order: Bitmasking generates subsets in a different order than DFS-based backtracking. Backtracking is usually "depth-first," while bitmasking is "numerical order" of the masks.
- Cache Locality: Iterative solutions are often more cache-friendly than recursive ones because they don't jump around the memory stack as much.
Logical Follow-up
Question: Constant Space Processing: If you are asked to print all subsets but are forbidden from using more than space total (no result list), how does Bitmasking help?
Solution: Since Bitmasking is iterative, you can generate and print each subset one by one in the inner loop. Once a subset is printed, you can clear the currentSubset list or just print directly from the array. This keeps the total space complexity at for the temporary storage, compared to if you were forced to store them all.
Question: Subset of a Specific Size: How would you modify the bitmask approach to only return subsets of size ?
Solution: Inside the inner loop, you would use Integer.bitCount(i) to check if the current mask has exactly bits set to 1. If it does, you process it; otherwise, you skip it. Note that while this works, it is less efficient than a specialized backtracking approach because you still iterate times.