Computing Fibonacci: Algorithms, Code, and Performance Explained

By
Published on

Fibonacci Numbers Definition

The Fibonacci numbers are defined by the following function:

Fn={0if n=01if n=1Fn1+Fn2if n>1

Let's look at different ways to compute Fn.

The Recursive Algorithm

Looking at the definition, we can see that computing Fn for n>1 can be reduced to computing Fn1 and Fn2, 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 n, so the space complexity of the algorithm is O(n).

Analyzing the time complexity is harder. Let's start by defining T(n) as the time it takes to compute Fn. Since computing the base cases F0 and F1 is done in constant time, we set:

T(0)=1T(1)=1

Now, for n>1 , computing Fn requires computing Fn1 which takes T(n1) time, computing Fn2 which takes T(n2) time, and then adding those solutions together which we will assume takes constant time, so we define T(n) recursively as follows:

T(n)=T(n1)+T(n2)+1

Expanding the definition a few times we get:

T(n)=T(n1)+T(n2)+1=2T(n2)+T(n3)+2=3T(n3)+2T(n4)+4=5T(n4)+3T(n5)+7

A pattern seems to be emerging:

T(n)=FkT(nk+1)+Fk1T(nk)+(Fk+11)

for kn. Setting k=n we get:

T(n)=FnT(1)+Fn1T(0)+Fn+11=Fn+Fn1+Fn+11=2Fn+11

Let's prove this identity by strong induction. Suppose T(n)=2Fn+11 for all kn. We first verify that the identity holds for T(n+1):

T(n+1)=T(n)+T(n1)+1=2Fn+11+2Fn1+1=2(Fn+1+Fn)1=2Fn+21

We then verify that the base cases satisfy the identity as well:

T(0)=2F11=21=1T(1)=2F21=21=1

Therefore, by strong induction: T(n)=2Fn+11 for all n.

Now, from Binet's formula we have:

Fn=ϕn(ϕ)n5

where ϕ is the golden ratio.

As a result, the time complexity of this algorithm is O(ϕn).

But we can do better! Notice that even though computing Fn requires solving a total of n unique sub-problems from computing Fn1 all the way down to computing F0, 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 n+1, where the kth element in the array contains the value of Fk 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 n+1 elements of O(logn) size each because of the use of BigInts to support arbitrary large integers. As such, the space complexity is O(nlogn). If we just limited the algorithm to using 64-bit integers instead, the space complexity would be O(n).

Furthermore, fibMemoized(k, memo) is called at most twice for each k from 0 to n, doing O(logn) work because of the use of BigInts. As such, the time complexity is O(nlogn). If we just limited the algorithm to using 64-bit integers instead, the time complexity would be O(n).

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 Fn, then Fn1, Fn2, all the way down to F1 and F0. 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 Fk we only need the solutions to 2 other sub-problems: Fk1 and Fk2, solutions to any earlier sub-problems are no longer needed. So we don't really need the O(n) 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 O(n) memo data structure, the memory usage will get dominated by the use of BigInts, so the space complexity of the algorithm is O(logn). If we just limited the algorithm to using 64-bit integers instead, the space complexity would be O(1).

And since we go through the for loop n1 times doing O(logn) work in each iteration because of the use of BigInts, the time complexity of the algorithm is O(nlogn). If we just limited the algorithm to using 64-bit integers instead, the time complexity would be O(n).

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

Fn=ϕnψn5=ϕn5ψn5

where ϕ is the golden ratio and ψ=ϕ1.

Since |ψn5|<12 for all n0, Fn is the closest integer to ϕn5, so we can compute Fn by rounding. The problem with this approach is that ϕ and 5 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 Fn, losing accuracy as n 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 O(1).

Furthermore, assuming constant-time mathematical operations, the time complexity of the algorithm is O(1).

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.