Python

Understand Memoization - A Caching Technique with Python

Memoization is a technique used in computer programming to reduce the execution time of programs. The term memoization is based on a Latin word memorandum which means "to be remembered". This technique is frequently used in dynamic programming.

memoization with python example

In the memoization, the result of a function call is saved in memory after it is invoked the first time with a certain set of input arguments. The next time you invoke the same function with the same input arguments, you can use the result from memory.

Consider the below pseudo-code examples:

# example -1 
function expensive_f(x):
  # Heavy computations code here
  -----
  -----

  return result

Equivalent pseudo-code with memoization technique:

# Example -2
memo = {}  # hash/dictionary/Associative-array
function expensive_f(x):
  if x in memo:

    return memo[x]
  else:
    # Heavy computations code here
    ------
    ------
   memo[x] = result 

    return result

In the example-1, function expensive_f() is performing (assumed) some heavy computations and returns the result. If this function gets invoked multiple times with the same x argument value, then each time it will repeat the computations and will return the result. Calling the function expensive_f(5) 100’s times will do the same computations again and again, and return the same result. Recursion is one of the most common examples where such repetitive function calls are made in a program

In the second example (example-2), the memoization concept is implemented by defining a memo variable (a hash or dictionary). Apparently, during each function call, expensive_f() checks if there is any previous result stored in the memo for an input argument,  if it exists then returns the result from the memo itself. Otherwise, it performs computations and stores the result in the memo.

 

Recursion with Memoization

You may already know that - "When a function calls itself, it is known as recursion, and such function calls repeat itself till a termination condition is met.

def rec_f(n):
	if n >= 1:
		rec_f(n)
		print(n)

In the above example, you can see that the function rec_f() is getting called by itself. This is an example of recursion, and rec_f() is a recursive function.

There are many places where recursion technique is used, for example - calculation of Fibonacci series, factorial, etc.  Let's see an example of Python function to find the Fibonacci of a given number using memoization and without using memoization concepts.

# no_memoize.py : Example-1 
# Recursive function recursive_fibo()
def recursive_fibo(n):
   if n <= 1:

       return n
   else:

       return(recursive_fibo(n-1) + recursive_fibo(n-2))

print(recursive_fibo(30))

In the above example, function recursive_fibo() uses a very common logic to calculate the Fibonacci series of a number. Executing this program with the cProfile module shows that it took 0.889 seconds to complete and made a total of 2692541 function calls (see below).

╰─ python -m cProfile no_memoize.py

832040
         2692541 function calls (5 primitive calls) in 0.889 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.889    0.889 no_memoize.py:1(<module>)
2692537/1    0.889    0.000    0.889    0.889 no_memoize.py:1(recursive_fibo)
        1    0.000    0.000    0.889    0.889 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.print}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

The below function recursive_fibo() is a new version of the same function defined in example-1 with additional memoization logic included.

# memoize.py : Example-2 
# Recursive function recursive_fibo() with Memoization
memo ={}
def recursive_fibo(n):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    else:
        result = recursive_fibo(n-1) + recursive_fibo(n-2)
        memo[n] = result
    
    return result

print(recursive_fibo(30))


Here is profiled output of the program :

╰─ python -m cProfile memoize.py
832040
         63 function calls (5 primitive calls) in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 memoize.py:1(<module>)
     59/1    0.000    0.000    0.000    0.000 memoize.py:2(recursive_fibo)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {built-in method builtins.print}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

Apparently, cProfile shows that this time the program took 0.000 seconds (perhaps only a few microseconds) and made only 63 function calls during entire execution of the program. This drastic change in execution time and function calls are possible because of memoization techniques.

 

Memoization with Python Function Decorator

Python provides mechanisms to automatically memoize functions and decorator is an amazing feature that is very useful for easy implementation of memoization techniques. Decorators can change how the function behaves, without needing to actually change the original code. Common use cases of decorators are - adding logging, caching, authentication, etc.

A decorator is just a callable that takes a function (mostly) or a class as an argument and returns a function/class object. Decorators enhance the readability and maintainability of a program. This post is not about Python decorators so I am not going deep into what they are and their types or how they get invoked.

# Recursive function recursive_fibo() with Memoization using decorator

def memoize(f):
    memo = {}
    
    def wrapper(x):
        if x not in memo:            
            memo[x] = f(x)
            print('New result for recursive_fibo({})'.format(x))
        else:
            print('Result from cache : recursive_fibo({})'.format(x))
        
        return memo[x]
    
    return wrapper

@memoize
def recursive_fibo(n):  
    if n <= 1:
        return n
    else:
        return recursive_fibo(n-1) + recursive_fibo(n-2)

print(recursive_fibo(8))

Output:

New result for recursive_fibo(1)
New result for recursive_fibo(0)
New result for recursive_fibo(2)
Result from cache : recursive_fibo(1)
New result for recursive_fibo(3)
Result from cache : recursive_fibo(2)
New result for recursive_fibo(4)
Result from cache : recursive_fibo(3)
New result for recursive_fibo(5)
Result from cache : recursive_fibo(4)
New result for recursive_fibo(6)
Result from cache : recursive_fibo(5)
New result for recursive_fibo(7)
Result from cache : recursive_fibo(6)
New result for recursive_fibo(8)
21

In the above example, use of the @memoize decorator has made it easy to separate the actual logic of the function recursive_fibo from its memorization implementation.

 

Conclusion

  • Memoization is a great technique that can make the code execute faster when you have repetitive/recursive function calls with the same set of arguments.
  • A prerequisite for a function to be memoized is that it must be pure, which means it must always return the same value whenever it gets called with the same set of arguments.

Share post

  •  
  •  
  •  
  •  

Tilak S.

Technology freak, Open Source lover. Someone trying to understand many things. Wants to make a difference. Life liver and Peripatetic.

Other posts you might like