Recursion and Invariants

Hide code cell content

import mmf_setup; mmf_setup.nbinit()
import logging
logging.getLogger("matplotlib").setLevel(logging.CRITICAL)
%matplotlib inline
import numpy as np, matplotlib.pyplot as plt

This cell adds /home/docs/checkouts/readthedocs.org/user_builds/wsu-phys-581-computation/checkouts/latest/src to your path, and contains some definitions for equations and some CSS for styling the notebook. If things look a bit strange, please try the following:

  • Choose "Trust Notebook" from the "File" menu.
  • Re-execute this cell.
  • Reload the notebook.

Recursion and Invariants#

Consider the famous Fibonacci numbers defined by

\[\begin{align*} F_0 &= 0,\\ F_1 &= 1,\\ F_{n} &= F_{n-1} + F_{n-2}, \qquad \text{for } n>1. \end{align*}\]

We code this directly in terms of recursion:

def fib0(n):
    """Return the n'th Fibonnacci number."""
    if n == 0:
        return 0
    if n == 1:
        return 1
    return fib0(n-1) + fib0(n-2)

[fib0(n) for n in range(15)]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]

If you try timing these, however, you will notice a disturbing trend:

Hide code cell source

from timeit import timeit
ns = np.arange(12)
number = 20
ts = np.array(
    [timeit("fib(n)", globals=dict(fib=fib0, n=n), number=number)*1000/number 
     for n in ns])

fig, axs = plt.subplots(1, 2, figsize=(8,3), constrained_layout=True)
for ax in axs:
    ax.plot(ns, ts, '+-')
    ax.set(xlabel="$n$", ylabel="$t$ [ms]")
axs[1].set_yscale('log');

m, b = np.polyfit(ns[4:], np.log10(ts)[4:], deg=1)
axs[1].plot(ns, 10**(m*ns + b), label=fr'$t={1000*10**b:.2g}μs×{10**m:.2g}^{{n}}$');
axs[1].legend();
../_images/1cb8c4cbf49ffa4608f5ef468a8305fb0ad14804ffb7de146a3f7672e34784e1.png
t_h = 0.13e-6 * (1.6**55) / 60**2
print(f"{t_h:.2g}h on my computer")
6.1h on my computer

If this trend continues – and it seems pretty likely – then fib(55) will take about 6 hours to compute.

Hide code cell source

# Check
xp = phi = (1+np.sqrt(5))/2
xm = -1/phi
x_ = np.array([xp, xm])
assert np.allclose(x_**2 - x_, 1)
print(xp, xm)
1.618033988749895 -0.6180339887498948
from functools import cache

@cache
def fib0a(n):
    """Return the n'th Fibonnacci number."""
    if n == 0:
        return 0
    if n == 1:
        return 1
    return fib0a(n-1) + fib0a(n-2)

%timeit fib0a(100)
37.9 ns ± 0.146 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

From Recursion to Iteration#

One way to improve this is to accumulate the results as we go along. This turns our recursive program into an iterative program.

def fib1(n):
    F0 = 0
    F1 = 1
    while n > 0:
        F0, F1 = F1, F0 + F1
        n -= 1
    return F1

Is this correct? Perhaps we should write a test:

def test_fib(fib):
    res = list(map(fib, range(15)))
    exact = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]
    if res == exact:
        return True
    else:
        print(f"{res} != {exact}")
        return False
    
test_fib(fib0)
True

Our test seems to work with fib0. What about fib1?

test_fib(fib1)
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610] != [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]
False

Looking at the output, we can probably guess how to fix this:

def fib1a(n):
    F0 = 0
    F1 = 1
    while n > 0:
        F0, F1 = F1, F0 + F1
        n -= 1
    return F0
    
test_fib(fib1a)
True

But how can we code in a way that we know our code is correct? A general approach for this strategy involves invariants.

Invariants#

Here is how we might structure our code, adding invariants so that we can ascertain the correctness of our program:

def fib2(n):
    # Invariant: F0 = F_{m}, F1 = F_{m+1}
    m = 0
    F0 = 0
    F1 = 1
    # Invariant satisfied: F0 = F_{0} = 0, F1 = F_{1} = 1
    
    # Check that n is a positive integer...
    assert n >= 0 and n % 1 == 0
    while m < n:
        # At the start of the loop we assume the invariant is satisfied:
        # F0 = F_{m}, F1 = F_{m+1}
        
        F0, F1 = F1, F0 + F1   # F0 = F_{m+1}, F1 = F_{m} + F_{m+1}
        m += 1                 # F0 = F_{m},   F1 = F_{m-1} + F_{m}
        
        # At the end of the loop, we check the invarient:
        # F0 = F_{m}, F1 = F_{m-1} + F_{m} = F_{m+1}
        # Invariant satisfied!
    
    # The loop will terminate when m == n (as long as n is an integer)
    # thus, F0 contains F_{m}
    return F0

# Now let's check to be sure
test_fib(fib2)
True