Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 26

Optimizing Recursion with Memoization

Recommended resources

Hello! Welcome to your next lesson in our journey through recursion.

In the previous lesson, we demystified how recursive functions execute by tracing the call stack. You learned that each recursive call adds a new frame to the stack, and how a RecursionError can occur if the stack grows too deep, usually because of a missing base case.

We ended with a crucial observation: recursive functions can be inefficient because they often solve the same subproblem multiple times. Today, we'll tackle that exact problem. Your learning outcome for this lesson is to explain the concept of memoization to optimize overlapping subproblems in recursion. This is a foundational technique in competitive programming, often serving as a gateway to a more powerful paradigm called dynamic programming.

The Problem: Overlapping Subproblems

Many recursive solutions work by breaking a problem down into smaller, self-similar pieces. Sometimes, these pieces are not unique and reappear multiple times. We call these overlapping subproblems.

The classic example is calculating the Nth Fibonacci number. The rule is fib(n) = fib(n-1) + fib(n-2), with base cases fib(0) = 0 and fib(1) = 1. A naive recursive implementation in Python looks like this:

def fib(n):
    # Base cases
    if n <= 1:
        return n
    # Recursive step
    else:
        return fib(n - 1) + fib(n - 2)

This looks simple, but it's incredibly inefficient. To understand why, let's visualize the work it does.

DP 1. Introduction to Dynamic Programming | Memoization | Tabulation | Space Optimization Techniques

The video 'Introduction to Dynamic Programming' by take U forward provides an excellent visualization of the problem. Watch this first segment to see the recursive call tree for the Fibonacci sequence and understand what 'overlapping subproblems' look like in practice.

Watch from the beginning of the problem explanation at 02:23 until 12:39. Pay close attention to how many times the function is called with the same input, for example f(2).

As the video demonstrated, to compute fib(5), the function ends up computing fib(3) twice, fib(2) three times, and so on. The amount of redundant work grows exponentially, leading to a time complexity of roughly .

This recursive call tree visually confirms the redundant computations:

Fibonacci Recursive Call Tree Illustrating Overlapping Subproblems
This call tree for `F(5)` shows how subproblems like `F(3)` (highlighted in yellow) are computed multiple times in different branches of the recursion.

This is where memoization comes to the rescue.


The Solution: Memoization

Memoization (not to be confused with memorization) is an optimization technique where you store the results of expensive function calls and return the cached result when the same inputs occur again.

Think of it as creating a cheat sheet. The first time you're asked a difficult question, you work out the answer and write it down. The next time you're asked the exact same question, you don't need to re-calculate; you just look up the answer you already wrote down.

This flow diagram illustrates the logic:

Flow Diagram of Memoization Process
This diagram shows the core logic of memoization. For any function call, we first check a 'cache'. If the answer is there, we return it. If not, we compute the answer, store it in the cache for next time, and then return it.

Let's see how this simple idea dramatically prunes the recursive call tree.

DP 1. Introduction to Dynamic Programming | Memoization | Tabulation | Space Optimization Techniques

Let's return to the 'take U forward' video. This next segment explains how memoization avoids re-computation by storing results in a table or array.

Watch from 12:39 to 17:14. Notice how, once a value like f(3) is computed and stored, subsequent calls for f(3) become instant lookups.


From Recursion to Memoization: A Step-by-Step Guide

Converting a recursive function to use memoization involves a few straightforward steps. Let's apply them to our fib function.

A Python Guide to the Fibonacci Sequence

The article 'A Python Guide to the Fibonacci Sequence' from Real Python provides a very clear code example of this transformation. We will use it as our guide.

Read the section titled 'Memoizing the Recursive Algorithm'. Focus on the Python code block that introduces a cache dictionary. This demonstrates the practical implementation of the memoization logic.

