Skip to main content

💰 The Discovery Journey: Coin Change Problem

The Designer's Mindset

Let me walk you through the messy thinking process that leads to the solution, exactly as a designer would discover it.

This isn't a polished solution - it's the authentic struggle of understanding recursion and optimization.


Section 1: Discovery & Problem Understanding​

1.1: Problem Analysis & Initial Thinking​

First Attempt: Your Intuition (What You Got Right)​

int func(int[] coins, int target, int numberOfCoinsUsed) {
if (target == 0) {
return numberOfCoinsUsed; // ✓ CORRECT INSTINCT
}

if (target < 0) {
// ??? You sensed something wrong happens here
}

for (int i = 0; i < length; i++) {
func(coins, target - coins[i], numberOfCoinsUsed++);
}
}
What You Correctly Sensed

✓ "When I reach exactly the target, I should return how many coins I used"

✓ "Negative target means I went too far - this path is invalid"

✓ "I need to try every coin choice"

Where You Got Stuck

❓ "What does an invalid path return?"

❓ "How do I compare results from different paths?"

❓ "Where does the MIN operation happen?"

This confusion is THE KEY INSIGHT we need to unlock!


1.2: Thought Process & Strategy​

How to Think About Recursion Returns​

Step-by-step checklist:

1. Define what the function ASKS:​
func(coins, 11) asks: "Minimum coins for amount 11?"
2. Define what the function RETURNS:​
An integer: the answer to that question
3. Base cases return DIRECT answers:​
target == 0 → Direct answer: 0 coins
target < 0 → Direct answer: "impossible" (use sentinel value)
4. Recursive case BUILDS answer from subproblems:​
  • Ask multiple subproblems (try each coin)
  • Combine their answers (MIN operation)
  • Add the "cost" of this step (+1 coin)

Resolving Common Confusions​

Question 1: "Where to put MIN/MAX control?"

Answer: The MIN happens in the recursive case, AFTER collecting all subproblem results.

// ❌ WRONG: Trying to track minimum while recursing
for (int coin : coins) {
func(amount - coin, numberOfCoinsUsed++); // ✗ Lost information
}

// ✅ CORRECT: Ask subproblems, then combine answers
int minCoins = Integer.MAX_VALUE;
for (int coin : coins) {
int subAnswer = func(amount - coin); // Get the answer
minCoins = Math.min(minCoins, subAnswer + 1); // Compare answers
}
return minCoins; // Return OUR answer
Why the First Approach Fails

When you call func(amount - coin, numberOfCoinsUsed++):

  • You're not capturing the return value
  • Each recursive call is independent - they can't see each other's results
  • You have no way to compare and choose the minimum

The return value IS how subproblems communicate their answers!


Question 2: "What should it return?"

Mental model: Every function call is a QUESTION-ANSWER pair:

QUESTION: func(coins, 11) = "Min coins for 11?"
ANSWER: return 3 = "3 coins"

QUESTION: func(coins, -1) = "Min coins for -1?"
ANSWER: return MAX_VALUE = "Impossible"
The Return Value IS the Answer

The return value is not a side effect - it's the primary mechanism for communication in recursion!

Each level asks questions (recursive calls) and gets answers (return values), then uses those answers to build its own answer.


1.3: Prerequisites & Core Insights​

The Critical "Aha!" Moment​

The Mental Shift You Need

In recursion, the return value IS the answer to "what's the minimum coins for THIS subproblem?"

Let me show you what this means:

// When you write:
func(coins, 11)

// You're asking: "What's the MINIMUM coins to make 11?"
// The RETURN VALUE is the answer!

// When recursion calls:
func(coins, 11 - 5) // which is func(coins, 6)

// You're asking: "What's the MINIMUM coins to make 6?"
// The RETURN VALUE tells you!

The Mental Shift:​

❌ WRONG THINKING:

"I'll track numberOfCoinsUsed as I go, and return it at the end"

✅ CORRECT THINKING:

"Each function call ASKS a question and RETURNS the answer. I ask about smaller subproblems, get their answers, and build my answer from theirs."


Section 2: Implementation & Deep Understanding​

2.1: Solution Implementation​

Step 1: What Should Invalid Paths Return?​

Designer's thought process:

"If target < 0, this path failed. What number represents FAILURE in a minimum problem?"

Think about the MIN operation:

Math.min(3, ???)  // What should ??? be so it doesn't affect the result?

Answer: A very large number (or infinity)

if (target < 0) {
return Integer.MAX_VALUE; // "This path is impossible"
}
Why Integer.MAX_VALUE?

Because when we compare:

Math.min(3, Integer.MAX_VALUE) → 3  // Good path wins!

The failed path doesn't interfere with valid paths!


Step 2: Where Does the MIN Operation Happen?​

The chronological discovery:

First Insight: "I try multiple coins, each gives a different answer"

