Memoization


In this video lesson we will learn about memoization and use it to improve the recursive fibonacci algorithm written in the previous lesson:


Problem - Fibonacci:

The Fibonacci numbers, commonly denoted F(n) form a sequence, called the Fibonacci sequence, such that each number is the sum of the two preceding ones, starting from 0 and 1. That is,

F(0) = 0, F(1) = 1
F(n) = F(n - 1) + F(n - 2), for n > 1.

Write a recursive function with memoization that given n, returns F(n).

Example 1:

Input: n = 2
Output: 1
Explanation: F(2) = F(1) + F(0) = 1 + 0 = 1.

Example 2:

Input: n = 3
Output: 2
Explanation: F(3) = F(2) + F(1) = 1 + 1 = 2.

Example 3:

Input: n = 4
Output: 3
Explanation: F(4) = F(3) + F(2) = 2 + 1 = 3.

Constraints:
  • 0 <= n <= 30
Note:

Your algorithm should run in O(n) time and use O(n) extra space.


Introduction

Memoization is a powerful optimization technique used to speed up recursive algorithms by storing the results of expensive function calls and reusing them when the same inputs occur again. This technique is particularly useful in dynamic programming and can significantly reduce the time complexity of algorithms.

One common scenario where memoization is beneficial is in computing the Fibonacci sequence. The naive recursive approach to calculate Fibonacci numbers has an exponential time complexity, making it inefficient for large inputs. By using memoization, we can improve the time complexity to linear, making the algorithm much more efficient.

Understanding the Basics

The fundamental concept of memoization involves storing the results of function calls in a data structure (usually a dictionary or an array) and checking this data structure before making a new function call. If the result is already computed, it is returned immediately, avoiding redundant calculations.

Let's start with a simple example to illustrate the concept:

# Naive recursive approach to calculate Fibonacci numbers
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Example usage
print(fibonacci(5))  # Output: 5

In this naive approach, the function makes redundant calls, leading to an exponential time complexity. We can optimize this using memoization.

Main Concepts

To implement memoization, we need to follow these steps:

  1. Create a data structure to store the results of function calls.
  2. Modify the recursive function to check this data structure before making a new call.
  3. Store the result in the data structure before returning it.

Here is the modified Fibonacci function with memoization:

# Fibonacci with memoization
def fibonacci_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

# Example usage
print(fibonacci_memo(5))  # Output: 5

In this implementation, we use a dictionary memo to store the results of function calls. Before making a new call, we check if the result is already in the dictionary. If it is, we return it immediately. Otherwise, we compute the result, store it in the dictionary, and then return it.

Examples and Use Cases

Let's look at a few more examples to understand how memoization works in different contexts:

# Example 1: Fibonacci of 2
print(fibonacci_memo(2))  # Output: 1

# Example 2: Fibonacci of 3
print(fibonacci_memo(3))  # Output: 2

# Example 3: Fibonacci of 4
print(fibonacci_memo(4))  # Output: 3

In each of these examples, the function efficiently computes the Fibonacci number by reusing previously computed results, avoiding redundant calculations.

Common Pitfalls and Best Practices

When using memoization, it's important to be aware of common pitfalls and follow best practices:

Advanced Techniques

Once you are comfortable with basic memoization, you can explore advanced techniques such as:

Code Implementation

Here is the complete code implementation of the Fibonacci function with memoization:

# Fibonacci with memoization
def fibonacci_memo(n, memo=None):
    # Initialize memo dictionary if not provided
    if memo is None:
        memo = {}
    # Check if result is already computed
    if n in memo:
        return memo[n]
    # Base cases
    if n <= 1:
        return n
    # Compute the result and store it in memo
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

# Example usage
print(fibonacci_memo(5))  # Output: 5
print(fibonacci_memo(10)) # Output: 55

Debugging and Testing

When debugging and testing code with memoization, consider the following tips:

import unittest

class TestFibonacciMemo(unittest.TestCase):
    def test_fibonacci_memo(self):
        self.assertEqual(fibonacci_memo(0), 0)
        self.assertEqual(fibonacci_memo(1), 1)
        self.assertEqual(fibonacci_memo(2), 1)
        self.assertEqual(fibonacci_memo(3), 2)
        self.assertEqual(fibonacci_memo(4), 3)
        self.assertEqual(fibonacci_memo(10), 55)

if __name__ == '__main__':
    unittest.main()

Thinking and Problem-Solving Tips

When approaching problems related to memoization, consider the following strategies:

Conclusion

Memoization is a powerful technique that can significantly improve the efficiency of recursive algorithms. By storing and reusing results of expensive function calls, you can reduce time complexity and make your code more efficient. Mastering memoization and other dynamic programming techniques will help you tackle a wide range of problems in programming.

Keep practicing and exploring further applications of memoization to enhance your problem-solving skills.

Additional Resources

For further reading and practice problems related to memoization, check out the following resources: