Coin Change 2

Problem

Given an integer amount and a list of coin denominations, return the number of combinations that make up the amount. You may assume that you have an infinite number of each kind of coin.

  • 0 ≤ amount ≤ 5000
  • 1 ≤ coins.length ≤ 500
  • 1 ≤ coins[i] ≤ 5000
  • All the values of coins are unique.

Example

Input: amount = 5, coins = [1, 2, 5]
Output: 4

The four combinations are: [1,1,1,1,1], [1,1,1,2], [1,2,2], and [5]. The algorithm explores two choices at each step: include the current coin (and stay at the same coin index) or skip it (move to the next coin). Memoization caches results for subproblems defined by the current coin index and accumulated amount, preventing redundant computations. For example, when considering coin 1 at amount 0, the algorithm recursively counts all combinations that sum to 5 by including or excluding coin 1, then moves on to coin 2 and so forth, accumulating counts efficiently.

Approach

Straightforward Solution

A brute-force recursive approach tries all possible combinations by exploring every coin at every step without caching, leading to exponential time complexity due to repeated subproblems.

Core Observation

The problem asks for the count of combinations to reach a target sum using unlimited coins, which naturally breaks down into overlapping subproblems defined by the current coin index and the current accumulated amount.

Path to Optimal

Recognizing overlapping subproblems and optimal substructure suggests dynamic programming. By defining a state as (coin index, current amount), the problem can be solved recursively with memoization. The key insight is to consider two choices at each state: include the current coin (stay at the same index and add its value) or exclude it (move to the next coin). Memoization ensures each state is computed once, reducing complexity drastically.

Optimal Approach

Implement a top-down recursive function with memoization that returns the number of combinations to reach the target amount starting from a given coin index and current accumulated amount. The base cases handle exact matches (return 1) and invalid states (return 0). The final answer is obtained by calling this function starting at coin index 0 and amount 0.

Time

O(n * amount)

There are n coins and amount+1 possible accumulated amounts, resulting in O(n * amount) unique states. Each state is computed once and cached, so the total time is proportional to the number of states.

Space

O(n * amount)

The memoization dictionary stores results for each unique state (coin index, accumulated amount), requiring O(n * amount) auxiliary space. The recursion stack depth is at most n + amount in the worst case, but this is dominated by the memoization storage.

Pattern Spotlight

Dynamic Programming (Top-Down Memoization for Combinatorial Counting)

When counting combinations with choices that can be repeated unlimited times, model the problem as a state defined by the current position and accumulated value, and use recursion with memoization to avoid redundant exploration of identical subproblems.

Solution

1class Solution:
2 def change(self, amount: int, coins: list[int]) -> int:
3 memo = {}
4
5 def dp(i, a):
6 if a == amount:
7 return 1
8 if a > amount or i == len(coins):
9 return 0
10 if (i, a) in memo:
11 return memo[(i, a)]
12
13 memo[(i, a)] = dp(i, a + coins[i]) + dp(i + 1, a)
14 return memo[(i, a)]
15
16 return dp(0, 0)

Step-by-Step Solution

1

Initialize Memoization Cache for Subproblem Results

3memo = {}

Objective

To create a dictionary that caches the number of combinations for each state defined by (coin index, accumulated amount).

Key Insight

Memoization prevents redundant recursive calls by storing results of subproblems. Since the problem has overlapping subproblems characterized by the current coin index and accumulated amount, caching these results ensures each unique state is computed once, drastically improving efficiency from exponential to polynomial time.

Interview Quick-Check

Core Logic

Memoization stores results keyed by (coin index, accumulated amount), enabling O(1) retrieval and avoiding recomputation.

Common Pitfalls & Bugs

Failing to memoize leads to exponential time due to repeated exploration of identical states.

2

Recursively Count Combinations by Including or Skipping Current Coin

5def dp(i, a):
6 if a == amount:
7 return 1
8 if a > amount or i == len(coins):
9 return 0
10 if (i, a) in memo:
11 return memo[(i, a)]
13 memo[(i, a)] = dp(i, a + coins[i]) + dp(i + 1, a)
14 return memo[(i, a)]

