Files
codetutor/backend/data/questions/coin-change-ii.yaml

247 lines
11 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
title: Coin Change II
slug: coin-change-ii
difficulty: medium
leetcode_id: 518
leetcode_url: https://leetcode.com/problems/coin-change-ii/
categories:
- arrays
- dynamic-programming
patterns:
- slug: dynamic-programming
is_optimal: true
function_signature: "def change(amount: int, coins: list[int]) -> int:"
test_cases:
visible:
- input: { amount: 5, coins: [1, 2, 5] }
expected: 4
- input: { amount: 3, coins: [2] }
expected: 0
- input: { amount: 10, coins: [10] }
expected: 1
hidden:
- input: { amount: 0, coins: [1, 2, 5] }
expected: 1
- input: { amount: 1, coins: [1] }
expected: 1
- input: { amount: 100, coins: [1, 5, 10, 25] }
expected: 242
- input: { amount: 7, coins: [2, 3, 5] }
expected: 2
- input: { amount: 500, coins: [1, 2, 5] }
expected: 12701
- input: { amount: 4, coins: [1, 2, 3] }
expected: 4
description: |
You are given an integer array `coins` representing coins of different denominations and an integer `amount` representing a total amount of money.
Return *the number of combinations that make up that amount*. If that amount of money cannot be made up by any combination of the coins, return `0`.
You may assume that you have an infinite number of each kind of coin.
The answer is **guaranteed** to fit into a signed **32-bit** integer.
constraints: |
- `1 <= coins.length <= 300`
- `1 <= coins[i] <= 5000`
- All the values of `coins` are **unique**
- `0 <= amount <= 5000`
examples:
- input: "amount = 5, coins = [1,2,5]"
output: "4"
explanation: "There are four ways to make up the amount: 5=5, 5=2+2+1, 5=2+1+1+1, 5=1+1+1+1+1"
- input: "amount = 3, coins = [2]"
output: "0"
explanation: "The amount of 3 cannot be made up just with coins of 2."
- input: "amount = 10, coins = [10]"
output: "1"
explanation: "There is only one way: use a single coin of denomination 10."
explanation:
intuition: |
Imagine you're a cashier with unlimited coins of certain denominations, and you need to count how many *distinct* ways you can give change for a specific amount.
The key insight is understanding the difference between **combinations** and **permutations**. If you have coins `[1, 2]` and need to make amount `3`, the combinations `1+2` and `2+1` are the **same** — they both use one coin of each type. We only count this once.
Think of it like filling a shopping bag: the order you put items in doesn't matter, only *what* items end up in the bag. To avoid counting the same combination multiple times, we process **one coin type at a time**. For each coin, we ask: "How many ways can I make each amount using this coin (zero or more times) plus coins I've already considered?"
This is the classic **Unbounded Knapsack** pattern where each item (coin) can be used unlimited times, and we're counting combinations, not finding a minimum.
approach: |
We solve this using **1D Dynamic Programming** with a space-optimised approach:
**Step 1: Define the DP array**
- `dp[i]`: The number of ways to make amount `i` using the coins considered so far
- Size: `amount + 1` (to include amount `0` through `amount`)
- Initial value: `dp[0] = 1` (there's exactly one way to make amount `0`: use no coins)
&nbsp;
**Step 2: Process coins one by one (outer loop)**
- Iterate through each coin in `coins`
- This ensures we count **combinations**, not permutations
- By fixing the coin order, `[1, 2]` and `[2, 1]` won't be counted separately
&nbsp;
**Step 3: Update amounts that can use this coin (inner loop)**
- For each coin, iterate through amounts from `coin` to `amount`
- For each amount `a`, add `dp[a - coin]` to `dp[a]`
- This represents: "ways to make amount `a` by using at least one of this coin"
&nbsp;
**Step 4: Return the result**
- Return `dp[amount]`, which contains the total number of combinations
&nbsp;
The key to avoiding duplicate counting is the loop order: coins in the outer loop, amounts in the inner loop. This ensures each combination is counted exactly once.
common_pitfalls:
- title: Counting Permutations Instead of Combinations
description: |
If you swap the loop order (amounts in outer, coins in inner), you'll count **permutations** instead of combinations.
For example, with `coins = [1, 2]` and `amount = 3`:
- Correct (combinations): `[1,1,1], [1,2]` → 2 ways
- Wrong (permutations): `[1,1,1], [1,2], [2,1]` → 3 ways
The `[1,2]` and `[2,1]` are the same combination but different permutations. Processing coins in the outer loop ensures each coin type is considered in a fixed order.
wrong_approach: "Outer loop over amounts, inner loop over coins"
correct_approach: "Outer loop over coins, inner loop over amounts"
- title: Wrong Base Case
description: |
Forgetting to initialise `dp[0] = 1` is a common mistake. There is exactly **one way** to make amount `0`: use no coins at all.
If you initialise `dp[0] = 0`, all subsequent values remain `0` since there's no base case to build from.
wrong_approach: "dp[0] = 0 or leaving it uninitialised"
correct_approach: "dp[0] = 1 (one way to make zero: use nothing)"
- title: Inner Loop Starting Point
description: |
The inner loop must start from `coin`, not from `0` or `1`.
- Starting from `0` would try to access `dp[negative]`
- Starting from `1` would miss the case where `amount == coin`
We can only add a coin to amounts >= its value.
wrong_approach: "for a in range(amount + 1)"
correct_approach: "for a in range(coin, amount + 1)"
- title: Confusing with Coin Change I
description: |
Coin Change I asks for the **minimum number** of coins, while Coin Change II asks for the **number of combinations**.
They require different DP formulations:
- Coin Change I: `dp[a] = min(dp[a], dp[a - coin] + 1)`
- Coin Change II: `dp[a] = dp[a] + dp[a - coin]`
Using `min` here would give incorrect results.
key_takeaways:
- "**Unbounded Knapsack pattern**: When items can be reused unlimited times and order doesn't matter, use the 'coin outer, amount inner' loop structure"
- "**Combinations vs Permutations**: Loop order determines whether you count ordered or unordered selections — this is a critical distinction in DP problems"
- "**Space optimisation**: 2D DP can often be reduced to 1D when each row only depends on the previous row (or itself)"
- "**Related problems**: This extends to Coin Change I (minimum coins), Combination Sum IV (permutations), and knapsack variants"
time_complexity: "O(n × amount). We iterate through each of the `n` coins, and for each coin, we iterate through amounts from `coin` to `amount`."
space_complexity: "O(amount). We use a 1D array of size `amount + 1` to store the number of combinations for each amount."
solutions:
- approach_name: 1D Dynamic Programming
is_optimal: true
code: |
def change(amount: int, coins: list[int]) -> int:
# dp[i] = number of ways to make amount i
dp = [0] * (amount + 1)
# Base case: one way to make amount 0 (use no coins)
dp[0] = 1
# Process each coin type one at a time
# This ensures we count combinations, not permutations
for coin in coins:
# For each amount that can use this coin
for a in range(coin, amount + 1):
# Add ways to make (a - coin) using coins considered so far
dp[a] += dp[a - coin]
return dp[amount]
explanation: |
**Time Complexity:** O(n × amount) — Nested loops over coins and amounts.
**Space Complexity:** O(amount) — Single array of size `amount + 1`.
By processing coins in the outer loop, we ensure each coin type is added in a fixed order, avoiding duplicate combinations. The inner loop accumulates ways to use the current coin (zero or more times) with previously processed coins.
- approach_name: 2D Dynamic Programming
is_optimal: false
code: |
def change(amount: int, coins: list[int]) -> int:
n = len(coins)
# dp[i][a] = ways to make amount a using first i coin types
dp = [[0] * (amount + 1) for _ in range(n + 1)]
# Base case: one way to make amount 0 with any set of coins
for i in range(n + 1):
dp[i][0] = 1
for i in range(1, n + 1):
coin = coins[i - 1]
for a in range(amount + 1):
# Don't use this coin type
dp[i][a] = dp[i - 1][a]
# Use this coin type (if possible)
if a >= coin:
dp[i][a] += dp[i][a - coin]
return dp[n][amount]
explanation: |
**Time Complexity:** O(n × amount) — Same as 1D approach.
**Space Complexity:** O(n × amount) — 2D array storing states for each coin prefix.
This explicit 2D formulation makes the state transition clearer: `dp[i][a]` represents ways to make amount `a` using only the first `i` coin types. The 1D solution is a space optimisation of this, possible because row `i` only depends on row `i` and row `i-1`.
- approach_name: Recursive with Memoization
is_optimal: false
code: |
def change(amount: int, coins: list[int]) -> int:
from functools import lru_cache
@lru_cache(maxsize=None)
def count_ways(coin_index: int, remaining: int) -> int:
# Base case: exact amount achieved
if remaining == 0:
return 1
# Base case: no more coins or overshot
if coin_index >= len(coins) or remaining < 0:
return 0
coin = coins[coin_index]
# Choice 1: Skip this coin type entirely
skip = count_ways(coin_index + 1, remaining)
# Choice 2: Use at least one of this coin (can use more)
use = count_ways(coin_index, remaining - coin)
return skip + use
return count_ways(0, amount)
explanation: |
**Time Complexity:** O(n × amount) — Each unique state `(coin_index, remaining)` is computed once.
**Space Complexity:** O(n × amount) — Memoization cache plus recursion stack.
This top-down approach makes the decision tree explicit: at each coin, we either skip it entirely or use at least one. The `coin_index` parameter ensures we process coins in order, counting combinations rather than permutations. Less efficient than iterative DP due to function call overhead.