Linear Algebra Done Right

Computing the Fibonacci sequence is a common exercise in your typical ‘Intro to Coding’ course. The exercise involves an introduction of concepts in dynamic programming in memoization. While the resulting recursive program is efficient, you can actually compute Fibonacci numbers directly with linear algebra.

This exposition was inspired by Excerise 5.C(16) in the Third Eidtion of Sheldon Axler’s Linear Algebra Done Right textbook.

The Fibonacci sequence

The Fibonacci sequence is defined by

You may have seen the Fibonacci sequence in the context of the so-called “golden spiral”.

Computing Fibonacci numbers

Notice that to compute a Fibonnaci number , we have to compute all Fibonnaci numbers . To perform this computation, we can make a simple recursive program.

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

Now let’s perform some tests.

for n in [2,5,10,25,40,41]:
    start_time = time.time()
    print(f"Fibonacci({n}) = {fibonacci(n)}")
    print(f"Execution time: {time.time() - start_time:.5f} seconds")
Fibonacci(2) = 1
Execution time: 0.00002 seconds
Fibonacci(5) = 5
Execution time: 0.00000 seconds
Fibonacci(10) = 55
Execution time: 0.00001 seconds
Fibonacci(25) = 75025
Execution time: 0.00705 seconds
Fibonacci(40) = 102334155
Execution time: 9.58250 seconds
Fibonacci(41) = 165580141
Execution time: 15.58907 seconds

As you can see, computing Fibonacci numbers and are relatively painless. But computing takes much longer, with the difference between the two execution times being seconds. Why?

This is because our program is highly redundant. To compute , we compute and , but in computing we also compute . Thus, we are computing twice. The problem only becomes worse as approaches in our program.

To remedy this redundancy, we employ dynamic programming. While an exposition of dynamic programming is outside the scope of this notebook, the idea is to create a dictionary, also called a memo, that stores the results of computing for and then to return those results when asked.

def fibonacci_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

Now let’s rerun our tests from before.

for n in [2,5,10,25,40,41]:
    start_time = time.time()
    print(f"Fibonacci({n}) = {fibonacci_memo(n)}")
    print(f"Execution time: {time.time() - start_time:.5f} seconds")
Fibonacci(2) = 1
Execution time: 0.00002 seconds
Fibonacci(5) = 5
Execution time: 0.00000 seconds
Fibonacci(10) = 55
Execution time: 0.00000 seconds
Fibonacci(25) = 75025
Execution time: 0.00001 seconds
Fibonacci(40) = 102334155
Execution time: 0.00000 seconds
Fibonacci(41) = 165580141
Execution time: 0.00000 seconds

Cool right? That took barely any time, and all we did was add a dictionary.

But can we do better? Is there a closed form solution to computing Fibonnaci numbers?

Linear maps

Let’s define the linear map by

Now suppose we had to compute for some positive integer . For , we have Notice that the coordinate of exactly matches and the second coordinate matches . We can even see that will produce . So far, it looks like , but can we prove it?

We can prove it with induction. We know that , so let our induction hypothesis be

Applying to both sides, we get

Using matrices

So given that we know our linear map computes Fibonnaci numbers, could we simplify the process of iteratively applying -times? Consider the following matrix defined by

It’s easy to see that for the vector , we can write

Wouldn’t it be nice if our matrix were diagonalizable so that

since . Given is a diagonal matrix, is simply obtained by raising each diagonal entry to the -th power. This would make the computing of Fibonnaci numbers near trivial. Lucky for us, is indeed diagonalizable. Let’s start by findind the eigenvalues of .

Eigenvalues

To compute the eigenvalues of , we can write

Combining and , we have

which becomes

leaving us to find the roots of the polynomial . Via the tried and true quadratic formula, it follows that

Hence, the eigenvalues of are and . In particular, our eigenvalue is the so-called “golden ratio”.

Eigenvectors

Now let’s find the eigenvectors. For , we can set and write suggesting that and . While initially unbelievable, it is indeed the case that

Notice that the same result also holds for . Hence, the eigenvectors of are

Putting linear algebra to work

At this point, we now have the following algorithm for computing Fibonacci numbers

where . Let’s sanity check this algorithm by writing a python function.

def fibonacci_matrix(n):
    sqrt5 = np.sqrt(5)
    lambda1 = (1 + sqrt5) / 2
    lambda2 = (1 - sqrt5) / 2
    
    Q = np.array([[1, 1], [lambda1, lambda2]])
    Lambda_n = np.diag([lambda1**n, lambda2**n])
    Q_inv = np.array([[lambda2, -1], [-lambda1, 1]]) / (lambda2 - lambda1)
    x = np.array([[0], [1]])
    
    result = Q @ Lambda_n @ Q_inv @ x
    return result[0, 0].item()

