{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Mobility and Smart Cities\n",
    "\n",
    "## TD1 Make Change\n",
    "\n",
    "### Objectives\n",
    "\n",
    "We have a list of coin and an amount of money to give back.\n",
    "\n",
    "A valid solution is a list of coins that sum to the amount.\n",
    "To be a perfect solution we want to give back the minimum number of coins.\n",
    "\n",
    "### Example\n",
    "\n",
    "We will base our tests with two example:\n",
    "\n",
    "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].\n",
    "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]."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Greedy algorithm\n",
    "\n",
    "#### Description\n",
    "\n",
    "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.\n",
    "So the order of the coins can be important.\n",
    "\n",
    "#### Implementation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "import time"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "   Exemple 1   \n",
      "Available coins: [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01] for 12.35 €\n",
      "==== Greedy Unsorted ====\n",
      "Result: [5, 5, 0.5, 0.5, 0.5, 0.5, 0.1, 0.1, 0.1, 0.05]\n",
      "Sum of coins: 12.35\n",
      "==== Greedy Sorted ====\n",
      "Result: [5, 5, 2, 0.2, 0.1, 0.05]\n",
      "Sum of coins: 12.35\n",
      "\n",
      "   Exemple 2   \n",
      "Available coins: [2, 5, 1.5] for 8 €\n",
      "==== Greedy Unsorted ====\n",
      "Result: [2, 2, 2, 2]\n",
      "Sum of coins: 8\n",
      "==== Greedy Sorted ====\n",
      "Cannot make exact change for 1.00€\n",
      "Result: [5, 2]\n",
      "Sum of coins: 7\n",
      "\n",
      "   Times   \n",
      "Time ex1 unsorted: 0.00007259s\n",
      "Time ex1 sorted: 0.00004450s\n",
      "Time ex2 unsorted: 0.00002494s\n",
      "Time ex2 sorted: 0.00003036s\n"
     ]
    }
   ],
   "source": [
    "# amount to change\n",
    "amount = 12.35\n",
    "\n",
    "# available coins in the cash register (do not take into account the number of coins available)\n",
    "available_coins = [5,0.5,0.1,2,1,0.2,0.05,0.02,0.01]\n",
    "\n",
    "# Greedy : function to make change with unsorted coins\n",
    "def make_change_unsorted(amount, coins):\n",
    "    i = 0\n",
    "    change = []\n",
    "    while amount > 0 and len(coins) > i:\n",
    "        # print(str(round(amount//coins[i])) + \" Coins of \" + str(coins[i]) + \"€\")\n",
    "        for j in range(round(amount//coins[i])):\n",
    "            change.append(coins[i])\n",
    "        amount = round(amount%coins[i],2)\n",
    "        i = i+1\n",
    "        \n",
    "    return change\n",
    "\n",
    "# Greedy : function to make change with sorted coins\n",
    "def make_change_sorted(amount, coins):\n",
    "    coins.sort(reverse=True)\n",
    "    i = 0\n",
    "    change = []\n",
    "\n",
    "    while amount > 0 and len(coins) > i:\n",
    "        # print(str(round(amount//coins[i])) + \" Coins of \" + str(coins[i]) + \"€\")\n",
    "        for j in range(round(amount//coins[i])):\n",
    "            change.append(coins[i])\n",
    "        amount = round(amount%coins[i], 2)\n",
    "        i = i+1\n",
    "    if amount > 0:\n",
    "        print(f\"Cannot make exact change for {amount:.2f}€\")\n",
    "    return change\n",
    "\n",
    "# Tests\n",
    "print(\"   Exemple 1   \")\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "start_unsorted = time.perf_counter()\n",
    "sol = make_change_unsorted(amount, available_coins)\n",
    "time_unsorted = time.perf_counter() - start_unsorted\n",
    "\n",
    "print(\"==== Greedy Unsorted ====\")\n",
    "print(f\"Result: {sol}\")\n",
    "print(f'Sum of coins: {sum(sol)}')\n",
    "\n",
    "print(\"==== Greedy Sorted ====\")\n",
    "\n",
    "start_sorted = time.perf_counter()\n",
    "sol = make_change_sorted(amount, available_coins)\n",
    "time_sorted = time.perf_counter() - start_sorted\n",
    "\n",
    "print(f\"Result: {sol}\")\n",
    "print(f'Sum of coins: {sum(sol)}')\n",
    "\n",
    "print(\"\")\n",
    "print(\"   Exemple 2   \")\n",
    "\n",
    "amount = 8\n",
    "available_coins = [2,5,1.5]\n",
    "\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "print(\"==== Greedy Unsorted ====\")\n",
    "start_ex2_unsorted = time.perf_counter()\n",
    "sol = make_change_unsorted(amount, available_coins)\n",
    "time_ex2_unsorted = time.perf_counter() - start_ex2_unsorted\n",
    "\n",
    "print(f\"Result: {sol}\")\n",
    "print(f'Sum of coins: {sum(sol)}')\n",
    "\n",
    "print(\"==== Greedy Sorted ====\")\n",
    "start_ex2_sorted = time.perf_counter()\n",
    "sol = make_change_sorted(amount, available_coins)\n",
    "time_ex2_sorted = time.perf_counter() - start_ex2_sorted\n",
    "\n",
    "print(f\"Result: {sol}\")\n",
    "print(f'Sum of coins: {sum(sol)}')\n",
    "\n",
    "print(\"\")\n",
    "print(\"   Times   \")\n",
    "# Print times\n",
    "print(f'Time ex1 unsorted: {time_unsorted:.8f}s')\n",
    "print(f'Time ex1 sorted: {time_sorted:.8f}s')\n",
    "print(f'Time ex2 unsorted: {time_ex2_unsorted:.8f}s')\n",
    "print(f'Time ex2 sorted: {time_ex2_sorted:.8f}s')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Analysis\n",
    "\n",
    "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.\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Generation of all the possible solutions\n",
    "\n",
    "#### Description\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Iterative implementation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  Exemple 1   \n",
      "Available coins: [5, 2, 1, 0.5, 0.2, 0.1, 0.05]\n",
      "Available coins: [5, 2, 1.5] for 8 €\n",
      "Minimum number of coins required: 6\n",
      "Combinations:\n",
      "[5, 5, 2, 0.2, 0.1, 0.05]\n",
      "\n",
      "  Exemple 2   \n",
      "Available coins: [5, 2, 1.5] for 8 €\n",
      "Minimum number of coins required: 3\n",
      "Combinations:\n",
      "[5, 1.5, 1.5]\n",
      "\n",
      "   Times   \n",
      "Time ex1: -6.94299234s\n",
      "Time ex2: -7.02674936s\n"
     ]
    }
   ],
   "source": [
    "# Function to calculate all combinations of coins to make a specific amount\n",
    "def calculate_change_combinations(coins, amount):\n",
    "    # Convert to integer by removing the decimal point\n",
    "    amount_cents = int(amount * 100)\n",
    "    coin_values_cents = [int(coin * 100) for coin in coins]\n",
    "\n",
    "    # Initialize a list to store combinations and their counts\n",
    "    combinations = []\n",
    "    list = [(0, [], 0)]  # (current amount in cents, current combination, current coin index)\n",
    "\n",
    "    while list:\n",
    "        current_amount, current_combination, current_coin_i = list.pop()\n",
    "\n",
    "        # If the current combination sums up to the target amount, add it to the list\n",
    "        if current_amount == amount_cents:\n",
    "            combinations.append(current_combination)\n",
    "\n",
    "        # If the amount is less than the target and that we have coins left to explore\n",
    "        elif current_amount < amount_cents and current_coin_i < len(coin_values_cents):\n",
    "            coin = coin_values_cents[current_coin_i]\n",
    "            max_count = (amount_cents - current_amount) // coin  # Maximum count of the current coin\n",
    "\n",
    "            # Try adding different counts of the current coin to explore possibilities\n",
    "            for count in range(max_count + 1):\n",
    "                new_amount = current_amount + count * coin\n",
    "                new_combination = current_combination + [coins[current_coin_i]] * count\n",
    "                # Push the new state onto the stack for further exploration\n",
    "                list.append((new_amount, new_combination, current_coin_i + 1))\n",
    "\n",
    "    return combinations\n",
    "\n",
    "# Tests\n",
    "\n",
    "print(\"  Exemple 1   \")\n",
    "\n",
    "coin_list = [5, 2, 1, 0.5, 0.2, 0.1, 0.05]\n",
    "print(\"Available coins:\", coin_list)\n",
    "change_amount = 12.35\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "# Call the function to calculate and display the combinations of coins for the given amount\n",
    "start_ex1= time.perf_counter()\n",
    "sol = calculate_change_combinations(coin_list, change_amount)\n",
    "time_ex1 = start_ex1 - time.perf_counter()\n",
    "\n",
    "# Find the minimum number of coins required to make the change\n",
    "min_coins = min([len(combination) for combination in sol])\n",
    "# Print the combinations with the minimum number of coins\n",
    "print(f\"Minimum number of coins required: {min_coins}\")\n",
    "print(\"Combinations:\")\n",
    "for combination in sol:\n",
    "    if len(combination) == min_coins:\n",
    "        print(combination)\n",
    "\n",
    "print(\"\")\n",
    "print(\"  Exemple 2   \")\n",
    "\n",
    "coin_list = [5, 2, 1.5]\n",
    "change_amount = 8\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "# Call the function to calculate and display the combinations of coins for the given amount\n",
    "start_ex2 = time.perf_counter()\n",
    "sol = calculate_change_combinations(coin_list, change_amount)\n",
    "time_ex2 = start_ex1 - time.perf_counter()\n",
    "\n",
    "# Find the minimum number of coins required to make the change\n",
    "min_coins = min([len(combination) for combination in sol])\n",
    "# Print the combinations with the minimum number of coins\n",
    "print(f\"Minimum number of coins required: {min_coins}\")\n",
    "print(\"Combinations:\")\n",
    "for combination in sol:\n",
    "    if len(combination) == min_coins:\n",
    "        print(combination)\n",
    "\n",
    "print(\"\")\n",
    "print(\"   Times   \")\n",
    "# Print times\n",
    "print(f'Time ex1: {time_ex1:.8f}s')\n",
    "print(f'Time ex2: {time_ex2:.8f}s')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Recursive Implementation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  Exemple 1   \n",
      "Available coins: [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01] with max coins: [2, 3, 4, 2, 3, 4, 2, 3, 4] for 12.35 €\n",
      "Valid combination: [5.0, 5.0, 0.1, 2.0, 0.2, 0.05]\n",
      "  Exemple 2   \n",
      "Available coins: [5, 2, 1.5] with max coins: [10, 10, 10] for 8 €\n",
      "Valid combination: [5.0, 1.5, 1.5]\n",
      "\n",
      "Time ex1: 0.16839738s\n",
      "Time ex2: 0.00175813s\n"
     ]
    }
   ],
   "source": [
    "def generates_all_combinations(amount, available_coins, max_coins):\n",
    "    # Convert the amount to cents (an integer)\n",
    "    amount_cents = int(amount * 100)\n",
    "\n",
    "    # Initialize a list to store all combinations\n",
    "    all_combinations = []\n",
    "\n",
    "    coins_list = []\n",
    "    for i in range(len(available_coins)):  # Iterate through available coin types\n",
    "        for j in range(max_coins[i]):  # Repeat each coin type based on max allowed\n",
    "            coins_list.append(int(available_coins[i] * 100))  # Convert coin values to cents\n",
    "\n",
    "    # Generate all combinations\n",
    "    for r in range(1, amount_cents + 1):\n",
    "        combinations_r = generate_combinations(coins_list, r, amount_cents)\n",
    "        if combinations_r:\n",
    "            # Convert combinations back to euros and cents\n",
    "            combinations_r_euros = [c / 100 for c in combinations_r[0]]\n",
    "            return combinations_r_euros\n",
    "\n",
    "    return all_combinations\n",
    "\n",
    "# Helper\n",
    "def generate_combinations(input_list, r, target_amount, current_combination=[]):\n",
    "    if r == 0:\n",
    "        if sum(current_combination) == target_amount:\n",
    "            return [current_combination]  # Return the valid combination\n",
    "        else:\n",
    "            return []  # Return an empty list for invalid combinations\n",
    "\n",
    "    if not input_list:\n",
    "        return []  # Base case: Return an empty list if input_list is empty\n",
    "\n",
    "    first, rest = input_list[0], input_list[1:]\n",
    "\n",
    "    # Generate combinations with the first element included\n",
    "    with_first = generate_combinations(rest, r - 1, target_amount, current_combination + [first])\n",
    "\n",
    "    # Generate combinations without the first element\n",
    "    without_first = generate_combinations(rest, r, target_amount, current_combination)\n",
    "\n",
    "    return with_first + without_first\n",
    "\n",
    "# Tests\n",
    "\n",
    "print(\"  Exemple 1   \")\n",
    "\n",
    "available_coins = [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01]\n",
    "max_coins = [2, 3, 4, 2, 3, 4, 2, 3, 4]\n",
    "amount = 12.35\n",
    "\n",
    "start_with_cut = time.perf_counter()\n",
    "combinations = generates_all_combinations(amount, available_coins, max_coins)\n",
    "time_with_cut = time.perf_counter() - start_with_cut\n",
    "\n",
    "print(\"Available coins:\", available_coins, \"with max coins:\", max_coins, \"for\", amount, \"€\")\n",
    "# Print the first valid combination found\n",
    "if combinations:\n",
    "    print(\"Valid combination:\", combinations)\n",
    "else:\n",
    "    print(\"No valid combination found.\")\n",
    "\n",
    "print(\"  Exemple 2   \")\n",
    "\n",
    "available_coins = [5, 2, 1.5]\n",
    "max_coins = [10, 10, 10]\n",
    "amount = 8\n",
    "\n",
    "print(\"Available coins:\", available_coins, \"with max coins:\", max_coins, \"for\", amount, \"€\")\n",
    "\n",
    "start_ex2_with_cut = time.perf_counter()\n",
    "combinations = generates_all_combinations(amount, available_coins, max_coins)\n",
    "time_ex2_with_cut = time.perf_counter() - start_ex2_with_cut\n",
    "\n",
    "# Print the first valid combination found\n",
    "if combinations:\n",
    "    print(\"Valid combination:\", combinations)\n",
    "else:\n",
    "    print(\"No valid combination found.\")\n",
    "\n",
    "# Print times\n",
    "print(\"\")\n",
    "print(f'Time ex1: {time_with_cut:.8f}s')\n",
    "print(f'Time ex2: {time_ex2_with_cut:.8f}s')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Analysis\n",
    "\n",
    "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."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Dynamic Programming\n",
    "\n",
    "#### Description\n",
    "\n",
    "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.\n",
    "\n",
    "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.\n",
    "\n",
    "[Wikipedia](https://en.wikipedia.org/wiki/Change-making_problem)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "  Exemple 1   \n",
      "Available coins: [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01] for 12.35 €\n",
      "Minimum number of coins needed: 6\n",
      "Coin combinations: [0.05, 0.2, 2.0, 0.1, 5.0, 5.0]\n",
      "  Exemple 2   \n",
      "Available coins: [5, 2, 1.5] for 8 €\n",
      "Minimum number of coins needed: 3\n",
      "Coin combinations: [1.5, 1.5, 5.0]\n",
      "\n",
      "Time ex1: 0.00191039s\n",
      "Time ex2: 0.00057598s\n"
     ]
    }
   ],
   "source": [
    "def _get_change_making_matrix(coins, target_amount):\n",
    "    # Initialize the matrix\n",
    "    num_coins = len(coins)\n",
    "    dp_matrix = [[0 for _ in range(target_amount + 1)] for _ in range(num_coins + 1)]\n",
    "\n",
    "    for i in range(target_amount + 1):\n",
    "        dp_matrix[0][i] = float('inf')  # By default, there is no way of making change\n",
    "\n",
    "    return dp_matrix\n",
    "\n",
    "def find_min_coins(coins, target_amount):\n",
    "    # to int :\n",
    "    coins = [int(coin * 100) for coin in coins]\n",
    "    target_amount = int(target_amount * 100)\n",
    "\n",
    "    dp_matrix = _get_change_making_matrix(coins, target_amount)\n",
    "\n",
    "    for c in range(1, len(coins) + 1):\n",
    "        for amount in range(1, target_amount + 1):\n",
    "            coin_value = coins[c - 1]\n",
    "\n",
    "            if coin_value == amount:\n",
    "                dp_matrix[c][amount] = 1\n",
    "            elif coin_value > amount:\n",
    "                dp_matrix[c][amount] = dp_matrix[c - 1][amount]\n",
    "            else:\n",
    "                without_this_coin = dp_matrix[c - 1][amount]\n",
    "                with_this_coin = 1 + dp_matrix[c][amount - coin_value]\n",
    "                if with_this_coin < without_this_coin:\n",
    "                    dp_matrix[c][amount] = with_this_coin\n",
    "                else:\n",
    "                    dp_matrix[c][amount] = without_this_coin\n",
    "\n",
    "    # Initialize a list to store the coin combinations\n",
    "    coin_combinations = []\n",
    "\n",
    "    # Backtrack to find the coin combinations\n",
    "    c, r = len(coins), target_amount\n",
    "    while c > 0 and r > 0:\n",
    "        if dp_matrix[c][r] == dp_matrix[c - 1][r]:\n",
    "            c -= 1\n",
    "        else:\n",
    "            coin_combinations.append(coins[c - 1])\n",
    "            r -= coins[c - 1]\n",
    "\n",
    "    # Divide the coin values by 100 to convert back to euros\n",
    "    coin_combinations = [coin / 100 for coin in coin_combinations]\n",
    "    return coin_combinations\n",
    "\n",
    "\n",
    "# Tests\n",
    "\n",
    "print(\"  Exemple 1   \")\n",
    "\n",
    "available_coins = [5, 0.5, 0.1, 2, 1, 0.2, 0.05, 0.02, 0.01]\n",
    "amount = 12.35\n",
    "\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "start_ex1 = time.perf_counter()\n",
    "result = find_min_coins(available_coins, amount)\n",
    "time_ex1 = time.perf_counter() - start_ex1\n",
    "\n",
    "if result is not None:\n",
    "    print(f\"Minimum number of coins needed: {len(result)}\")\n",
    "    print(f\"Coin combinations: {result}\")\n",
    "else:\n",
    "    print(\"It's not possible to make change for the given amount.\")\n",
    "\n",
    "\n",
    "print(\"  Exemple 2   \")\n",
    "\n",
    "available_coins = [5, 2, 1.5]\n",
    "amount = 8\n",
    "\n",
    "print(\"Available coins:\", available_coins, \"for\", amount, \"€\")\n",
    "\n",
    "start_ex2 = time.perf_counter()\n",
    "result = find_min_coins(available_coins, amount)\n",
    "time_ex2 = time.perf_counter() - start_ex2\n",
    "\n",
    "\n",
    "if result is not None:\n",
    "    print(f\"Minimum number of coins needed: {len(result)}\")\n",
    "    print(f\"Coin combinations: {result}\")\n",
    "else:\n",
    "    print(\"It's not possible to make change for the given amount.\")\n",
    "\n",
    "# Times\n",
    "\n",
    "print(\"\")\n",
    "print(f'Time ex1: {time_ex1:.8f}s')\n",
    "print(f'Time ex2: {time_ex2:.8f}s')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Analysis\n",
    "\n",
    "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."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.12"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
