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.