Computing Fibonacci: Algorithms, Code, and Performance Explained
Fibonacci Numbers Definition
The Fibonacci numbers are defined by the following function:
Let's look at different ways to compute .
The Recursive Algorithm
Looking at the definition, we can see that computing for can be reduced to computing and , then adding those two values together. Breaking down a problem into sub-problems in a recursive fashion and then combining the solutions to those sub-problems to obtain a solution to the original problem is a common techique in computer science called dynamic programming.
A straightforward way to implement such algorithm would be:
function fibRecursive(n: number): bigint {
if (n === 0) return 0n;
if (n === 1) return 1n;
return fibRecursive(n - 1) + fibRecursive(n - 2);
} Give it a try!
Complexity Analysis
Even though the algorithm does not use any explicit data structures, the fact that it is recursive means that there is an implicit data structre at play: the call stack. This stack will grow linearly with , so the space complexity of the algorithm is .
Analyzing the time complexity is harder. Let's start by defining as the time it takes to compute . Since computing the base cases and is done in constant time, we set:
Now, for , computing requires computing which takes time, computing which takes time, and then adding those solutions together which we will assume takes constant time, so we define recursively as follows:
Expanding the definition a few times we get:
A pattern seems to be emerging:
for . Setting we get:
Let's prove this identity by strong induction. Suppose for all . We first verify that the identity holds for :
We then verify that the base cases satisfy the identity as well:
Therefore, by strong induction: for all .
Now, from Binet's formula we have:
where is the golden ratio.
As a result, the time complexity of this algorithm is .
But we can do better! Notice that even though computing requires solving a total of unique sub-problems from computing all the way down to computing , we are actually solving most of those multiple times. We can speed up the algorithm by applying a space-time trade-off: using an additional data structure to store the solutions to the problems we've already solved so that we can later retrieve them in constant time.
The Memoized Recursive Algorithm
Let's introduce an array of length , where the element
in the array contains the value of we previously computed. We will name this data
structure memo:
function fibMemoized(n: number, memo?: Array<bigint>): bigint {
if (memo === undefined) {
memo = new Array<bigint>(n + 1);
memo[0] = 0n;
memo[1] = 1n;
}
if (memo[n] === undefined) {
memo[n] = fibMemoized(n - 1, memo) + fibMemoized(n - 2, memo);
}
return memo[n];
} Give it a try!
Complexity Analysis
This time, in addition to the call stack, we also have the memo data structure,
which is an array of elements of size each because of
the use of BigInts to support arbitrary large integers. As such, the space complexity is . If we just limited the algorithm to using 64-bit integers instead, the space complexity
would be .
Furthermore, fibMemoized(k, memo) is called at most twice for each k from 0 to , doing work because of the use
of BigInts. As such, the time complexity is . If we just limited the
algorithm to using 64-bit integers instead, the time complexity would be .
But we can do even better! This time, lets try to improve the space complexity by getting rid of
the memo data structure.
The Bottom-Up Algorithm
In the previous algorithm, we had several sub-problems that we solved in a top-down approach: we first tackled , then , , all the way down to and . This time, let's try to tackle the sub-problems in a bottom-up fashion:
function fibBottomUp(n: number): bigint {
const memo = new Array<bigint>(n + 1);
memo[0] = 0n;
memo[1] = 1n;
for (let k = 2; k <= n; k += 1) {
memo[k] = memo[k - 1] + memo[k - 2];
}
return memo[n];
} This algorithm has the same space and time complexities as the previous one. However, notice
that to solve a sub-problem we only need the solutions to 2 other sub-problems: and , solutions to any earlier sub-problems
are no longer needed. So we don't really need the memo data structure, we just need a constant number of variables to store the two most
recent solutions at each iteration:
function fibBottomUpLight(n: number): bigint {
let a = 0n;
let b = 1n;
let temp: bigint;
for (let k = 2; k <= n; k += 1) {
temp = a;
a = b;
b += temp;
}
return b;
} Give it a try!
Complexity Analysis
Since we got rid of the memo data structure, the memory usage
will get dominated by the use of BigInts, so the space complexity of the algorithm is . If we just limited the algorithm to using 64-bit integers instead, the space complexity
would be .
And since we go through the for loop times doing work in each iteration because of the use of BigInts, the time complexity of the algorithm is . If we just limited the algorithm to using 64-bit integers instead,
the time complexity would be .
There is a way to improve the running-time further, but at the cost of accuracy.
The Closed-Form Approximation Algorithm
From Binet's formula we have
where is the golden ratio and .
Since for all , is the closest integer to , so we can compute by rounding. The problem with this approach is that and are irrational numbers, which means they cannot be computed exactly, regardless of how much time and memory we dedicate to the task. Therefore, the value we end up with will be an approximation of , losing accuracy as increases.
function fibClosedForm(n: number): number {
const phi = 1.618033988749895;
return Math.round(phi ** n / Math.sqrt(5));
} Give it a try!
Complexity Analysis
We are not using BigInts this time, so the space complexity of the algorithm is .
Furthermore, assuming constant-time mathematical operations, the time complexity of the algorithm is .
Conclusion
There are several different ways to compute Fibonacci numbers, each with their own trade-offs. The recursive algorithm is easy to understand and implement as it most closely matches the mathematical definition, but it's very slow and uses quite a bit of memory. The memoized recursive algorithm is much faster but it is also memory-inefficient. The bottom-up dynamic programming approach is just as fast as the memoized recursive one, but uses a constant amount of memory. And the closed form approximation approach, while extremely fast and memory-efficient, is not very accurate.