#!/usr/bin/env python
# coding: utf-8

# # Mobility and Smart Cities
# 
# ## TD1 Make Change
# 
# ### Objectives
# 
# We have a list of coin and an amount of money to give back.
# 
# A valid solution is a list of coins that sum to the amount.
# To be a perfect solution we want to give back the minimum number of coins.
# 
# ### Example
# 
# We will base our tests with two example:
# 
# 1. We have the following coins [5,2,1,0.5,0.2,0.1,0.05,0.02,0.01] and we want to give back 12.35. The best solution is [5,5,2,0.2,0.1,0.05].
# 2. We have the following coins [5,2,1.5] and we want to give back 8. The best solution is [5,1.5,1.5].

# ### Greedy algorithm
# 
# #### Description
# 
# The greedy algorithm is to give back the biggest coin possible at each step. It will take the best solution at each step without taking into account what's next.
# So the order of the coins can be important.
# 
# #### Implementation

# In[30]:


import time


# In[31]:


# amount to change
amount = 12.35

# available coins in the cash register (do not take into account the number of coins available)
available_coins = [5,0.5,0.1,2,1,0.2,0.05,0.02,0.01]

# Greedy : function to make change with unsorted coins
def make_change_unsorted(amount, coins):
    i = 0
    change = []
    while amount > 0 and len(coins) > i:
        # print(str(round(amount//coins[i])) + " Coins of " + str(coins[i]) + "€")
        for j in range(round(amount//coins[i])):
            change.append(coins[i])
        amount = round(amount%coins[i],2)
        i = i+1
        
    return change

# Greedy : function to make change with sorted coins
def make_change_sorted(amount, coins):
    coins.sort(reverse=True)
    i = 0
    change = []

    while amount > 0 and len(coins) > i:
        # print(str(round(amount//coins[i])) + " Coins of " + str(coins[i]) + "€")
        for j in range(round(amount//coins[i])):
            change.append(coins[i])
        amount = round(amount%coins[i], 2)
        i = i+1
    if amount > 0:
        print(f"Cannot make exact change for {amount:.2f}€")
    return change

# Tests
print("   Exemple 1   ")
print("Available coins:", available_coins, "for", amount, "€")

start_unsorted = time.perf_counter()
sol = make_change_unsorted(amount, available_coins)
time_unsorted = time.perf_counter() - start_unsorted

print("==== Greedy Unsorted ====")
print(f"Result: {sol}")
print(f'Sum of coins: {sum(sol)}')

print("==== Greedy Sorted ====")

start_sorted = time.perf_counter()
sol = make_change_sorted(amount, available_coins)
time_sorted = time.perf_counter() - start_sorted

print(f"Result: {sol}")
print(f'Sum of coins: {sum(sol)}')

print("")
print("   Exemple 2   ")

amount = 8
available_coins = [2,5,1.5]

print("Available coins:", available_coins, "for", amount, "€")

print("==== Greedy Unsorted ====")
start_ex2_unsorted = time.perf_counter()
sol = make_change_unsorted(amount, available_coins)
time_ex2_unsorted = time.perf_counter() - start_ex2_unsorted

print(f"Result: {sol}")
print(f'Sum of coins: {sum(sol)}')

print("==== Greedy Sorted ====")
start_ex2_sorted = time.perf_counter()
sol = make_change_sorted(amount, available_coins)
time_ex2_sorted = time.perf_counter() - start_ex2_sorted

print(f"Result: {sol}")
print(f'Sum of coins: {sum(sol)}')

print("")
print("   Times   ")
# Print times
print(f'Time ex1 unsorted: {time_unsorted:.8f}s')
print(f'Time ex1 sorted: {time_sorted:.8f}s')
print(f'Time ex2 unsorted: {time_ex2_unsorted:.8f}s')
print(f'Time ex2 sorted: {time_ex2_sorted:.8f}s')


# #### Analysis
# 
# As we can see, the greedy is sometimes not able to reach a valid solution. It can get stuck close to the answer but not find it depending on the ordering of the coins.
# In the last example, we can find a valid solution: 8€ to change with 5, 2 and 1.5€ coins. But the greedy algorithm will give back 1 coins of 5€ and 1 coin of 2€. It will not find the solution as it does not explore the next step. Despite this behavior it manages to find the perfect solution in some cases, like in the first example when the coins are ordered from the biggest to the smallest.

# ### Generation of all the possible solutions
# 
# #### Description
# 
# To find the perfect solution, we can try to generate all the possible solutions and keep the best one. This way, we are sure to find the perfect solution.

# #### Iterative implementation

# In[32]:


# Function to calculate all combinations of coins to make a specific amount
def calculate_change_combinations(coins, amount):
    # Convert to integer by removing the decimal point
    amount_cents = int(amount * 100)
    coin_values_cents = [int(coin * 100) for coin in coins]

    # Initialize a list to store combinations and their counts
    combinations = []
    list = [(0, [], 0)]  # (current amount in cents, current combination, current coin index)

    while list:
        current_amount, current_combination, current_coin_i = list.pop()

        # If the current combination sums up to the target amount, add it to the list
        if current_amount == amount_cents:
            combinations.append(current_combination)

        # If the amount is less than the target and that we have coins left to explore
        elif current_amount < amount_cents and current_coin_i < len(coin_values_cents):
            coin = coin_values_cents[current_coin_i]
            max_count = (amount_cents - current_amount) // coin  # Maximum count of the current coin

            # Try adding different counts of the current coin to explore possibilities
            for count in range(max_count + 1):
                new_amount = current_amount + count * coin
                new_combination = current_combination + [coins[current_coin_i]] * count
                # Push the new state onto the stack for further exploration
                list.append((new_amount, new_combination, current_coin_i + 1))

    return combinations

# Tests

print("  Exemple 1   ")

coin_list = [5, 2, 1, 0.5, 0.2, 0.1, 0.05]
print("Available coins:", coin_list)
change_amount = 12.35
print("Available coins:", available_coins, "for", amount, "€")

# Call the function to calculate and display the combinations of coins for the given amount
start_ex1= time.perf_counter()
sol = calculate_change_combinations(coin_list, change_amount)
time_ex1 = start_ex1 - time.perf_counter()

# Find the minimum number of coins required to make the change
min_coins = min([len(combination) for combination in sol])
# Print the combinations with the minimum number of coins
print(f"Minimum number of coins required: {min_coins}")
print("Combinations:")
for combination in sol:
    if len(combination) == min_coins:
        print(combination)

print("")
print("  Exemple 2   ")

coin_list = [5, 2, 1.5]
change_amount = 8
print("Available coins:", available_coins, "for", amount, "€")

# Call the function to calculate and display the combinations of coins for the given amount
start_ex2 = time.perf_counter()
sol = calculate_change_combinations(coin_list, change_amount)
time_ex2 = start_ex1 - time.perf_counter()

# Find the minimum number of coins required to make the change
min_coins = min([len(combination) for combination in sol])
# Print the combinations with the minimum number of coins
print(f"Minimum number of coins required: {min_coins}")
print("Combinations:")
for combination in sol:
    if len(combination) == min_coins:
        print(combination)

print("")
print("   Times   ")
# Print times
print(f'Time ex1: {time_ex1:.8f}s')
print(f'Time ex2: {time_ex2:.8f}s')


# #### Recursive Implementation

# In[33]:


def generates_all_combinations(amount, available_coins, max_coins):
    # Convert the amount to cents (an integer)
    amount_cents = int(amount * 100)

    # Initialize a list to store all combinations
    all_combinations = []

    coins_list = []
    for i in range(len(available_coins)):  # Iterate through available coin types
        for j in range(max_coins[i]):  # Repeat each coin type based on max allowed
            coins_list.append(int(available_coins[i] * 100))  # Convert coin values to cents

    # Generate all combinations
    for r in range(1, amount_cents + 1):
        combinations_r = generate_combinations(coins_list, r, amount_cents)
        if combinations_r:
            # Convert combinations back to euros and cents
            combinations_r_euros = [c / 100 for c in combinations_r[0]]
            return combinations_r_euros

    return all_combinations

# Helper
def generate_combinations(input_list, r, target_amount, current_combination=[]):
    if r == 0:
        if sum(current_combination) == target_amount:
            return [current_combination]  # Return the valid combination
        else:
            return []  # Return an empty list for invalid combinations

    if not input_list:
        return []  # Base case: Return an empty list if input_list is empty

    first, rest = input_list[0], input_list[1:]

    # Generate combinations with the first element included
    with_first = generate_combinations(rest, r - 1, target_amount, current_combination + [first])

    # Generate combinations without the first element
    without_first = generate_combinations(rest, r, target_amount, current_combination)

    return with_first + without_first

# Tests

print("  Exemple 1   ")

available_coins = [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01]
max_coins = [2, 3, 4, 2, 3, 4, 2, 3, 4]
amount = 12.35

start_with_cut = time.perf_counter()
combinations = generates_all_combinations(amount, available_coins, max_coins)
time_with_cut = time.perf_counter() - start_with_cut

print("Available coins:", available_coins, "with max coins:", max_coins, "for", amount, "€")
# Print the first valid combination found
if combinations:
    print("Valid combination:", combinations)
else:
    print("No valid combination found.")

print("  Exemple 2   ")

available_coins = [5, 2, 1.5]
max_coins = [10, 10, 10]
amount = 8

print("Available coins:", available_coins, "with max coins:", max_coins, "for", amount, "€")

start_ex2_with_cut = time.perf_counter()
combinations = generates_all_combinations(amount, available_coins, max_coins)
time_ex2_with_cut = time.perf_counter() - start_ex2_with_cut

# Print the first valid combination found
if combinations:
    print("Valid combination:", combinations)
else:
    print("No valid combination found.")

# Print times
print("")
print(f'Time ex1: {time_with_cut:.8f}s')
print(f'Time ex2: {time_ex2_with_cut:.8f}s')


# #### Analysis
# 
# We manage to obtain a the perfect solution in both our implementation. As we have to generate all the combinations this takes a lot of time compare to the greedy algorithm. The time needed to compute the solutions will increase with the number of coins and the amount to change, therefore it is not a good solution for a real world problem as it will not scale.

# ### Dynamic Programming
# 
# #### Description
# 
# Dynamic programming is a common approach where we find combinations of smaller values to reach a target value. In this case, we're trying to make change using different types of coins. We consider all possible combinations of coins to reach the desired amount. This approach takes O(nW) steps, where n is the number of coin types.
# 
# It uses a matrix to store solutions to sub-problems and returns the minimum number of coins needed to make change. If it's not possible to make change with the given coins, it returns "Infinity." Another matrix can be used to find the specific coins for the optimal solution.
# 
# [Wikipedia](https://en.wikipedia.org/wiki/Change-making_problem)

# In[34]:


def _get_change_making_matrix(coins, target_amount):
    # Initialize the matrix
    num_coins = len(coins)
    dp_matrix = [[0 for _ in range(target_amount + 1)] for _ in range(num_coins + 1)]

    for i in range(target_amount + 1):
        dp_matrix[0][i] = float('inf')  # By default, there is no way of making change

    return dp_matrix

def find_min_coins(coins, target_amount):
    # to int :
    coins = [int(coin * 100) for coin in coins]
    target_amount = int(target_amount * 100)

    dp_matrix = _get_change_making_matrix(coins, target_amount)

    for c in range(1, len(coins) + 1):
        for amount in range(1, target_amount + 1):
            coin_value = coins[c - 1]

            if coin_value == amount:
                dp_matrix[c][amount] = 1
            elif coin_value > amount:
                dp_matrix[c][amount] = dp_matrix[c - 1][amount]
            else:
                without_this_coin = dp_matrix[c - 1][amount]
                with_this_coin = 1 + dp_matrix[c][amount - coin_value]
                if with_this_coin < without_this_coin:
                    dp_matrix[c][amount] = with_this_coin
                else:
                    dp_matrix[c][amount] = without_this_coin

    # Initialize a list to store the coin combinations
    coin_combinations = []

    # Backtrack to find the coin combinations
    c, r = len(coins), target_amount
    while c > 0 and r > 0:
        if dp_matrix[c][r] == dp_matrix[c - 1][r]:
            c -= 1
        else:
            coin_combinations.append(coins[c - 1])
            r -= coins[c - 1]

    # Divide the coin values by 100 to convert back to euros
    coin_combinations = [coin / 100 for coin in coin_combinations]
    return coin_combinations


# Tests

print("  Exemple 1   ")

available_coins = [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01]
amount = 12.35

print("Available coins:", available_coins, "for", amount, "€")

start_ex1 = time.perf_counter()
result = find_min_coins(available_coins, amount)
time_ex1 = time.perf_counter() - start_ex1

if result is not None:
    print(f"Minimum number of coins needed: {len(result)}")
    print(f"Coin combinations: {result}")
else:
    print("It's not possible to make change for the given amount.")


print("  Exemple 2   ")

available_coins = [5, 2, 1.5]
amount = 8

print("Available coins:", available_coins, "for", amount, "€")

start_ex2 = time.perf_counter()
result = find_min_coins(available_coins, amount)
time_ex2 = time.perf_counter() - start_ex2


if result is not None:
    print(f"Minimum number of coins needed: {len(result)}")
    print(f"Coin combinations: {result}")
else:
    print("It's not possible to make change for the given amount.")

# Times

print("")
print(f'Time ex1: {time_ex1:.8f}s')
print(f'Time ex2: {time_ex2:.8f}s')


# #### Analysis
# 
# Using this method, we are able to generate a perfect solution in a reasonable amount of time. It's faster than generating all the possible solutions.