Objective

To compute the number of ways to reach the target amount by either including the current coin or skipping it, using recursion with memoization.

Key Insight

At each recursive call, the algorithm explores two paths: one that adds the current coin's value to the accumulated amount (allowing unlimited reuse of the same coin), and one that moves to the next coin without changing the amount. This dual-choice recursion captures all unique combinations. Memoization ensures that once a state is solved, its result is reused, preventing exponential blowup.

Interview Quick-Check

Core Logic

The recursive function returns the sum of counts from including the current coin (stay at same index) and skipping it (move to next index), covering all combination possibilities.

State & Boundaries

Base cases return 1 when the accumulated amount equals the target and 0 when it exceeds the target or runs out of coins.

Common Pitfalls & Bugs

Not handling base cases correctly can lead to incorrect counts or infinite recursion.

3

Return Final Count from Initial State

16return dp(0, 0)

Objective

To start the recursive counting process from the first coin and zero accumulated amount, returning the total number of combinations.

Key Insight

The initial call represents the full problem: counting all combinations to reach the target amount starting with the first coin and no amount accumulated. This triggers the entire memoized recursion, which efficiently explores all valid combinations and returns the final count.

Interview Quick-Check

Core Logic

Starting recursion at (0, 0) covers all coins and amounts, ensuring the complete solution space is explored.

Line Analysis

This solution has 5 Critical lines interviewers watch for.

Line 10 Critical
if (i, a) in memo:

Check if the current state (i, a) has been computed before.

This memoization lookup prevents recomputation of identical subproblems, drastically improving efficiency.

Line 11 Critical
return memo[(i, a)]

Return the cached result for the current state.

Returning the cached value avoids redundant recursion and ensures each state is computed once.

Line 13 Critical
memo[(i, a)] = dp(i, a + coins[i]) + dp(i + 1, a)

Compute the number of combinations by including the current coin and by skipping it, then store the sum in the memo.

This line embodies the core DP recurrence: the total combinations from the current state equal the sum of combinations including the current coin (which allows repeated use) and combinations excluding it (moving to the next coin), capturing all unique combinations without duplication.

Line 6 Critical
if a == amount:

Check if the accumulated amount equals the target amount.

This base case signifies a valid combination has been found, so the function returns 1 to count it.

Line 8 Critical
if a > amount or i == len(coins):

Check if the accumulated amount exceeds the target or if all coins have been considered.

This base case prunes invalid paths that either overshoot the target or run out of coins, returning 0 to indicate no valid combination from this state.

Line 3 Core
memo = {}

Initialize a memoization dictionary to cache results of subproblems.

This cache is essential to avoid redundant recursive calls by storing the number of combinations for each unique state defined by (coin index, accumulated amount).

Line 16 Core
return dp(0, 0)

Start the recursion from the first coin and zero accumulated amount.

This initial call triggers the entire memoized recursion, exploring all valid combinations to reach the target amount.

Line 5 Core
def dp(i, a):

Define the recursive function dp with parameters coin index i and accumulated amount a.

This function models the problem as a state machine where each state represents the current coin and the amount accumulated so far, enabling systematic exploration of all combinations.

Line 7 Core
return 1

Return 1 to count the valid combination.

Returning 1 here contributes to the total count of combinations, marking the successful end of a recursive path.

Line 9 Core
return 0

Return 0 to indicate no valid combination from this state.

Returning 0 here prevents invalid paths from contributing to the count, ensuring correctness.

Line 14 Core
return memo[(i, a)]

Return the computed number of combinations for the current state.

Returning the memoized result completes the recursive call, propagating the count up the call stack.

Test Your Understanding

Why does the recursive function consider both including the current coin and moving to the next coin, rather than just one of these choices?

Because including the current coin accounts for combinations that use it multiple times, while moving to the next coin explores combinations that exclude it. Both choices together ensure all unique combinations are counted without duplication.

Related Problems

Dynamic Programming pattern

Don't just read it. Drill it.

Reconstruct Coin Change 2 from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the Coin Change 2 drill

or drill a free problem