Skip to content

Commit 95377e8

Browse files
authored
Merge pull request #162 from BrianLusina/feat/algorithms-dynamic-programming-coin-change
feat(algorithms, dynamic programming): coin change
2 parents 039a999 + 8018a3d commit 95377e8

18 files changed

+362
-151
lines changed

DIRECTORY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
* [Test Max Profit With Fee](https://github.com/BrianLusina/PythonSnips/blob/master/algorithms/dynamic_programming/buy_sell_stock/test_max_profit_with_fee.py)
5858
* Climb Stairs
5959
* [Test Climb Stairs](https://github.com/BrianLusina/PythonSnips/blob/master/algorithms/dynamic_programming/climb_stairs/test_climb_stairs.py)
60+
* Coin Change
61+
* [Test Coin Change](https://github.com/BrianLusina/PythonSnips/blob/master/algorithms/dynamic_programming/coin_change/test_coin_change.py)
6062
* Countingbits
6163
* [Test Counting Bits](https://github.com/BrianLusina/PythonSnips/blob/master/algorithms/dynamic_programming/countingbits/test_counting_bits.py)
6264
* Decodeways
@@ -1076,7 +1078,6 @@
10761078
* [Test Bowling](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_bowling.py)
10771079
* [Test Cake Is Not A Lie](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_cake_is_not_a_lie.py)
10781080
* [Test Chess Board Cell Color](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_chess_board_cell_color.py)
1079-
* [Test Coin Change](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_coin_change.py)
10801081
* [Test Coin Flip](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_coin_flip.py)
10811082
* [Test Count Vegetables](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_count_vegetables.py)
10821083
* [Test Doomsday Fuel](https://github.com/BrianLusina/PythonSnips/blob/master/tests/puzzles/test_doomsday_fuel.py)

algorithms/dynamic_programming/climb_stairs/__init__.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,40 @@ def climb_stairs(n: int) -> int:
1313
return second
1414

1515

16-
def climb(n: int) -> int:
16+
def climb_stairs_dp_bottom_up(n: int) -> int:
17+
if n < 0:
18+
return 0
19+
if n <= 1:
20+
return 1
21+
22+
dp = [0] * (n + 1)
23+
dp[1] = 1
24+
dp[2] = 2
25+
26+
for idx in range(3, n + 1):
27+
dp[idx] = dp[idx - 1] + dp[idx - 2]
28+
29+
return dp[n]
30+
31+
32+
def climb_stairs_dp_top_down(n: int) -> int:
1733
"""
1834
Finds the number of possible ways to climb up n steps given the steps can be climbed wither 1 at a time or 2 at a
1935
time
2036
:param n: number of steps
2137
:return: number of possible ways to climb up the steps
2238
23-
>>> climb(2)
39+
>>> climb_stairs_dp_top_down(2)
2440
2
2541
26-
>>> climb(1)
42+
>>> climb_stairs_dp_top_down(1)
2743
1
2844
29-
>>> climb(3)
45+
>>> climb_stairs_dp_top_down(3)
3046
3
3147
"""
3248
if n < 0:
3349
return 0
3450
if n == 1 or n == 0:
3551
return 1
36-
return climb(n - 1) + climb(n - 2)
52+
return climb_stairs_dp_top_down(n - 1) + climb_stairs_dp_top_down(n - 2)

algorithms/dynamic_programming/climb_stairs/test_climb_stairs.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
import unittest
2-
3-
from . import climb_stairs
2+
from parameterized import parameterized
3+
from algorithms.dynamic_programming.climb_stairs import (
4+
climb_stairs,
5+
climb_stairs_dp_bottom_up,
6+
climb_stairs_dp_top_down,
7+
)
8+
9+
CLIMB_STAIRS_TEST_CASES = [
10+
(2, 2),
11+
(3, 3),
12+
(4, 5),
13+
(4, 5),
14+
(5, 8),
15+
]
416

517

618
class ClimbStairsTestCase(unittest.TestCase):
7-
def test_1(self):
8-
"""should return 2 for n = 2"""
9-
n = 2
10-
expected = 2
19+
@parameterized.expand(CLIMB_STAIRS_TEST_CASES)
20+
def test_climb_stairs(self, n: int, expected: int):
1121
actual = climb_stairs(n)
12-
1322
self.assertEqual(expected, actual)
1423

15-
def test_2(self):
16-
"""should return 3 for n = 3"""
17-
n = 3
18-
expected = 3
19-
actual = climb_stairs(n)
24+
@parameterized.expand(CLIMB_STAIRS_TEST_CASES)
25+
def test_climb_stairs_dp_bottom_up(self, n: int, expected: int):
26+
actual = climb_stairs_dp_bottom_up(n)
27+
self.assertEqual(expected, actual)
2028

29+
@parameterized.expand(CLIMB_STAIRS_TEST_CASES)
30+
def test_climb_stairs_dp_top_down(self, n: int, expected: int):
31+
actual = climb_stairs_dp_top_down(n)
2132
self.assertEqual(expected, actual)
2233

2334

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Coin Change
2+
3+
Correctly determine the fewest number of coins to be given to a customer such that the sum of the coins' value would
4+
equal the correct amount of change.
5+
6+
## For example
7+
8+
- An input of 15 with [1, 5, 10, 25, 100] should return one nickel (5)
9+
and one dime (10) or [0, 1, 1, 0, 0]
10+
- An input of 40 with [1, 5, 10, 25, 100] should return one nickel (5)
11+
and one dime (10) and one quarter (25) or [0, 1, 1, 1, 0]
12+
13+
## Edge cases
14+
15+
- Does your algorithm work for any given set of coins?
16+
- Can you ask for negative change?
17+
- Can you ask for a change value smaller than the smallest coin value?
18+
19+
## Exception messages
20+
21+
Sometimes it is necessary to raise an exception. When you do this, you should include a meaningful error message to
22+
indicate what the source of the error is. This makes your code more readable and helps significantly with debugging. Not
23+
every exercise will require you to raise an exception, but for those that do, the tests will only pass if you include a
24+
message.
25+
26+
To raise a message with an exception, just write it as an argument to the exception type. For example, instead of
27+
`raise Exception`, you should write:
28+
29+
```python
30+
raise Exception("Meaningful message indicating the source of the error")
31+
```
32+
33+
## Source
34+
35+
Software Craftsmanship - Coin Change
36+
Kata [https://web.archive.org/web/20130115115225/http://craftsmanship.sv.cmu.edu:80/exercises/coin-change-kata](https://web.archive.org/web/20130115115225/http://craftsmanship.sv.cmu.edu:80/exercises/coin-change-kata)
37+
38+
## Solution
39+
40+
If we look at the problem, we might immediately think that it could be solved through a greedy approach. However, if we
41+
look at it closely, we’ll know that it’s not the correct approach here. Let’s take a look at an example to understand why
42+
this problem can’t be solved with a greedy approach.
43+
44+
Let's suppose we have coins = [1, 3, 4, 5] and we want to find the total = 7 and we try to solve the problem with a greedy
45+
approach. In a greedy approach, we always start from the very end of a sorted array and traverse backward to find our
46+
solution because that allows us to solve the problem without traversing the whole array. However, in this situation, we
47+
start off with a 5 and add that to our total. We then check if it’s possible to get a 7 with the help of either 4 or 3,
48+
but as expected, that won't be the case, and we would need to add 1 twice to get our required total.
49+
50+
The problem seems to be solved, and we have concluded that we need maximum 3 coins to get to the total of 7. However, if
51+
we take a look at our array, that isn’t the case. In fact, we could have reached the total of 7 with just 2 coins: 4 and 3.
52+
So, the problem needs to be broken down into subproblems, and an optimal solution can be reached from the optimal
53+
solutions of its subproblems.
54+
55+
To split the problem into subproblems, let's assume we know the number of coins required for some total value and the
56+
last coin denomination is C. Because of the optimal substructure property, the following equation will be true:
57+
58+
Min(total)=Min(total−C)+1
59+
60+
But, we don't know what is the value of C yet, so we compute it for each element of the coins array and select the
61+
minimum from among them. This creates the following recurrence relation:
62+
63+
Min(total)=mini=0.....n-1(Min(total−Ci)+1), such that
64+
Min(total)=0, for total=0
65+
Min(total)= -1, for n=0
66+
67+
> Note: The problem can also be solved with the help of a simple recursive tree without any backtracking, but that would
68+
> take extra memory and time complexity, as we can see in the illustration below.
69+
70+
![Coin Change Recursive Tree](images/solutions/coin_change_recursive_tree_1.png)
71+
72+
> Recursive tree for finding minimum number of coins for the total 5 with the coins [1,2,3]
73+
74+
### Step-by-step solution construction
75+
76+
The idea is to solve the problem using the top-down technique of dynamic programming. If the required total is less than
77+
the number that’s being evaluated, the algorithm doesn’t make any more recursive calls. Moreover, the recursive tree
78+
calculates the results of many subproblems multiple times. Therefore, if we store the result of each subproblem in a
79+
table, we can drastically improve the algorithm’s efficiency by accessing the required value at a constant time. This
80+
massively reduces the number of recursive calls we need to make to reach our results.
81+
82+
We start our solution by creating a helper function that assists us in calculating the number of coins we need. It has
83+
three base cases to cover about what to return if the remaining amount is:
84+
85+
- Less than zero
86+
- Equal to zero
87+
- Neither less than zero nor equal to zero
88+
89+
> The top-down solution, commonly known as the memoization technique, is an enhancement of the recursive solution. It
90+
> solves the problem of calculating redundant solutions over and over by storing them in an array.
91+
92+
In the last case, when the remaining amount is neither of the base cases, we traverse the coins array, and at each
93+
element, we recursively call the calculate_minimum_coins() function, passing the updated remaining amount remaining_amount
94+
minus the value of the current coin. This step effectively evaluates the number of coins needed for each possible
95+
denomination to make up the remaining amount. We store the return value of the base cases for each subproblem in a
96+
variable named result. We then add 1 to the result variable indicating that we're using this coin denomination in the
97+
process of making up the corresponding total. Now, we assign this value to minimum, which is initially set to infinity
98+
at the start of each path.
99+
100+
To avoid recalculating the minimum values for subproblems, we utilize the counter array, which serves as a memoization
101+
table. This array stores the minimum number of coins required to make up each specific amount of money up to the given
102+
total. At the end of each path traversal, we update the corresponding index of the counter array with the calculated
103+
minimum value. Finally, we return the minimum number of coins needed for the given total amount.
104+
105+
![Solution 1](./images/solutions/coin_change_solution_1.png)
106+
![Solution 2](./images/solutions/coin_change_solution_2.png)
107+
![Solution 3](./images/solutions/coin_change_solution_3.png)
108+
![Solution 4](./images/solutions/coin_change_solution_4.png)
109+
![Solution 5](./images/solutions/coin_change_solution_5.png)
110+
![Solution 6](./images/solutions/coin_change_solution_6.png)
111+
![Solution 7](./images/solutions/coin_change_solution_7.png)
112+
![Solution 8](./images/solutions/coin_change_solution_8.png)
113+
114+
### Summary
115+
116+
To recap, the solution to this problem can be divided into the following parts:
117+
118+
1. We first check the base cases, if total is either 0 or less than 0:
119+
- 0 means no new coins need to be added because we have reached a viable solution.
120+
- Less than 0 means our path can’t lead to this solution, so we need to backtrack.
121+
2. After this, we use the top-down approach and traverse the given coin denominations.
122+
3. At each iteration, we either pick a coin or we don’t.
123+
- If we pick a coin, we move on to solve a new subproblem based on the reduced total value.
124+
- If we don’t pick a coin, then we look up the answer from the counter array if it is already computed to avoid
125+
recalculation.
126+
4. Finally, we return the minimum number of coins required for the given total.
127+
128+
### Time Complexity
129+
130+
The time complexity for the above algorithm is O(n*m). Here,
131+
n represents the total and m represents the number of coins we have. In the worst case, the height of the recursive tree
132+
is n as the subproblems solved by the algorithm will be n because we're storing precalculated solutions in a table. Each
133+
subproblem takes m iterations, one per coin value. So, the time complexity is O(n*m).
134+
135+
### Space Complexity
136+
137+
The space complexity for this algorithm is O(n) because we’re using the counter array which is the size of total.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Finds the minimum number of coins that sum up to the total change given the total change due and the list of coins with
3+
denominations
4+
"""
5+
6+
from typing import List
7+
from itertools import combinations_with_replacement
8+
9+
10+
def find_minimum_coins(total_change: int, coins: List[int]) -> List[int]:
11+
"""
12+
Finds the minimum number coins that add up to the total change given a list of coins. This should return a list of
13+
the coins that add up to the total change
14+
:param total_change: Total change due
15+
:type total_change int
16+
:param coins: list of denominations
17+
:type coins list
18+
:return: list with the least amount of coins that sum up to the total change
19+
:rtype list
20+
"""
21+
# return early when there is no change
22+
if total_change == 0:
23+
return []
24+
25+
if total_change < 0:
26+
raise ValueError("Cannot find change of negative values")
27+
28+
if total_change < min(coins):
29+
raise ValueError(
30+
"Cannot find change if total change is smaller than smallest coin"
31+
)
32+
33+
result = None
34+
35+
for n in range(total_change):
36+
for combination in combinations_with_replacement(coins, n):
37+
if sum(combination) == total_change:
38+
return list(combination)
39+
if result is None:
40+
raise ValueError("No combination can add up to target")
41+
return []
42+
43+
44+
def coin_change(coins: List[int], total: int) -> int:
45+
if total == 0:
46+
return 0
47+
# Initialize dimensions: number of coin types and target amount
48+
num_coins = len(coins)
49+
50+
# Create 2D DP table
51+
# dp[i][j] represents minimum coins needed to make amount j using first i coin types
52+
dp = [[float("inf")] * (total + 1) for _ in range(num_coins + 1)]
53+
54+
# Base case: 0 coins needed to make amount 0
55+
dp[0][0] = 0
56+
57+
# Fill the DP table
58+
for coin_idx in range(1, num_coins + 1):
59+
current_coin_value = coins[coin_idx - 1]
60+
61+
for current_amount in range(total + 1):
62+
# Option 1: Don't use the current coin type
63+
dp[coin_idx][current_amount] = dp[coin_idx - 1][current_amount]
64+
65+
# Option 2: Use the current coin if possible
66+
if current_amount >= current_coin_value:
67+
# Compare with using one more of the current coin
68+
dp[coin_idx][current_amount] = min(
69+
dp[coin_idx][current_amount],
70+
dp[coin_idx][current_amount - current_coin_value] + 1,
71+
)
72+
73+
# Return result: -1 if impossible, otherwise the minimum number of coins
74+
return -1 if dp[num_coins][total] == float("inf") else dp[num_coins][total]
75+
76+
77+
def coin_change_dp(coins: List[int], total: int) -> int:
78+
if total < 1:
79+
return 0
80+
counter: List[int | float] = [float("inf")] * total
81+
82+
def calculate_minimum_coins(remaining_amount: int) -> int:
83+
if remaining_amount < 0:
84+
return -1
85+
if remaining_amount == 0:
86+
return 0
87+
if counter[remaining_amount - 1] != float("inf"):
88+
return counter[remaining_amount - 1]
89+
90+
minimum = float("inf")
91+
92+
for coin in coins:
93+
result = calculate_minimum_coins(remaining_amount - coin)
94+
if 0 <= result < minimum:
95+
minimum = 1 + result
96+
97+
counter[remaining_amount - 1] = minimum if minimum != float("inf") else -1
98+
return counter[remaining_amount - 1]
99+
100+
return calculate_minimum_coins(remaining_amount=total)
114 KB
Loading
56.9 KB
Loading
61.3 KB
Loading
83 KB
Loading
63.6 KB
Loading

0 commit comments

Comments
 (0)