You are given an integer array coins representing coins of different denominations and counts representing number of coins available of each denomination and an integer amount representing a total amount of money.
So, to summarise, you are given:
coins: An integer array representing coins of different denominations.
counts: An integer array where counts[i] represents the maximum number of coins available for the denomination coins[i].
amount: An integer representing the total amount of money to form.
The goal is to determine and return the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.
The naive recursive approach explores all possible combinations of coins to make up the target amount, considering their limited counts. It uses brute force by trying to use 0, 1, ..., counts[i] coins for each denomination recursively.
⏰ Time complexity: O(n * max_count ^ n) where n is the number of denominations and max_count is the maximum coins available for a single denomination. This is because for each denomination, we recursively explore counts[i] ways for n coins.
🧺 Space complexity: O(n). Due to the recursive stack depth corresponding to the number of denominations.
This approach uses recursion with memoisation to avoid redundant computations and store intermediate results. The memoisation ensures that we solve subproblems only once and reuse the results, significantly optimising the computation compared to the naive recursive approach.
classSolution:
defcoinChangeTopDown(self, coins, counts, amount):
# Memoisation dictionary memo = {}
defdp(n, remaining):
# Base casesif remaining ==0:
return0# No coins neededif n ==0or remaining <0:
return float('inf') # Impossible to form the amount# Check memoisation tableif (n, remaining) in memo:
return memo[(n, remaining)]
# Minimum number of coins to form 'remaining' using coins[:n] minCoins = float('inf')
for k in range(0, counts[n -1] +1):
result = dp(n -1, remaining - k * coins[n -1])
if result != float('inf'): # Valid result minCoins = min(minCoins, result + k)
# Store the result in memo table memo[(n, remaining)] = minCoins
return minCoins
# Solve the problem result = dp(len(coins), amount)
return result if result != float('inf') else-1# Example Usage:coins = [1, 2, 5]
counts = [5, 2, 1]
amount =11solution = Solution()
print(solution.coinChangeTopDown(coins, counts, amount)) # Output: 3
⏰ Time complexity: O(n * amount * max_count), where N is the number of denominations, amount is the total target value, and max_count is the maximum count of coins available for any denomination.
🧺 Space complexity: O(n * amount). For the memoisation table and recursive stack.
Dynamic Programming Extension:
Instead of considering unlimited coins, we restrict the count of available coins. Each coin denomination has limited availability, necessitating modifications to the standard Knapsack problem.
The problem can be addressed using a DP approach by maintaining states for possible amounts and optimising for the fewest coins while verifying constraints on counts.
Valid States:
For each denomination:
Either do not use it, or
Use the denomination within its count limit.
Final Check:
If the amount cannot be formed, return -1.
Initialisation:
Create an array dp of size (amount + 1) where dp[i] denotes the minimum coins required to form amount i. Initialise all entries to infinity (float('inf')), except dp[0] = 0, which means no coins are needed for amount 0.
Transition:
For each denomination coins[i]:
Simulate usage of 1 to counts[i] coins of this denomination.
⏰ Time complexity: O(n * A * max_count) where n is the number of coin denominations, A is the amount that is the given target, and max_count is the maximum count in the counts array.
🧺 Space complexity: O(A), as we use a 1D DP array.