Fibonacci complexity
Generating the Fibonacci sequence is a classic early exercise in recursion and memoization. The recursive code for generating the sequence could hardly be simpler: it reads just like the definition of the Fibonacci sequence itself.
def fib(n):
  return n if n <= 1 else fib(n-1) + fib(n-2)
Then, by memoizing the function and counting the calls it makes to itself, we can see the benefits of memoization.
def fib_rec(n):
  fib_rec.counter += 1
  return n if n <= 1 else fib_rec(n-1) + fib_rec(n-2)
def fib_memo(n):
  fib_memo.counter += 1
  if n not in fibs:
    fibs[n] = fib_memo(n-1) + fib_memo(n-2)
  return fibs[n]
for i in range(11):
  fib_rec.counter = 0
  fib_memo.counter = 0
  fibs = { 0: 0, 1: 1 }
  result_rec = fib_rec(i)
  result_memo = fib_memo(i)
  assert(result_rec == result_memo)
  print("fib({}) = {}: Recursive calls: {}, memoized calls: {}"
    .format(i, result_rec, fib_rec.counter, fib_memo.counter))
This gives us the following output:
| n | fib(n) | Recursive calls | Memoized calls | 
|---|---|---|---|
| 0 | 0 | 1 | 1 | 
| 1 | 1 | 1 | 1 | 
| 2 | 1 | 3 | 3 | 
| 3 | 2 | 5 | 5 | 
| 4 | 3 | 9 | 7 | 
| 5 | 5 | 15 | 9 | 
| 6 | 8 | 25 | 11 | 
| 7 | 13 | 41 | 13 | 
| 8 | 21 | 67 | 15 | 
| 9 | 34 | 109 | 17 | 
| 10 | 55 | 177 | 19 | 
The memoized calls number is easy enough to explain: we make n-1 calls that
actually require inserting to fibs, plus an additional n calls that just
require lookup from fibs, for a total of 2n - 1 calls (0 being a special
case requires 0 inserts to fibs, not -1). Accounting for the number of
recursive calls is more interesting.
Calling fib_rec(n) involves one initial call, followed by calls to fib(n-1)
and fib(n-2), so we can write the number of calls \(\hat{f}(n)\) as
The Fibonacci sequence again! For completeness, let’s define our base cases. We know these don’t require any recursive function calls because we put those entries in the lookup table ourselves.
\[\hat{f}(0) = 1,\ \hat{f}(1) = 1\]Now, let’s express \(\hat{f}\), our count of calls required to compute a Fibonacci number, in terms of \(f\), the Fibonacci sequence itself.
\[\hat{f}(n) = 2 f(n+1) - 1\]We should be able to apply this same principle to other recurrence relations. As an example, let’s take the binomial formula in its recursive form.
\[\binom{n}{k} = \binom{n-1}{k-1} + \binom{n-1}{k}\]We can write a bit of Python to compute the number of calls required to compute various values of this function.
def choose(n, k):
  choose.counter += 1
  if k == 0 or k == n:
    return 1
  return choose(n-1, k-1) + choose(n-1, k)
for n in range(11):
  for k in range(n+1):
    choose.counter = 0
    print(choose(n, k), end=' ')
  print()
for n in range(11):
  for k in range(n+1):
    choose.counter = 0
    choose(n, k)
    print(choose.counter, end=' ')
  print()
This gives us two lopsided triangles.
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
1 6 15 20 15 6 1
1 7 21 35 35 21 7 1
1 8 28 56 70 56 28 8 1
1 9 36 84 126 126 84 36 9 1
1 10 45 120 210 252 210 120 45 10 1
1
1 1
1 3 1
1 5 5 1
1 7 11 7 1
1 9 19 19 9 1
1 11 29 39 29 11 1
1 13 41 69 69 41 13 1
1 15 55 111 139 111 55 15 1
1 17 71 167 251 251 167 71 17 1
1 19 89 239 419 503 419 239 89 19 1
The first is Pascal’s triangle, and we second we can see is
\[\widehat{\binom{n}{k}} = 2 \binom{n}{k} - 1\]This is remarkably similar to the Fibonacci formula from earlier! Not shocking, perhaps, but satisfying to see.