int result1 = func(coins, target - coins[0]);
int result2 = func(coins, target - coins[1]);
int result3 = func(coins, target - coins[2]);

Second Insight: "I want the MINIMUM of all these results"

int best = Math.min(result1, Math.min(result2, result3));

Third Insight: "But I need to ADD 1 to account for the coin I just used!"

int best = Math.min(result1 + 1, result2 + 1, result3 + 1);

Step 3: The Complete Recursive Structure​

int func(int[] coins, int target) {
// BASE CASE 1: Success - we made exact change
if (target == 0) {
return 0; // Zero coins needed to make zero
}

// BASE CASE 2: Failure - we overshot
if (target < 0) {
return Integer.MAX_VALUE; // Impossible path
}

// RECURSIVE CASE: Try every coin
int minCoins = Integer.MAX_VALUE; // Start with "impossible"

for (int coin : coins) {
int subproblemResult = func(coins, target - coin);

// If this path is valid, consider it
if (subproblemResult != Integer.MAX_VALUE) {
minCoins = Math.min(minCoins, subproblemResult + 1);
// ^^^^^^^^^^^^^^^^
// subproblem + current coin
}
}

return minCoins;
}

Complete Production-Ready Code​

class Solution {
public int coinChange(int[] coins, int amount) {
int result = coinChangeHelper(coins, amount);
return result == Integer.MAX_VALUE ? -1 : result;
}

private int coinChangeHelper(int[] coins, int amount) {
// BASE CASE 1: Exact change made
if (amount == 0) {
return 0;
}

// BASE CASE 2: Overshot (invalid path)
if (amount < 0) {
return Integer.MAX_VALUE;
}

// RECURSIVE CASE: Try every coin choice
int minCoins = Integer.MAX_VALUE;

for (int coin : coins) {
int subResult = coinChangeHelper(coins, amount - coin);

// Only consider valid paths
if (subResult != Integer.MAX_VALUE) {
minCoins = Math.min(minCoins, subResult + 1);
}
}

return minCoins;
}
}

2.2: Key Concepts & Mechanics​

Understanding the Critical Addition: Why + 1?​

The Critical Addition

Why + 1?

Each recursive call tells us: "minimum coins needed for the remaining amount"

We need to add 1 to count the coin we just used to reduce the amount!

subproblemResult + 1
↑ ↑
remainder current coin

Reading the Code Like English​

  1. "If I've made exact change" → return 0 (no more coins needed)
  2. "If I went negative" → return impossible (this path failed)
  3. "Otherwise, try every coin":
    • Ask: "What's minimum for the remainder?"
    • Add 1 for the coin I'm using now
    • Keep track of the best option
  4. "Return the best option I found"

Why Two Functions?​

Function Separation Strategy

Outer function (coinChange):

  • Handles the conversion of MAX_VALUE to -1 (problem requirement)
  • Clean interface for the caller

Inner function (coinChangeHelper):

  • Pure recursive logic
  • Uses MAX_VALUE as sentinel for impossible cases

2.3: Edge Cases & Validation​

Test 1: Impossible Case​

Input: coins = [2], amount = 3

Trace:

func([2], 3)
→ try coin 2: func([2], 1)
→ try coin 2: func([2], -1)
→ return MAX_VALUE // overshot
→ return MAX_VALUE
→ minCoins = MAX_VALUE
→ return MAX_VALUE // Final answer: -1
What This Teaches

Sentinel values propagate up when no valid path exists.

The MAX_VALUE "bubbles up" through all the failed attempts, eventually reaching the top level where we convert it to -1.


Test 2: Multiple Paths​

Input: coins = [1, 2, 5], amount = 11

Key decision point at func([1,2,5], 11):

// Path 1: Use coin 5
func(11 - 5) = func(6) → eventually returns 2
// So this path = 2 + 1 = 3

// Path 2: Use coin 2
func(11 - 2) = func(9) → eventually returns 3
// So this path = 3 + 1 = 4

// Path 3: Use coin 1
func(11 - 1) = func(10) → eventually returns 2
// So this path = 2 + 1 = 3

// MIN(3, 4, 3) = 3 ✓
What This Teaches

The MIN operation naturally selects the optimal path.

We don't need to explicitly track which path is best - the MIN operation does it for us!


Section 3: Mastery & Visualization​

3.1: Practical Examples/Visualization​

Detailed Trace for coins = [1, 2, 5], amount = 11​

Let me trace one complete path to show how the recursion works:

func(11)
├─ Try coin 5: func(6) + 1
│ ├─ Try coin 5: func(1) + 1
│ │ ├─ Try coin 5: func(-4) → MAX_VALUE
│ │ ├─ Try coin 2: func(-1) → MAX_VALUE
│ │ └─ Try coin 1: func(0) → 0
│ │ → min(MAX, MAX, 0+1) = 1
│ ├─ Try coin 2: func(4) + 1
│ │ └─ ... → returns 2
│ └─ Try coin 1: func(5) + 1
│ └─ ... → returns 1
│ → min(1+1, 2+1, 1+1) = 2
├─ Try coin 2: func(9) + 1
│ └─ ... → returns 3
├─ Try coin 1: func(10) + 1
│ └─ ... → returns 2
→ min(2+1, 3+1, 2+1) = 3 ✓

