Hello! Welcome to your next lesson on recursion.
In our last session, we explored why a naive recursive function like one for the Fibonacci sequence is inefficient due to overlapping subproblems. We also introduced the concept of memoization—storing the results of expensive function calls to avoid re-computation.
Today, we move from theory to practice. Your learning outcome is to apply memoization to a recursive solution, such as calculating Fibonacci numbers. We'll focus on the hands-on implementation, transforming a slow recursive function into a highly efficient one. You'll learn the standard manual pattern for memoization and a powerful Python shortcut that you'll use frequently in competitive programming.
The Problem Revisited: Exponential Inefficiency
Let's start with a quick refresher on the problem we're solving. A simple recursive function for the Fibonacci sequence is elegant but slow.
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
As we discussed, this function's runtime grows exponentially. Calculating fib_naive(40) can take close to a minute! Why? Because it needlessly re-computes the same values over and over.
Recursion, the Fibonacci Sequence and Memoization || Python Tutorial || Learn Python Programming
The video 'Recursion, the Fibonacci Sequence and Memoization' from the Socratica channel provides a great visual demonstration of this inefficiency.
Watch from 02:36 to 03:48. This segment vividly illustrates how the recursive calls pile up and repeat themselves, making the function practically useless for larger numbers.
This explosion of repeated function calls is exactly what memoization is designed to prevent.
The Manual Approach: Memoizing with a Dictionary
Let's transform our fib_naive function into an efficient, memoized version step-by-step. The core idea is to use a cache—a dictionary is perfect for this—to store results as we compute them.
The pattern is:
- Check the cache: Before doing any work, see if the result for the current input is already stored. If yes, return it instantly.
- Compute and store: If the result isn't in the cache, compute it as you normally would. Then, right before returning, store the newly computed result in the cache.
A Python Guide to the Fibonacci Sequence
The article 'A Python Guide to the Fibonacci Sequence' on Real Python provides a clear implementation of this pattern. Let's study its code.
Read the section titled 'Memoizing the Recursive Algorithm'. Focus on the code block that introduces a cache dictionary and modifies the recursive function to use it. Pay attention to the three key steps: creating the cache, checking it, and updating it.
Here is the complete memoized function, incorporating the pattern you just read about:
# 1. Initialize a cache. A global variable or a class attribute can be used.
# Here, we'll pass it as a parameter for better encapsulation.
memo = {0: 0, 1: 1}
def fib_memo(n, cache):
# 2. Check if the result for `n` is already in the cache.
if n in cache:
return cache[n]
# 3. If not, compute the result recursively.
result = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
# 4. Store the result in the cache before returning.
cache[n] = result
return result
# Let's test it. This will be almost instantaneous.
print(f"The 40th Fibonacci number is: {fib_memo(40, memo)}")
# Output: The 40th Fibonacci number is: 102334155
Visualizing the Impact of Memoization
How does this simple change make such a drastic difference? With memoization, we prune entire branches of the recursion tree.