As you saw in the article, we can implement memoization with these key modifications:

  1. Create a cache: Use a dictionary (or an array) to store the results. We'll initialize it with our base cases.
    cache = {0: 0, 1: 1}
    
  2. Check the cache: At the beginning of the function, before any computation, check if the result for the current input (n) is already in the cache. If it is, return it immediately.
    if n in cache:
        return cache[n]
    
  3. Compute and store: If the result is not in the cache, perform the recursive computation as before. Crucially, before returning the result, store it in the cache.
    # Compute the result
    result = fib_memo(n - 1) + fib_memo(n - 2)
    # Store it in the cache
    cache[n] = result
    # Return the result
    return result
    

Here is the complete memoized function:

# A cache to store computed Fibonacci numbers
cache = {0: 0, 1: 1}

def fib_memo(n):
    # 1. Check if the value is already in the cache
    if n in cache:
        return cache[n]

    # 2. If not, compute it recursively
    result = fib_memo(n - 1) + fib_memo(n - 2)

    # 3. Store the result in the cache before returning
    cache[n] = result
    
    return result

# Let's test it
print(f"The 40th Fibonacci number is: {fib_memo(40)}")

If you were to run the original naive fib(40), it would take a very long time. With fib_memo(40), the result is nearly instantaneous.


Analyzing the Improvement: Trading Space for Time

By adding the cache, we've made a classic engineering trade-off.

  • Time Complexity: We now compute the value for each fib(k) (where k is from n down to 2) only once. This reduces the time complexity from the exponential to a much more efficient linear .
  • Space Complexity: We pay a price for this speed. We now need extra space for the cache, which will store up to n values. Additionally, the call stack will still go n levels deep in the worst case. Therefore, our space complexity is .

This trade-off—using more memory to drastically reduce execution time—is at the heart of many efficient algorithms.

Test your understanding!

Would memoization be an effective optimization for the recursive factorial(n) function we studied in the last lesson? Why or why not?

def factorial(n):
    if n <= 1:
        return 1
    else:
        return n * factorial(n - 1)
Show answer

No, memoization would not be effective for factorial(n).

The reason is that factorial does not have overlapping subproblems. To calculate factorial(5), you call factorial(4), which calls factorial(3), and so on. You never call the function with the same argument more than once in a single top-level computation. Memoization only provides a benefit when you would otherwise be re-computing the same work.


An Important Caveat: Pure Functions

Memoization is a powerful tool, but it should only be applied to pure functions. A pure function has two main properties:

  1. Deterministic: It always returns the same output for the same set of inputs. fib(5) will always be 5.
  2. No Side Effects: It doesn't modify any state outside its own scope (e.g., it doesn't change global variables, print to the console, or write to a file).

Chapter 7 - Memoization and Dynamic Programming

The article 'Memoization and Dynamic Programming' from 'Invent with Python' explains this concept clearly. It's an important detail for writing correct and bug-free code.

First, read the section 'Memoization in Functional Programming' to understand the definition of pure functions, side effects, and determinism. Then, jump to the section 'What Happens When You Memoize Impure Functions?' to see concrete examples of the bugs that can arise.

If you memoize an impure function, you might get incorrect, stale data, or you might unintentionally skip important side effects like logging or screen output. Always ensure your function is pure before you decide to memoize it.


Conclusion

In this lesson, you've learned a critical optimization technique for recursive algorithms. By understanding the problem of overlapping subproblems, you can now see why a simple recursive solution can be inefficient and how to fix it.

Key Takeaways:

  • Overlapping Subproblems: This occurs when a recursive algorithm ends up solving the exact same subproblem multiple times.
  • Memoization: A technique to optimize for this by storing (caching) the results of function calls and returning the cached value when the same input occurs again.
  • Implementation: You can implement memoization by adding a cache (like a dictionary), checking it at the start of the function, and storing the result before returning.
  • Time-Space Trade-off: Memoization dramatically improves time complexity (e.g., from to for Fibonacci) at the cost of increased space complexity (for the cache and call stack).
  • Pure Functions: Memoization should only be used on pure functions to avoid bugs.

Up Next:
You now understand the "what" and "why" of memoization. In the next lesson, we'll solidify this knowledge by focusing on the "how". You'll get more hands-on practice applying the memoization pattern to recursive solutions, turning this powerful concept into a practical skill for your competitive programming toolkit.