221 lines
9.6 KiB
YAML
221 lines
9.6 KiB
YAML
title: Subsets
|
|
slug: subsets
|
|
difficulty: medium
|
|
leetcode_id: 78
|
|
leetcode_url: https://leetcode.com/problems/subsets/
|
|
categories:
|
|
- arrays
|
|
- recursion
|
|
patterns:
|
|
- backtracking
|
|
|
|
function_signature: "def subsets(nums: list[int]) -> list[list[int]]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { nums: [1, 2, 3] }
|
|
expected: [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]
|
|
- input: { nums: [0] }
|
|
expected: [[], [0]]
|
|
- input: { nums: [1, 2] }
|
|
expected: [[], [1], [2], [1, 2]]
|
|
hidden:
|
|
- input: { nums: [] }
|
|
expected: [[]]
|
|
- input: { nums: [5, 10, 15] }
|
|
expected: [[], [5], [10], [5, 10], [15], [5, 15], [10, 15], [5, 10, 15]]
|
|
- input: { nums: [-1, 0, 1] }
|
|
expected: [[], [-1], [0], [-1, 0], [1], [-1, 1], [0, 1], [-1, 0, 1]]
|
|
- input: { nums: [1, 2, 3, 4] }
|
|
expected: [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3], [4], [1, 4], [2, 4], [1, 2, 4], [3, 4], [1, 3, 4], [2, 3, 4], [1, 2, 3, 4]]
|
|
- input: { nums: [9] }
|
|
expected: [[], [9]]
|
|
|
|
description: |
|
|
Given an integer array `nums` of **unique** elements, return *all possible subsets* (the power set).
|
|
|
|
The solution set **must not** contain duplicate subsets. Return the solution in **any order**.
|
|
|
|
constraints: |
|
|
- `1 <= nums.length <= 10`
|
|
- `-10 <= nums[i] <= 10`
|
|
- All the numbers of `nums` are **unique**
|
|
|
|
examples:
|
|
- input: "nums = [1,2,3]"
|
|
output: "[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]"
|
|
explanation: "The power set contains all 2^3 = 8 subsets, from the empty set to the full set."
|
|
- input: "nums = [0]"
|
|
output: "[[],[0]]"
|
|
explanation: "With a single element, we have two subsets: the empty set and the set containing just that element."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Think of building subsets as a series of **binary decisions**. For each element in the array, you have exactly two choices: either **include it** in the current subset or **exclude it**.
|
|
|
|
Imagine you're packing a bag and laying out items on a table. For each item, you decide "yes, take it" or "no, leave it." If you have 3 items, you make 3 independent yes/no decisions, giving you 2^3 = 8 possible combinations — from taking nothing (empty bag) to taking everything.
|
|
|
|
This decision tree structure naturally maps to **backtracking**: start with an empty subset, and at each step, branch into two paths — one where you add the current element, one where you don't. When you've made decisions for all elements, you've formed one complete subset.
|
|
|
|
The key insight is that every subset corresponds to a unique path through this decision tree. By exploring all paths systematically, we generate the complete power set.
|
|
|
|
approach: |
|
|
We solve this using **Backtracking** to explore all include/exclude decisions:
|
|
|
|
**Step 1: Initialise the result and define the recursive function**
|
|
|
|
- `result`: An empty list that will collect all subsets
|
|
- `backtrack(index, current)`: A recursive function where `index` is the current position in `nums` and `current` is the subset being built
|
|
|
|
|
|
|
|
**Step 2: Define the base case**
|
|
|
|
- When `index` equals `len(nums)`, we've made decisions for all elements
|
|
- Add a copy of `current` to `result` (copy is important since we'll modify `current` later)
|
|
|
|
|
|
|
|
**Step 3: Explore both choices at each step**
|
|
|
|
- **Include the element**: Add `nums[index]` to `current`, recurse with `index + 1`, then remove the element (backtrack)
|
|
- **Exclude the element**: Simply recurse with `index + 1` without adding anything
|
|
|
|
|
|
|
|
**Step 4: Start the recursion**
|
|
|
|
- Call `backtrack(0, [])` to begin from the first element with an empty subset
|
|
- Return `result` containing all 2^n subsets
|
|
|
|
|
|
|
|
This systematic exploration guarantees we visit every possible combination exactly once.
|
|
|
|
common_pitfalls:
|
|
- title: Forgetting to Copy the Subset
|
|
description: |
|
|
When adding `current` to the result, you must add a **copy**, not a reference:
|
|
|
|
```python
|
|
result.append(current[:]) # Correct: adds a copy
|
|
result.append(current) # Wrong: adds a reference
|
|
```
|
|
|
|
If you add the reference, all entries in `result` will point to the same list, which gets modified during backtracking. You'll end up with a result full of empty lists or unexpected values.
|
|
wrong_approach: "Appending the list directly without copying"
|
|
correct_approach: "Use `current[:]` or `list(current)` to create a copy"
|
|
|
|
- title: Not Backtracking After Recursion
|
|
description: |
|
|
After recursing with an element included, you must **remove it** before exploring the "exclude" path:
|
|
|
|
```python
|
|
current.append(nums[index])
|
|
backtrack(index + 1, current)
|
|
current.pop() # Essential: undo the choice
|
|
backtrack(index + 1, current)
|
|
```
|
|
|
|
Without `current.pop()`, the element remains in `current` for subsequent branches, corrupting all future subsets.
|
|
wrong_approach: "Forgetting to pop after the recursive call"
|
|
correct_approach: "Always undo modifications after recursing"
|
|
|
|
- title: Generating Duplicates
|
|
description: |
|
|
If you accidentally allow revisiting earlier elements, you'll generate duplicates:
|
|
|
|
```python
|
|
# Wrong: starts from 0 each time
|
|
for i in range(len(nums)):
|
|
backtrack(i + 1, current + [nums[i]])
|
|
|
|
# Correct: only consider elements after current index
|
|
backtrack(index + 1, current)
|
|
```
|
|
|
|
The key is to always move **forward** in the array. Once you've decided about `nums[i]`, never reconsider it.
|
|
wrong_approach: "Allowing the recursion to revisit earlier indices"
|
|
correct_approach: "Always pass index + 1 to move forward only"
|
|
|
|
key_takeaways:
|
|
- "**Backtracking pattern**: For problems asking for 'all combinations' or 'all subsets', think of a decision tree where each node branches based on include/exclude choices"
|
|
- "**Power set property**: An array of `n` unique elements has exactly `2^n` subsets, since each element is independently included or excluded"
|
|
- "**Foundation for harder problems**: This same backtracking template extends to Subsets II (with duplicates), Combinations, Permutations, and many constraint-satisfaction problems"
|
|
- "**Bit manipulation alternative**: Each subset maps to a binary number from `0` to `2^n - 1`, where bit `i` indicates whether `nums[i]` is included"
|
|
|
|
time_complexity: "O(n * 2^n). We generate 2^n subsets, and copying each subset takes O(n) time in the worst case."
|
|
space_complexity: "O(n). The recursion depth is at most `n`, and the `current` list holds at most `n` elements. Note: the output itself requires O(n * 2^n) space, but that's not counted as auxiliary space."
|
|
|
|
solutions:
|
|
- approach_name: Backtracking
|
|
is_optimal: true
|
|
code: |
|
|
def subsets(nums: list[int]) -> list[list[int]]:
|
|
result = []
|
|
|
|
def backtrack(index: int, current: list[int]) -> None:
|
|
# Base case: made decisions for all elements
|
|
if index == len(nums):
|
|
result.append(current[:]) # Add a copy of current subset
|
|
return
|
|
|
|
# Choice 1: Include nums[index]
|
|
current.append(nums[index])
|
|
backtrack(index + 1, current)
|
|
current.pop() # Backtrack: undo the choice
|
|
|
|
# Choice 2: Exclude nums[index]
|
|
backtrack(index + 1, current)
|
|
|
|
backtrack(0, [])
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) — We generate 2^n subsets, each requiring O(n) to copy.
|
|
|
|
**Space Complexity:** O(n) — Recursion depth and current subset storage.
|
|
|
|
This approach explicitly models the include/exclude decision tree. At each index, we branch into two recursive calls: one with the element added, one without. The backtracking (pop) ensures we can reuse the same list across branches.
|
|
|
|
- approach_name: Iterative (Cascading)
|
|
is_optimal: true
|
|
code: |
|
|
def subsets(nums: list[int]) -> list[list[int]]:
|
|
result = [[]] # Start with empty subset
|
|
|
|
for num in nums:
|
|
# For each existing subset, create a new one with num added
|
|
result += [subset + [num] for subset in result]
|
|
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) — Same as backtracking.
|
|
|
|
**Space Complexity:** O(1) auxiliary — We only use the output list (no recursion stack).
|
|
|
|
This iterative approach builds subsets incrementally. Starting with `[[]]`, for each new element, we take every existing subset and create a copy with the new element added. After processing all elements, we have all 2^n subsets.
|
|
|
|
- approach_name: Bit Manipulation
|
|
is_optimal: true
|
|
code: |
|
|
def subsets(nums: list[int]) -> list[list[int]]:
|
|
n = len(nums)
|
|
result = []
|
|
|
|
# Each number from 0 to 2^n - 1 represents a unique subset
|
|
for mask in range(1 << n): # 1 << n equals 2^n
|
|
subset = []
|
|
for i in range(n):
|
|
# Check if bit i is set in mask
|
|
if mask & (1 << i):
|
|
subset.append(nums[i])
|
|
result.append(subset)
|
|
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(n * 2^n) — We iterate through 2^n masks, checking n bits each.
|
|
|
|
**Space Complexity:** O(1) auxiliary — No recursion, just loop variables.
|
|
|
|
This approach leverages the bijection between subsets and binary numbers. For an array of size n, integers from 0 to 2^n - 1 enumerate all possible subsets. If bit `i` is set in the integer, include `nums[i]` in the subset. This is elegant and avoids recursion entirely.
|