Like before, let’s rerun those tests.

for n in [2,5,10,25,40,41]:
    start_time = time.time()
    print(f"Fibonacci({n}) = {fibonacci_matrix(n)}")
    print(f"Execution time: {time.time() - start_time:.5f} seconds")
Fibonacci(2) = 0.9999999999999999
Execution time: 0.00020 seconds
Fibonacci(5) = 5.000000000000001
Execution time: 0.00003 seconds
Fibonacci(10) = 55.00000000000002
Execution time: 0.00002 seconds
Fibonacci(25) = 75025.00000000006
Execution time: 0.00001 seconds
Fibonacci(40) = 102334155.00000013
Execution time: 0.00001 seconds
Fibonacci(41) = 165580141.00000024
Execution time: 0.00001 seconds

While fibonacci_matrix is much faster our original fibonacci program, it’s still beat by fibonacci_memo in speed. Also notice that the numbers are not integers as there are small remainders. These remainders occur due to floating-point precision errors in computing with in our eigenvalues.

With these issues in mind, we can do better.

From our expression for from before, we can write

implying that

Substituting in the exact values of and , we have

which is equivalently expressed as

Doesn’t it look much nicer? Let’s make a new program.

def fibonacci_expression(n):
    sqrt5 = np.sqrt(5)
    lambda1 = (1 + sqrt5) / 2
    lambda2 = (1 - sqrt5) / 2
    
    result = (lambda1**n - lambda2**n)/sqrt5
    return result

You know the drill.

for n in [2,5,10,25,40,41]:
    start_time = time.time()
    print(f"Fibonacci({n}) = {fibonacci_expression(n)}")
    print(f"Execution time: {time.time() - start_time:.5f} seconds")
Fibonacci(2) = 1.0
Execution time: 0.00020 seconds
Fibonacci(5) = 5.000000000000001
Execution time: 0.00001 seconds
Fibonacci(10) = 55.000000000000014
Execution time: 0.00001 seconds
Fibonacci(25) = 75025.00000000006
Execution time: 0.00000 seconds
Fibonacci(40) = 102334155.00000013
Execution time: 0.00000 seconds
Fibonacci(41) = 165580141.00000024
Execution time: 0.00000 seconds

As you can see, this new program is much quicker, but we still have those pesky floating-point precision errors. The obvious solution would be to simply round those errors off, but if we are going to resort to rounding, we can actually make an even better program.

Approximating Fibonacci

Let’s take another look at our expression for Fibonacci numbers

Do you notice something interesting about the term ? Computing the term for , we have

The absolute value of each of these terms is less than . So if we are going to round our expression of the Fibonacci number, then we don’t even need to compute the term since rounding will always make up for what we lost. Hence, we can write

Are you skeptical? Let’s make a fifth and final program to compute Fibonacci numbers.

def fibonacci_approximation(n):
    sqrt5 = np.sqrt(5)
    lambda1 = (1 + sqrt5) / 2    
    result = (lambda1**n)/sqrt5
    return round(result)
for n in [2,5,10,25,40,41]:
    start_time = time.time()
    print(f"Fibonacci({n}) = {fibonacci_approximation(n)}")
    print(f"Execution time: {time.time() - start_time:.5f} seconds")
Fibonacci(2) = 1
Execution time: 0.00005 seconds
Fibonacci(5) = 5
Execution time: 0.00001 seconds
Fibonacci(10) = 55
Execution time: 0.00001 seconds
Fibonacci(25) = 75025
Execution time: 0.00001 seconds
Fibonacci(40) = 102334155
Execution time: 0.00000 seconds
Fibonacci(41) = 165580141
Execution time: 0.00000 seconds

Isn’t that just wonderful? We now have a simple closed-form approximation of Fibonacci numbers with an execution time that rivals our memoization method.

The power of linearity

In this notebook, we explored how computing the Fibonacci sequence can be performed with brute-force recursion, memoizaiton, linear maps, matrices, eigenvalues, and rounding. I bet you won’t be surprised, but there are even more methods one can implement to go faster.

From our work, I believe the most striking takeaway is how powerful linearity is. While nonlinear methods have seen widespread adoption in machine learning, the Fibonacci sequence shows us that a great deal can be achieved by finding elegant linear approximations to complex mathematical objects. For more examples of the power of linearity and linear algebra, checkout my solutions to exercises in Sheldon Axler’s Linear Algebra Done Right textbook.