Now, let's see what happens to the call stack with memoization.
A Python Guide to the Fibonacci Sequence
The Real Python article 'A Python Guide to the Fibonacci Sequence' has a fantastic step-by-step visualization of the call stack for a memoized Fibonacci function. This will give you a concrete understanding of how the cache prevents redundant work.
Scroll down to the section 'Visualizing the Memoized Fibonacci Sequence Algorithm'. Follow the diagrams from step 1 to step 18. Pay special attention to steps 12 and 16, where the function needs F(2) and F(3). Notice how it gets the value directly from the cache instead of making more recursive calls.
As you saw, once a value like F(3) is computed and cached, any future call for F(3) is a simple dictionary lookup, which is extremely fast.
Complexity Analysis: The Time-Space Trade-off
By implementing memoization, we have fundamentally changed the algorithm's performance.
What Is Dynamic Programming and How To Use It
The video 'What Is Dynamic Programming and How To Use It' by CS Dojo provides one of the clearest explanations of the time complexity of a memoized Fibonacci function.
Watch the segment from 06:34 to 09:26. The analysis shows that the function for each number k (from n down to 1) is fully computed only once. This is the key to understanding the linear time complexity.
Let's summarize the trade-off:
- Time Complexity: We've reduced it from exponential to linear . Each Fibonacci number from 2 to
nis computed exactly once. - Space Complexity: This speed comes at a cost. We now require extra space for the cache, which will store
nkey-value pairs. The recursion depth can also go up ton. Therefore, the space complexity is .
This is a classic and highly favorable trade-off in algorithm design.
A Pythonic Shortcut: functools.lru_cache
Writing the manual memoization logic every time is good for understanding, but it's also repetitive. Python, with its "batteries included" philosophy, provides a more elegant solution: a decorator.
A decorator is a special function that can modify or enhance another function. The functools module gives us @lru_cache, which automatically wraps a function with memoization logic.
Chapter 7 - Memoization and Dynamic Programming
Let's explore this powerful feature. The 'Invent with Python' guide provides a concise explanation and example.
Read the section 'Python’s functools Module'. Notice how the code becomes much cleaner compared to the manual implementation.
Using this decorator, our fib function becomes incredibly clean:
import functools
@functools.lru_cache(maxsize=None) # maxsize=None means the cache can grow indefinitely
def fib_lru(n):
if n <= 1:
return n
return fib_lru(n - 1) + fib_lru(n - 2)
# The result is the same, and it's just as fast.
print(f"The 40th Fibonacci number is: {fib_lru(40)}")
# Output: The 40th Fibonacci number is: 102334155
The @lru_cache decorator handles the creation and management of the cache behind the scenes. In competitive programming, this is the preferred method for its speed of implementation and readability. "LRU" stands for "Least Recently Used," which is the strategy the cache uses to discard old entries if maxsize is set to a specific number.
Test your understanding!
A common competitive programming problem is the "climbing stairs" problem. You are climbing a staircase with n steps. You can climb either 1 or 2 steps at a time. In how many distinct ways can you climb to the top?
For example:
n=2: 2 ways (1+1, 2)n=3: 3 ways (1+1+1, 1+2, 2+1)
The recursive relation is ways(n) = ways(n-1) + ways(n-2).
Write a Python function climbing_stairs(n) that solves this problem efficiently using the @lru_cache decorator.
Show answer
import functools
@functools.lru_cache(maxsize=None)
def climbing_stairs(n):
# Base cases
if n < 0:
return 0
if n == 0:
return 1 # There is one way to stay at step 0 (do nothing)
# Recursive step (same as Fibonacci)
return climbing_stairs(n - 1) + climbing_stairs(n - 2)
# Let's test it for n=3
# climbing_stairs(3) = climbing_stairs(2) + climbing_stairs(1)
# climbing_stairs(2) = climbing_stairs(1) + climbing_stairs(0) -> 1 + 1 = 2
# climbing_stairs(1) = climbing_stairs(0) + climbing_stairs(-1) -> 1 + 0 = 1
# So, climbing_stairs(3) = 2 + 1 = 3. Correct!
print(f"Ways to climb 10 steps: {climbing_stairs(10)}")
# Expected output for n=10 is 89.
Note: The base cases can be defined in a few ways. if n <= 1: return 1 also works and is simpler if n is guaranteed to be non-negative.
Conclusion
Congratulations! You have now mastered a fundamental technique for optimizing recursive algorithms. By moving from concept to practical application, you've added a powerful tool to your problem-solving arsenal.
Key Takeaways:
- Manual Memoization Pattern: You can implement memoization by creating a cache (like a dictionary), checking it at the start of a function call, and storing the result before returning.
- Performance Gain: Memoization transforms exponential-time problems with overlapping subproblems into linear-time solutions, making them feasible to solve.
- The Pythonic Way: Python's
@functools.lru_cachedecorator provides a clean, efficient, and robust way to memoize any pure function with a single line of code.
Up Next:
This technique of solving a problem by breaking it down and remembering the solutions to subproblems is the essence of Dynamic Programming (DP). Memoization is often called the "top-down" approach to DP. In the next module, we will officially begin our study of Dynamic Programming, where you will learn to identify problems suitable for DP and explore the "bottom-up" or tabulation approach, which is the other side of the DP coin.