questions C
This commit is contained in:
187
backend/data/questions/combinations.yaml
Normal file
187
backend/data/questions/combinations.yaml
Normal file
@@ -0,0 +1,187 @@
|
||||
title: Combinations
|
||||
slug: combinations
|
||||
difficulty: medium
|
||||
leetcode_id: 77
|
||||
leetcode_url: https://leetcode.com/problems/combinations/
|
||||
categories:
|
||||
- arrays
|
||||
- recursion
|
||||
patterns:
|
||||
- backtracking
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user