diff --git a/Sprint-2/improve_with_caches/CHANGES_MADE.md b/Sprint-2/improve_with_caches/CHANGES_MADE.md new file mode 100644 index 0000000..4623d9f --- /dev/null +++ b/Sprint-2/improve_with_caches/CHANGES_MADE.md @@ -0,0 +1,26 @@ +# Changes Made: Optimization via Memoisation + +## Overview +The goal of these changes was to improve the performance of recursive functions that were previously performing redundant calculations. By introducing a manual cache (memoisation), the time complexity was reduced from exponential to linear. + +## 1. Fibonacci Optimization (`fibonacci.py`) +- **Implemented Memoisation**: Introduced a dictionary named `memo` to store the results of each Fibonacci term as it is calculated. +- **Improved Complexity**: + - **Before**: $O(2^n)$ (Exponential) - The function recalculated the same branches of the recursion tree millions of times. + - **After**: $O(n)$ (Linear) - Each term is calculated exactly once and then retrieved from the cache. +- **Readability**: Renamed the parameter `n` to `term_index` to more clearly describe that the function is looking for a specific position in a sequence. + +## 2. Making Change Optimization (`making_change.py`) +- **Recursive Helper Pattern**: Refactored the original iterative-recursive logic into a dedicated helper function (`ways_to_make_change_helper`) to better support memoisation. +- **State Tracking**: + - Created a **state key** using a Tuple: `(total, len(coins))`. + - **The "Why"**: A unique solution depends on both the amount of money left and which coins are still available. A tuple is used because it is immutable and can be used as a dictionary key. +- **Logic Refinement**: + - Updated the base case to return `1` when `total == 0`, representing a successful combination. + - Added `memo.clear()` in the wrapper function to ensure the cache is fresh for every new call to the main function. +- **Legacy Preservation**: Maintained original variable names (`coin`, `count_of_coin`, `intermediate`) while implementing the performance improvements. + +## 3. Technical Trade-offs: Space vs. Time +In both implementations, I applied the **Space-vs-Time trade-off**: +- **The Cost (Space)**: Increased memory usage to store the `memo` dictionary. +- **The Benefit (Time)**: Drastic reduction in execution time. For example, `ways_to_make_change(9176)` now returns a result instantly, whereas the unoptimised version would likely never finish on standard hardware. \ No newline at end of file diff --git a/Sprint-2/improve_with_caches/fibonacci/fibonacci.py b/Sprint-2/improve_with_caches/fibonacci/fibonacci.py index 60cc667..d7deec1 100644 --- a/Sprint-2/improve_with_caches/fibonacci/fibonacci.py +++ b/Sprint-2/improve_with_caches/fibonacci/fibonacci.py @@ -1,4 +1,25 @@ -def fibonacci(n): - if n <= 1: - return n - return fibonacci(n - 1) + fibonacci(n - 2) +from typing import Dict + +# 1. Create a dictionary to hold answers (the cache) +memo: Dict[int, int] = {} + +def fibonacci(term_index: int) -> int: + """ + Calculate the term_indexth Fibonacci number using memoisation. + + Time Complexity: O(n) - Each number up to n is calculated only once. + Space Complexity: O(n) - To store the recursion stack and the memo dictionary. + """ + # 2. The "Check": Do we already have the answer for term_index? + if term_index in memo: + return memo[term_index] + + # 3. Base cases + if term_index <= 1: + return term_index + + # 4. The "Store": Calculate and save the answer in the dictionary + memo[term_index] = fibonacci(term_index - 1) + fibonacci(term_index - 2) + + # 5. Return the newly saved answer + return memo[term_index] \ No newline at end of file diff --git a/Sprint-2/improve_with_caches/making_change/making_change.py b/Sprint-2/improve_with_caches/making_change/making_change.py index 255612e..462de0d 100644 --- a/Sprint-2/improve_with_caches/making_change/making_change.py +++ b/Sprint-2/improve_with_caches/making_change/making_change.py @@ -1,32 +1,42 @@ -from typing import List +from typing import List, Dict, Tuple +# 1. Create the cache +memo: Dict[Tuple[int, int], int] = {} -def ways_to_make_change(total: int) -> int: - """ - Given access to coins with the values 1, 2, 5, 10, 20, 50, 100, 200, returns a count of all of the ways to make the passed total value. - - For instance, there are two ways to make a value of 3: with 3x 1 coins, or with 1x 1 coin and 1x 2 coin. - """ - return ways_to_make_change_helper(total, [200, 100, 50, 20, 10, 5, 2, 1]) +def ways_to_make_change_helper(total: int, coins: List[int]) -> int: + # Cache Key + state_key = (total, len(coins)) + # Cache Check + if state_key in memo: + return memo[state_key] -def ways_to_make_change_helper(total: int, coins: List[int]) -> int: - """ - Helper function for ways_to_make_change to avoid exposing the coins parameter to callers. - """ - if total == 0 or len(coins) == 0: + if total == 0: + return 1 + if total < 0 or len(coins) == 0: return 0 ways = 0 - for coin_index in range(len(coins)): - coin = coins[coin_index] - count_of_coin = 1 - while coin * count_of_coin <= total: - total_from_coins = coin * count_of_coin - if total_from_coins == total: - ways += 1 - else: - intermediate = ways_to_make_change_helper(total - total_from_coins, coins=coins[coin_index+1:]) - ways += intermediate - count_of_coin += 1 + # We take the first coin and pass the rest + coin = coins[0] + remaining_coins = coins[1:] + + count_of_coin = 0 + while (coin * count_of_coin) <= total: + total_from_coins = total - (coin * count_of_coin) + + intermediate = ways_to_make_change_helper(total_from_coins, remaining_coins) + ways += intermediate + + count_of_coin += 1 + + # Store Result + memo[state_key] = ways return ways + +def ways_to_make_change(total: int) -> int: + """Wrapper that matches the legacy test suite signature.""" + memo.clear() + + default_coins = [200, 100, 50, 20, 10, 5, 2, 1] + return ways_to_make_change_helper(total, default_coins) \ No newline at end of file