205 lines
8.9 KiB
YAML
205 lines
8.9 KiB
YAML
title: Combinations
|
|
slug: combinations
|
|
difficulty: medium
|
|
leetcode_id: 77
|
|
leetcode_url: https://leetcode.com/problems/combinations/
|
|
categories:
|
|
- arrays
|
|
- recursion
|
|
patterns:
|
|
- slug: backtracking
|
|
is_optimal: true
|
|
|
|
function_signature: "def combine(n: int, k: int) -> list[list[int]]:"
|
|
|
|
test_cases:
|
|
visible:
|
|
- input: { n: 4, k: 2 }
|
|
expected: [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
|
|
- input: { n: 1, k: 1 }
|
|
expected: [[1]]
|
|
hidden:
|
|
- input: { n: 5, k: 3 }
|
|
expected: [[1, 2, 3], [1, 2, 4], [1, 2, 5], [1, 3, 4], [1, 3, 5], [1, 4, 5], [2, 3, 4], [2, 3, 5], [2, 4, 5], [3, 4, 5]]
|
|
- input: { n: 3, k: 1 }
|
|
expected: [[1], [2], [3]]
|
|
- input: { n: 2, k: 2 }
|
|
expected: [[1, 2]]
|
|
|
|
description: |
|
|
Given two integers `n` and `k`, return *all possible combinations of* `k` *numbers chosen from the range* `[1, n]`.
|
|
|
|
You may return the answer in **any order**.
|
|
|
|
constraints: |
|
|
- `1 <= n <= 20`
|
|
- `1 <= k <= n`
|
|
|
|
examples:
|
|
- input: "n = 4, k = 2"
|
|
output: "[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]"
|
|
explanation: "There are 4 choose 2 = 6 total combinations. Note that combinations are unordered, i.e., [1,2] and [2,1] are considered to be the same combination."
|
|
- input: "n = 1, k = 1"
|
|
output: "[[1]]"
|
|
explanation: "There is 1 choose 1 = 1 total combination."
|
|
|
|
explanation:
|
|
intuition: |
|
|
Imagine you're picking a team of `k` players from a pool of `n` candidates numbered 1 through `n`. The order you pick them doesn't matter — selecting player 1 then player 3 is the same team as selecting player 3 then player 1.
|
|
|
|
The key insight is that we can **build combinations incrementally** by making a series of choices. At each step, we decide: "Should I include this number in my combination?" If we include it, we move on to consider the next number. If not, we skip it and move to the next.
|
|
|
|
To avoid duplicates like `[1,3]` and `[3,1]`, we enforce a rule: **only consider numbers greater than the last number we picked**. This ensures each combination is built in ascending order, so `[1,3]` is generated but `[3,1]` never is.
|
|
|
|
This naturally leads to a *backtracking* approach: we explore one path (include a number), and if it doesn't lead to a valid combination of size `k`, we "backtrack" by removing that number and trying the next option.
|
|
|
|
approach: |
|
|
We solve this using **Backtracking**:
|
|
|
|
**Step 1: Define the recursive function**
|
|
|
|
- Create a helper function `backtrack(start, current_combination)` that builds combinations
|
|
- `start`: the smallest number we can still pick (ensures ascending order)
|
|
- `current_combination`: the combination being built
|
|
|
|
|
|
|
|
**Step 2: Base case — combination is complete**
|
|
|
|
- If `len(current_combination) == k`, we've found a valid combination
|
|
- Add a **copy** of `current_combination` to results (important: we need a copy because we'll modify it later)
|
|
- Return to explore other paths
|
|
|
|
|
|
|
|
**Step 3: Recursive case — try each remaining number**
|
|
|
|
- Loop through numbers from `start` to `n`
|
|
- For each number `i`:
|
|
- **Choose**: Add `i` to `current_combination`
|
|
- **Explore**: Recursively call `backtrack(i + 1, current_combination)`
|
|
- **Unchoose**: Remove `i` from `current_combination` (backtrack)
|
|
|
|
|
|
|
|
**Step 4: Optimisation — pruning**
|
|
|
|
- If there aren't enough numbers left to complete a combination, stop early
|
|
- We need `k - len(current_combination)` more numbers
|
|
- We have `n - start + 1` numbers remaining
|
|
- If remaining < needed, prune this branch
|
|
|
|
|
|
|
|
**Step 5: Return all combinations**
|
|
|
|
- Call `backtrack(1, [])` to start building from number 1
|
|
- Return the collected results
|
|
|
|
common_pitfalls:
|
|
- title: Generating Duplicate Combinations
|
|
description: |
|
|
Without enforcing order, you might generate both `[1,3]` and `[3,1]`. Since combinations are unordered, these are duplicates.
|
|
|
|
The fix is to only consider numbers **greater than** the last number added. By always building in ascending order, each unique set of numbers appears exactly once.
|
|
wrong_approach: "Starting each recursion from 1"
|
|
correct_approach: "Starting from last_picked + 1"
|
|
|
|
- title: Forgetting to Copy the Combination
|
|
description: |
|
|
A common bug is adding `current_combination` directly to results:
|
|
```python
|
|
result.append(current_combination) # Bug!
|
|
```
|
|
|
|
Since `current_combination` is modified during backtracking, all entries in `result` end up pointing to the same (eventually empty) list.
|
|
|
|
Always append a **copy**: `result.append(current_combination[:])` or `result.append(list(current_combination))`.
|
|
wrong_approach: "result.append(current_combination)"
|
|
correct_approach: "result.append(current_combination[:])"
|
|
|
|
- title: Missing the Pruning Optimisation
|
|
description: |
|
|
Without pruning, the algorithm explores paths that can never lead to valid combinations. For example, if `n = 4`, `k = 3`, and we've picked `[4]`, there's no way to pick 2 more numbers greater than 4.
|
|
|
|
Adding a check at the start of the loop — `if n - i + 1 < k - len(current)` — skips these futile branches and significantly speeds up execution.
|
|
wrong_approach: "Exploring all paths regardless of remaining elements"
|
|
correct_approach: "Pruning when remaining elements < needed elements"
|
|
|
|
key_takeaways:
|
|
- "**Backtracking template**: The choose-explore-unchoose pattern is the foundation for generating all permutations, combinations, and subsets"
|
|
- "**Avoiding duplicates**: Enforcing an ordering (only picking larger numbers) is a common technique to prevent duplicate combinations"
|
|
- "**Pruning**: Early termination when a path cannot lead to a solution dramatically improves performance"
|
|
- "**Copy before storing**: When collecting mutable objects during recursion, always store copies, not references"
|
|
|
|
time_complexity: "O(k * C(n,k)). We generate C(n,k) combinations, and copying each combination of length k takes O(k) time."
|
|
space_complexity: "O(k). The recursion depth is at most k (the size of each combination), and we use O(k) space for the current combination being built. The output space O(k * C(n,k)) is not counted as auxiliary space."
|
|
|
|
solutions:
|
|
- approach_name: Backtracking
|
|
is_optimal: true
|
|
code: |
|
|
def combine(n: int, k: int) -> list[list[int]]:
|
|
result = []
|
|
|
|
def backtrack(start: int, current: list[int]) -> None:
|
|
# Base case: combination is complete
|
|
if len(current) == k:
|
|
result.append(current[:]) # Append a copy
|
|
return
|
|
|
|
# Try each number from start to n
|
|
for i in range(start, n + 1):
|
|
# Pruning: not enough numbers left to complete combination
|
|
if n - i + 1 < k - len(current):
|
|
break
|
|
|
|
# Choose: add current number
|
|
current.append(i)
|
|
# Explore: recurse with next starting point
|
|
backtrack(i + 1, current)
|
|
# Unchoose: remove current number (backtrack)
|
|
current.pop()
|
|
|
|
backtrack(1, [])
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(k * C(n,k)) — We generate all C(n,k) combinations, each of length k.
|
|
|
|
**Space Complexity:** O(k) — Recursion depth and current combination size are bounded by k.
|
|
|
|
The backtracking approach systematically explores all valid combinations by making choices, recursing, and undoing choices. Pruning ensures we skip branches that cannot yield valid combinations.
|
|
|
|
- approach_name: Iterative with Lexicographic Generation
|
|
is_optimal: false
|
|
code: |
|
|
def combine(n: int, k: int) -> list[list[int]]:
|
|
result = []
|
|
# Start with the lexicographically smallest combination
|
|
current = list(range(1, k + 1))
|
|
|
|
while True:
|
|
result.append(current[:])
|
|
|
|
# Find the rightmost element that can be incremented
|
|
i = k - 1
|
|
while i >= 0 and current[i] == n - k + 1 + i:
|
|
i -= 1
|
|
|
|
# If no such element exists, we've generated all combinations
|
|
if i < 0:
|
|
break
|
|
|
|
# Increment this element and reset all elements to its right
|
|
current[i] += 1
|
|
for j in range(i + 1, k):
|
|
current[j] = current[j - 1] + 1
|
|
|
|
return result
|
|
explanation: |
|
|
**Time Complexity:** O(k * C(n,k)) — Same as backtracking, we generate all combinations.
|
|
|
|
**Space Complexity:** O(k) — Only the current combination is stored.
|
|
|
|
This approach generates combinations in lexicographic order without recursion. It starts with `[1, 2, ..., k]` and repeatedly finds the rightmost element that can be incremented, then resets all elements after it. More memory-efficient than recursion but harder to understand.
|