Files
codetutor/backend/data/questions/subsets.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
&nbsp;
**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)
&nbsp;
**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
&nbsp;
**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
&nbsp;
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.