algorithm
recursion
performance
optimization
computation

Execution algorithm recursively seeking lower number, is very slow

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

A common performance problem in recursive algorithms is exponential time complexity caused by recomputing the same subproblems many times. When a recursive function seeks a lower number (like computing Fibonacci, counting change, or finding minimum steps), naive recursion explores an exponential number of paths because overlapping subproblems are solved repeatedly. The fix is memoization or converting to an iterative dynamic programming approach.

The Problem: Exponential Recursion

Consider a classic example — computing the nth Fibonacci number:

python
1def fib(n):
2    if n <= 1:
3        return n
4    return fib(n - 1) + fib(n - 2)

This looks clean but is extremely slow. fib(50) makes over 12 billion recursive calls because each call branches into two more:

 
1fib(5)
2├── fib(4)
3│   ├── fib(3)
4│   │   ├── fib(2) ← computed again
5│   │   └── fib(1)
6│   └── fib(2)     ← computed again
7└── fib(3)          ← entire subtree recomputed
8    ├── fib(2)
9    └── fib(1)

The time complexity is O(2^n) because each call generates two more calls, and subproblems like fib(3) are recomputed exponentially many times.

Why Recursive Descent is Slow

The core issue is overlapping subproblems. The recursive function:

  1. Breaks the problem into smaller subproblems
  2. Solves the same subproblem multiple times from different call paths
  3. Has no memory of previous results

This pattern appears in many algorithms:

ProblemNaive RecursionWith Optimization
FibonacciO(2^n)O(n)
Coin changeO(S^n)O(n * S)
Minimum stepsO(k^n)O(n)
Edit distanceO(3^n)O(m * n)
KnapsackO(2^n)O(n * W)

Fix 1: Memoization (Top-Down DP)

Add a cache to store results of subproblems:

python
1from functools import lru_cache
2
3@lru_cache(maxsize=None)
4def fib(n):
5    if n <= 1:
6        return n
7    return fib(n - 1) + fib(n - 2)
8
9print(fib(50))  # Instant: 12586269025

Or with a manual dictionary:

python
1def fib(n, memo={}):
2    if n in memo:
3        return memo[n]
4    if n <= 1:
5        return n
6    memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
7    return memo[n]

Each subproblem is computed only once, reducing time complexity from O(2^n) to O(n).

Fix 2: Iterative Dynamic Programming (Bottom-Up)

Build the solution iteratively from the base case up:

python
1def fib(n):
2    if n <= 1:
3        return n
4    prev, curr = 0, 1
5    for _ in range(2, n + 1):
6        prev, curr = curr, prev + curr
7    return curr
8
9print(fib(50))  # 12586269025

Benefits over memoization:

  • No recursion stack overhead (no risk of stack overflow)
  • O(1) space when only the last few values are needed
  • Slightly faster due to no function call overhead

Real-World Example: Minimum Coin Change

Given coins [1, 5, 10, 25], find the minimum coins to make amount n:

python
1# SLOW: O(S^n) where S = number of coin denominations
2def min_coins_slow(coins, amount):
3    if amount == 0:
4        return 0
5    if amount < 0:
6        return float('inf')
7    return 1 + min(min_coins_slow(coins, amount - c) for c in coins)
8
9# FAST: O(n * S) with memoization
10from functools import lru_cache
11
12def min_coins_fast(coins, amount):
13    @lru_cache(maxsize=None)
14    def dp(remaining):
15        if remaining == 0:
16            return 0
17        if remaining < 0:
18            return float('inf')
19        return 1 + min(dp(remaining - c) for c in coins)
20
21    result = dp(amount)
22    return result if result != float('inf') else -1
23
24# FAST: O(n * S) iterative
25def min_coins_iter(coins, amount):
26    dp = [float('inf')] * (amount + 1)
27    dp[0] = 0
28    for i in range(1, amount + 1):
29        for coin in coins:
30            if coin <= i and dp[i - coin] + 1 < dp[i]:
31                dp[i] = dp[i - coin] + 1
32    return dp[amount] if dp[amount] != float('inf') else -1

How to Identify the Problem

Signs that your recursion has overlapping subproblems:

  1. The function calls itself multiple times with arguments that decrease toward a base case
  2. Different call paths reach the same arguments — draw the call tree and look for duplicates
  3. Runtime grows exponentially with input size — n=20 is fast, n=40 takes minutes, n=50 never finishes

Profiling

python
1import time
2
3for n in [10, 20, 30, 35, 40]:
4    start = time.time()
5    fib_slow(n)
6    elapsed = time.time() - start
7    print(f"fib({n}): {elapsed:.3f}s")
8
9# fib(10): 0.000s
10# fib(20): 0.002s
11# fib(30): 0.198s
12# fib(35): 2.154s
13# fib(40): 23.71s  ← exponential growth!

Fix 3: Tail Recursion (Language-Dependent)

Some languages optimize tail-recursive calls. Python does not, but here is the pattern:

python
1# Tail-recursive style (conceptual — Python doesn't optimize this)
2def fib_tail(n, a=0, b=1):
3    if n == 0:
4        return a
5    return fib_tail(n - 1, b, a + b)

In languages like Scheme, Haskell, or Scala, this compiles to an iterative loop automatically.

Common Pitfalls

  • Mutable default arguments in Python: Using memo={} as a default parameter shares the cache across calls, which is usually what you want but can cause issues if the function is called with different problem contexts. Use @lru_cache instead.
  • Stack overflow with deep recursion: Even with memoization, Python's default recursion limit is 1000. For large inputs, either increase it with sys.setrecursionlimit() or use the iterative approach.
  • Not recognizing overlapping subproblems: Not every recursive algorithm benefits from memoization. If subproblems do not overlap (like merge sort), memoization adds overhead without benefit.
  • Space complexity: Memoization caches all subproblem results. For problems where you only need the last few values (like Fibonacci), the iterative approach with O(1) space is better.

Summary

  • Naive recursion that seeks lower numbers often has exponential O(2^n) or O(k^n) complexity due to overlapping subproblems
  • Memoization (@lru_cache or dictionary) reduces this to polynomial time by caching results
  • Iterative DP (bottom-up) avoids recursion overhead and often uses less memory
  • Draw the recursion tree to identify if subproblems are being recomputed
  • If runtime doubles each time input grows by 1, you have an exponential recursion problem

Course illustration
Course illustration

All Rights Reserved.