questions C

This commit is contained in:
2025-05-25 10:16:13 +01:00
parent 1e0aebfbfd
commit e6a22f98f8
85 changed files with 16925 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
title: Combination Sum
slug: combination-sum
difficulty: medium
leetcode_id: 39
leetcode_url: https://leetcode.com/problems/combination-sum/
categories:
- arrays
- recursion
patterns:
- backtracking
description: |
Given an array of **distinct** integers `candidates` and a target integer `target`, return *a list of all **unique combinations** of* `candidates` *where the chosen numbers sum to* `target`. You may return the combinations in **any order**.
The **same** number may be chosen from `candidates` an **unlimited number of times**. Two combinations are unique if the frequency of at least one of the chosen numbers is different.
The test cases are generated such that the number of unique combinations that sum up to `target` is less than `150` combinations for the given input.
constraints: |
- `1 <= candidates.length <= 30`
- `2 <= candidates[i] <= 40`
- All elements of `candidates` are **distinct**
- `1 <= target <= 40`
examples:
- input: "candidates = [2,3,6,7], target = 7"
output: "[[2,2,3],[7]]"
explanation: "2 and 3 are candidates, and 2 + 2 + 3 = 7. Note that 2 can be used multiple times. 7 is a candidate, and 7 = 7. These are the only two combinations."
- input: "candidates = [2,3,5], target = 8"
output: "[[2,2,2,2],[2,3,3],[3,5]]"
explanation: "The three combinations that sum to 8 are: four 2s, two 2s and two 3s, and one 3 with one 5."
- input: "candidates = [2], target = 1"
output: "[]"
explanation: "The smallest candidate is 2, which is already larger than the target 1, so no combination is possible."
explanation:
intuition: |
Imagine you're at a vending machine that only accepts exact change. You have an unlimited supply of certain coin denominations (the candidates), and you need to find every possible way to make exactly the target amount.
The key insight is that this is a **decision tree** problem. At each step, you decide: "Should I use another coin of this denomination, or move on to the next denomination?" By exploring all paths through this decision tree, you find all valid combinations.
Think of it like filling a shopping cart: for each item type (candidate), you can take zero, one, two, or more of that item. But you have a budget (target), and you need to find all ways to spend *exactly* that budget.
What makes this different from standard combinations is that we can **reuse the same element**. This means when we pick a candidate, we don't move past it — we stay and consider picking it again. We only move to the next candidate when we decide we're done with the current one.
The backtracking approach efficiently explores this space by building combinations incrementally, abandoning paths as soon as they exceed the target (pruning), and trying all possibilities through the choose-explore-unchoose pattern.
approach: |
We solve this using **Backtracking with Pruning**:
**Step 1: Sort the candidates (optional but enables better pruning)**
- Sorting allows us to stop early when a candidate exceeds the remaining target
- If `candidates[i] > remaining`, all subsequent candidates will also exceed it
&nbsp;
**Step 2: Define the recursive backtrack function**
- `backtrack(start, remaining, current_combination)`
- `start`: index of the first candidate we can use (prevents duplicates)
- `remaining`: how much more we need to reach the target
- `current_combination`: the combination being built
&nbsp;
**Step 3: Base case — target reached**
- If `remaining == 0`, we've found a valid combination
- Add a **copy** of `current_combination` to results
- Return to explore other paths
&nbsp;
**Step 4: Recursive case — try each candidate**
- Loop through candidates starting from index `start`
- For each candidate at index `i`:
- **Prune**: If `candidates[i] > remaining`, break (no point trying larger candidates)
- **Choose**: Add `candidates[i]` to `current_combination`
- **Explore**: Recursively call `backtrack(i, remaining - candidates[i], current_combination)`
- Note: we pass `i`, not `i + 1`, because we can reuse the same candidate
- **Unchoose**: Remove the last element (backtrack)
&nbsp;
**Step 5: Return all valid combinations**
- Call `backtrack(0, target, [])` to start the search
- Return the collected results
common_pitfalls:
- title: Generating Duplicate Combinations
description: |
Without careful indexing, you might generate `[2,3,2]` and `[2,2,3]` and `[3,2,2]` as separate combinations when they should all be the same.
The fix is to **only consider candidates at or after the current index**. By always building combinations in a consistent order (never going backwards in the candidates array), each unique multiset appears exactly once.
For example, with candidates `[2,3]` and target 7:
- We explore `[2,2,2,...]` before `[2,3,...]` before `[3,...]`
- We never generate `[3,2,...]` because we don't go back to index 0 after processing index 1
wrong_approach: "Starting each recursion from index 0"
correct_approach: "Starting from the current candidate's index"
- title: Moving Past the Current Candidate Too Early
description: |
In standard combination problems, after picking element at index `i`, we recurse with `i + 1`. But here, we can reuse elements!
If you recurse with `i + 1` instead of `i`, you'll miss combinations like `[2,2,3]` because after picking the first 2, you'd move to 3 and never pick another 2.
The key difference: pass `i` (same index) to allow reuse, not `i + 1`.
wrong_approach: "backtrack(i + 1, ...) after choosing candidates[i]"
correct_approach: "backtrack(i, ...) to allow reusing the same candidate"
- title: Not Pruning When Exceeding Target
description: |
Without pruning, the algorithm wastes time exploring paths that already exceed the target. For example, if `remaining = 3` and `candidates[i] = 5`, there's no point adding 5 or any larger candidate.
Sorting candidates first enables early termination: once a candidate exceeds `remaining`, all subsequent candidates (being equal or larger) will also exceed it, so we can `break` out of the loop entirely.
wrong_approach: "Continuing to try all candidates regardless of remaining target"
correct_approach: "Breaking early when candidate > remaining (after sorting)"
- title: Forgetting to Copy the Combination
description: |
A classic backtracking bug: appending `current_combination` directly to results instead of a copy.
Since `current_combination` is modified during backtracking (elements added and removed), all entries in results would reference the same list, which ends up empty.
Always append a copy: `results.append(current_combination[:])` or `results.append(list(current_combination))`.
wrong_approach: "results.append(current_combination)"
correct_approach: "results.append(current_combination[:])"
key_takeaways:
- "**Reuse vs. no-reuse**: The key difference from standard combinations is passing `i` instead of `i + 1` in the recursive call, allowing unlimited reuse of each candidate"
- "**Pruning with sorting**: Sorting candidates enables early termination when a candidate exceeds the remaining target, significantly improving performance"
- "**Avoiding duplicates through ordering**: By only considering candidates at index >= current index, we ensure each combination is generated exactly once"
- "**Foundation for variants**: This pattern extends to Combination Sum II (each element used once), III (exactly k numbers), and IV (count combinations)"
time_complexity: "O(n^(t/m)). In the worst case, we explore a tree where each node has n branches, and the depth is t/m (target divided by minimum candidate). The actual complexity depends heavily on the input — with good pruning, it's often much faster."
space_complexity: "O(t/m). The recursion depth is bounded by target/min_candidate (the maximum number of elements in any combination). We also use O(t/m) space for the current combination being built."
solutions:
- approach_name: Backtracking with Pruning
is_optimal: true
code: |
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
result = []
# Sort to enable pruning: once a candidate exceeds remaining, all after it will too
candidates.sort()
def backtrack(start: int, remaining: int, current: list[int]) -> None:
# Base case: found a valid combination
if remaining == 0:
result.append(current[:]) # Append a copy
return
# Try each candidate starting from 'start' index
for i in range(start, len(candidates)):
candidate = candidates[i]
# Pruning: if this candidate exceeds remaining, all after it will too
if candidate > remaining:
break
# Choose: add this candidate to our combination
current.append(candidate)
# Explore: recurse with same index (can reuse this candidate)
backtrack(i, remaining - candidate, current)
# Unchoose: remove the candidate (backtrack)
current.pop()
backtrack(0, target, [])
return result
explanation: |
**Time Complexity:** O(n^(t/m)) — Where n is the number of candidates, t is the target, and m is the minimum candidate value. This represents the worst-case branching factor and depth.
**Space Complexity:** O(t/m) — The maximum recursion depth equals the maximum combination length.
The backtracking approach systematically explores all valid combinations. Sorting enables powerful pruning: once a candidate exceeds the remaining target, we skip all larger candidates. The key insight is passing the same index `i` (not `i + 1`) to allow unlimited reuse of each candidate.
- approach_name: Backtracking without Sorting
is_optimal: false
code: |
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
result = []
def backtrack(start: int, remaining: int, current: list[int]) -> None:
# Base case: found a valid combination
if remaining == 0:
result.append(current[:])
return
# Base case: exceeded target, abandon this path
if remaining < 0:
return
# Try each candidate starting from 'start' index
for i in range(start, len(candidates)):
# Choose
current.append(candidates[i])
# Explore with same index (can reuse)
backtrack(i, remaining - candidates[i], current)
# Unchoose
current.pop()
backtrack(0, target, [])
return result
explanation: |
**Time Complexity:** O(n^(t/m)) — Same worst case as the optimised version.
**Space Complexity:** O(t/m) — Same recursion depth bound.
This version works without sorting but is less efficient. Instead of breaking early when a candidate exceeds the remaining target, it continues and relies on the `remaining < 0` check to prune. This means it explores more invalid branches before abandoning them. For small inputs the difference is negligible, but sorting provides a meaningful speedup for larger cases.
- approach_name: Dynamic Programming
is_optimal: false
code: |
def combination_sum(candidates: list[int], target: int) -> list[list[int]]:
# dp[i] contains all combinations that sum to i
dp = [[] for _ in range(target + 1)]
dp[0] = [[]] # One way to make sum 0: empty combination
for candidate in candidates:
# For each sum from candidate to target
for current_sum in range(candidate, target + 1):
# Extend each combination that sums to (current_sum - candidate)
for combo in dp[current_sum - candidate]:
# Add this candidate to form a combination summing to current_sum
dp[current_sum].append(combo + [candidate])
return dp[target]
explanation: |
**Time Complexity:** O(n * t * k) — Where n is the number of candidates, t is the target, and k is the average number of combinations at each sum.
**Space Complexity:** O(t * k * m) — We store all combinations for each sum up to target, where m is the average combination length.
This bottom-up DP approach builds combinations incrementally. For each candidate, we iterate through all possible sums and extend existing combinations. By processing candidates in order and only extending existing combinations, we naturally avoid duplicates. However, this approach uses more memory than backtracking since it stores all intermediate combinations.