Interactive Understanding Questions​

Q1: Why do we return 0 when amount == 0?

Click to reveal answer
Answer

Because we need zero coins to make zero amount.

This is both:

  1. Logically correct: No coins needed for no amount
  2. Mathematically correct: Doesn't add anything when we do subResult + 1

If we returned 1 or any other value, we'd be adding extra coins that don't exist!


Q2: What if we used 0 instead of MAX_VALUE for invalid paths?

Click to reveal answer
Answer

Disaster! The MIN operation would always choose the invalid path:

Math.min(3, 0) = 0  // Wrong! 0 means "impossible", not "0 coins"

We'd return 0 for impossible cases, which the problem interprets as "success with 0 coins".

MAX_VALUE ensures invalid paths lose the MIN competition.


Q3: Why check subResult != Integer.MAX_VALUE before taking MIN?

Click to reveal answer
Answer

To prevent integer overflow!

Integer.MAX_VALUE + 1 = -2147483648  // Overflow! Wraps to negative

If we add 1 to MAX_VALUE, it wraps around to a huge negative number, which would win the MIN operation incorrectly!

By checking first, we avoid this overflow issue.


Q4: Can we solve this without recursion?

Click to reveal answer
Answer

Yes! That's what DP is for.

The recursive solution is top-down (starts at the goal, works to base cases).

We can convert it to bottom-up (starts at base cases, builds to goal):

// Bottom-up approach:
dp[0] = 0; // base case
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}

Same logic, different direction!


3.2: Framework & Pattern Recognition​

The Universal Recursion Pattern​

This problem follows the same pattern as many DP problems:

ProblemDecision at Each StepHow to Combine
Climbing Stairs1 step or 2 stepsSum (counting paths)
Coin ChangeTry each coinMin (finding optimal)
Decode Ways1 digit or 2 digitsSum (counting ways)
The Core Pattern
// General structure:
func(state) {
if (base_case) return direct_answer;

best = initial_value;
for each choice:
result = func(new_state_after_choice);
best = combine(best, result + cost_of_choice);

return best;
}

Variables:

  • combine = min, max, sum, etc.
  • cost_of_choice = what this decision costs (often 1)
  • initial_value = MAX_VALUE for min, 0 for sum, etc.

Complexity Analysis - Pure Recursion​

Time Complexity: O(amount^coins.length)

  • At each level, we try all coins
  • Maximum recursion depth is amount (if we use coin=1 every time)
  • Exponential time due to overlapping subproblems

Space Complexity: O(amount)

  • Recursion stack depth in worst case
This is Too Slow!

For coins = [1, 2, 5] and amount = 100:

  • We'll compute func(50) many times from different paths
  • This is where memoization comes in!

The pure recursive solution has exponential time complexity - we need DP optimization!


3.3: Key Takeaways & Summary​

Core Insights​

  1. ✅ Return values are answers - Not side effects, but the primary communication mechanism
  2. ✅ Sentinel values represent impossibility - MAX_VALUE for MIN problems, -1 or MIN_VALUE for MAX problems
  3. ✅ Base cases return direct answers - No recursion needed when you know the answer
  4. ✅ Recursive cases build from subproblems - Ask smaller questions, combine answers
  5. ✅ The +1 represents the current decision - Add the cost of the choice you're making
The Core Insight

Recursion is about asking questions and building answers from smaller questions.

func(amount) asks: "Min coins for this amount?"
↓
Asks smaller questions: "Min coins for amount - coin?"
↓
Gets answers: subResult
↓
Builds own answer: min(all subResults) + 1
↓
Returns answer to whoever asked

Master this thinking pattern, and hundreds of DP problems become approachable!


Next Steps in the Journey​

Now that you understand the recursive foundation:

  1. Add Memoization - Cache results to eliminate redundant computation
  2. Convert to Bottom-Up - Iterative DP for better space efficiency
  3. Optimize Space - Notice we only need recent values
  4. Master the Pattern - Apply to similar unbounded knapsack problems
The Complete Evolution
Pure Recursion (Understand the structure)
↓
Top-Down with Memo (Eliminate redundant work)
↓
Bottom-Up DP (Remove recursion overhead)
↓
Space-Optimized (Minimize memory usage)

You now have the foundation! Each optimization builds on this recursive understanding.


Ready for Optimization?​

Next Document

The next doc will show you memoization and bottom-up DP - same logic, but 1000x faster!

Would you like to see how we transform this O(amount^coins) solution into O(amount × coins)?