In keeping with our recent theme of
generating famous number structures, we'll consider the Fibonacci sequence today. This post was also inspired by some of the examples
and exercises in the Structure and Interpretation of Computer
Programs (namely Exercise 1.13 and Exercise 1.19 for those that came for the
Scheme solutions.)
The story goes that the Fibonacci
numbers were so named by the Italian mathematician Leonardo Fibonacci
in the 1200's, when he discovered the sequence while forming a
mathematical model for rabbit populations. However evidence suggests
that the sequence was known to others before then (it seems to appear
in earlier Indian mathematics, for instance.)
The Fibonacci numbers also have an
interesting relationship with the Golden Ratio, namely that as we
take larger and larger numbers in the sequence, the ratio between one
number and the next approaches the Golden Ratio.
Or mathematically:
And these numbers pop up in all sorts
of places. Remember Pascal's triangle? Turns out if you draw some
“shallow diagonal” lines across the triangle and sum up the
numbers on each line, you get the Fibonacci sequence too!
(There's a nice picture of this here.)
Though that history is all well and
good, computation is always much more interesting! We'll look at four
different ways to generate the Fibonacci numbers, with Clojure taking
centre stage as our JVM language today, including some guest appearances from Python (or Jython if you like, so as to keep with the JVM theme.)
So without further ado...
1. Using a Recursive Process
The simplest way
to generate the Fibonacci numbers is recursively.
Let n be some
positive integer. Then the recurrence relation, including the base
cases, for the nth Fibonacci number is as follows:
We can express
this exactly in Clojure code with a recursive function:
We can then call
this function from the Clojure REPL to generate, say, the first 20
non-zero Fibonacci numbers:
(map fibonacci (range 1 (inc 20)))
;> (1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597
2584 4181 6765)
(map fibonacci (range 1 (inc 20)))
;> (1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597
2584 4181 6765)
This is a concise
and elegant way to compute the Fibonacci numbers. However, using
recursion in this way is actually so inefficient that it was worthy of a quip in Code Complete!
To understand
why, notice that we've got both of the following:
- overlapping subproblems, ie. When we compute F5 for example, we compute F4 and F3 , and F4 itself computes F3 again in its own branch. Both of these calls to F3 make their own separate calls to F2 and F1 , and you can see how this quickly gets ridiculous. So we're doubling (and tripling, and quadrupling, …) up on the work we end up doing for each calculation!
- delayed operations. This means that the code cannot possibly be optimised to be tail-recursive, so we're creating a new stack frame for each of those calls!
The inefficiency
of this method was also explored in SICP, of course. The process has
order of growth O(2n) in time and O(n) in space.
(Try computing
(fibonacci 500) if you like! Funny story: I was originally going to
do this in the hopes that it would quickly give me a StackOverflow exception that
I could talk about here for a bit. But an hour later the thing still
hadn't finished and I gave up.)
So though the recursive method has a certain beauty to it, it's probably not a good way to calculate Fibonacci numbers. Luckily, there are more efficient means of computation!
2. Using an Iterative Process
A better approach
is to compute the Fibonacci numbers using an iterative process.
Intuitively, this means that we build up the next numbers at each
step (instead of branching down), which means we don't have any
repeated calculations this time.
Basically, at each
point we're applying a transformation to a pair of integers [current,
next]:
- we start off with the initial values current = 0, next = 1
- at each point, we apply the transformation[current, next] → [next, current+next].
So for example,
[0,1] → [1,1] → [1,2] → [2,3].
After n transformations, the nth
Fibonacci number appears in the current (left-hand) slot.
Now most
programming languages these days have iteration front and centre, and
so are well suited to representing these particular 'looping variable
update' constructs. In the same way that the recursion method
translated easily into Clojure, the iterative method translates very
easily into any of these languages.
Here's the iterative method in Python (or Jython, its JVM variant):
Here's the iterative method in Python (or Jython, its JVM variant):
Generating the
first 20 non-zero Fibonacci numbers in the Python interpreter:
map(fibonacci,
range(1,21))
#> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377,
#> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377,
610, 987, 1597, 2584, 4181,
6765]
The numbers agree with our recursive definition from earlier, so that's good.
In Clojure, we can
use the loop-recur construct to generate an iterative process by
using the aforementioned tail-recursion.
Syntactically it looks very similar to how it would look in a
procedural language, but behind the scenes we are actually using
recursion to run the iteration, the state transformations happen as
the recursive calls change the values of the input parameters each
time. Since the recursive call is in the 'tail' position (that is,
the call is the last thing that happens and isn't nested inside
another expression), the Java Virtual Machine can optimise the
recursion away, so we don't accumulate stack frames as we go.
Here's
the iterative version in Clojure:
(The
N at the end of the numbers signifies that the numbers should have
arbitrary precision: we might be dealing with some pretty big numbers
here!)
Sure
enough, we can generate the exact same set of 20 Fibonacci numbers
that we had before.
(map fibonacci (range 1 (inc 20)))
;> (1N 1N 2N 3N 5N 8N 13N 21N 34N 55N 89N 144N 233N 377N
610N 987N 1597N 2584N 4181N 6765N)
This
time though, we have a much more efficient implementation. Now we can
actually calculate things like (fibonacci 500) with no trouble
at all:
(fibonacci
500)
;> 13942322456169788013972438287040728395007025658769730
;> 13942322456169788013972438287040728395007025658769730
..7264108962948325571622863290691557658876222521294125N
So
let's analyze the time-complexity and space-complexity for this
iterative process.
In this iterative method (shown by both the Python and Clojure versions above), note that we're looping up to n, and only doing some basic checks/variable assignment within that loop. So we have a process that grows only linearly with the input n, ie. Order of growth O(n) in time.
And in terms of space, we only ever need space to hold the current and next values at any point! Now of course as the number of digits in the numbers increases we'll need more memory to store them, but in terms of algorithm analysis we consider this basically constant. So we have O(1) space-complexity.
In terms of algorithms, it doesn't get much better than linear time – if you've got a linear time algorithm you're doing pretty well, really. But can we do even better with the Fibonacci numbers? As it turns out, yes!
In terms of algorithms, it doesn't get much better than linear time – if you've got a linear time algorithm you're doing pretty well, really. But can we do even better with the Fibonacci numbers? As it turns out, yes!
3. Using a Logarithmic-time Process
As noted in that good old SICP masterpiece, there is a way to compute the Fibonacci numbers with a logarithmic-time process. This algorithm is one of the coolest things I've ever seen, and it's also got a hefty bit of mathematics to it too!
So in the iterative process version that we had just before, we had the state transformation [current, next] → [next, current+next]. Let's denote this transformation by T. As we saw, applying T n times to the pair [0,1] gave us the nth Fibonacci number.
So mathematically, we could write that as Tn(0,1)
= [Fn
,
Fn+1].
Now
here's the trick; suppose we consider T to be a special case of a
more general transformation? Suppose that Tpq is a transformation that maps
[current, next] → [p(current) + q(next), q(current) + (p+q)(next)].
[current, next] → [p(current) + q(next), q(current) + (p+q)(next)].
Then
you can see that taking p=0, q=1 gives us our Fibonacci
transformation T.
Now consider what happens when we compose Tpq with itself:
Now consider what happens when we compose Tpq with itself:
So
we have that Tpq2
= Tp'q'
, which gives us a notion of “squaring” this transformation that
we can use to cut down on the amount of work the iterative process
does!
This allows us to speed up the iterative process we had earlier. We'll use the same loop-recur construct (modifying the values for the current, next parameters as we count down from n), but this time we'll check whether n is even at each point. If it is, then we can save some steps by applying the above "squaring" transformation instead.
Here's some Clojure code that implements this new algorithm:
This allows us to speed up the iterative process we had earlier. We'll use the same loop-recur construct (modifying the values for the current, next parameters as we count down from n), but this time we'll check whether n is even at each point. If it is, then we can save some steps by applying the above "squaring" transformation instead.
Here's some Clojure code that implements this new algorithm:
The code works exactly the same way as before, giving us the same output.
(map fibonacci (range 1 (inc 20)))
;> (1N 1N 2N 3N 5N 8N 13N 21N 34N 55N 89N 144N 233N 377N
610N 987N 1597N 2584N 4181N 6765N)
However, this algorithm is efficient enough to calculate some huge Fibonacci numbers in no time at all, too. The following calculation took about half a second, and a good majority of that would've been printing it to the screen:
(fibonacci 100000)
;> 25974069347...(absolutely huge number that spans
over 10 A4 pages on a 12pt font)...3428746875N
We can modify the Python version of the iterative code to use this new logarithmic algorithm too:
The Python version works just as quick as the Clojure version:
map(fibonacci, range(1,21))
#> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377,
610, 987, 1597, 2584, 4181, 6765]
fibonacci(100000)
#> 25974069347221...lots of digits...3428746875L
However, this algorithm is efficient enough to calculate some huge Fibonacci numbers in no time at all, too. The following calculation took about half a second, and a good majority of that would've been printing it to the screen:
(fibonacci 100000)
;> 25974069347...(absolutely huge number that spans
over 10 A4 pages on a 12pt font)...3428746875N
We can modify the Python version of the iterative code to use this new logarithmic algorithm too:
The Python version works just as quick as the Clojure version:
map(fibonacci, range(1,21))
#> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377,
610, 987, 1597, 2584, 4181, 6765]
fibonacci(100000)
#> 25974069347221...lots of digits...3428746875L
In terms of time- and space-complexity,
we have a logarithmic order of growth in time (hence the name). Note too that we've still got that underlying iteration process happening – we're not delaying any operations or anything. So we're still working
with constant space here. As such, we have O(log n) order of growth for
time, and O(1) for space.
So
seems like we've reached the epitome of efficiency then. We've gone
from an exponential time process, to a linear time process, to a
logarithmic time process. That's pretty awesome as it is, and the only thing more impressive would be
if we could somehow compute a Fibonacci number in constant time, using some sort of mathematical formula. But
there's no way that could be possible, right?
Remember that Golden Ratio we saw at the start? What if I said that, mathematically, this is true:
And for this one, we even get a cryptic error for our trouble!
A little step-by-step debugging reveals the culprit almost immediately:
(math/expt golden-ratio 100000)
;> Infinity
So we pass in Infinity to the bigdec function. No wonder it complains!
4. Using a Mathematical Formula
Remember that Golden Ratio we saw at the start? What if I said that, mathematically, this is true:
Of course, you
shouldn't take my word for it! If we're going to suggest that this is
true, and then go ahead and use it in code, we'll need to prove it first.
So here's a proof,
(as per the one hinted at in the book):
So good for us!
This means that we can use this formula to calculate the Fibonacci
numbers directly, as we've done in the following Clojure function.
(Note that we've
used Clojure's numeric-tower math library here to handle the
rounding, exponentiation and square root stuff.)
Sure enough, this
function works exactly like the others... right?
(map fibonacci
(range 1 (inc 20)))
;> (1N 1N 2N 3N 5N 8N 13N 21N 34N 55N 89N 144N 233N 377N
;> (1N 1N 2N 3N 5N 8N 13N 21N 34N 55N 89N 144N 233N 377N
610N 987N 1597N 2584N 4181N 6765N)
(fibonacci 50)
;> 12586269025N
;> 12586269025N
(fibonacci 70)
;> 190392490709135N
;> 190392490709135N
(fibonacci 90)
;> 2880067194370824700N
;> 2880067194370824700N
(fibonacci 100)
;> 354224848179263100000N
;> 354224848179263100000N
(fibonacci 200)
;> 280571172992512000000000000000000000000000N
;> 280571172992512000000000000000000000000000N
(fibonacci 300)
;> 222232244629422680000000000000000000000
;> 222232244629422680000000000000000000000
..000000000000000000000000N
We're starting to see a suspicious amount of zeros at the end of these numbers...
(fibonacci 100000)
;> NumberFormatException java.math.BigDecimal.<init>
(BigDecimal.java:470)
;> NumberFormatException java.math.BigDecimal.<init>
(BigDecimal.java:470)
And for this one, we even get a cryptic error for our trouble!
A little step-by-step debugging reveals the culprit almost immediately:
(math/expt golden-ratio 100000)
;> Infinity
So we pass in Infinity to the bigdec function. No wonder it complains!
The problem here is in the exponentiation, which uses Java Doubles. The second we pass the limit of that representation:
(math/expt golden-ratio 1474)
;> 1.1163020658835235E308
(math/expt golden-ratio 1475)
;> Infinity
java.lang.Double/MAX_VALUE
;> 1.7976931348623157E308
.. we'll get Infinity from then on.
Also, using Doubles explains the zeros we were seeing earlier. As you can see from the scientific-notation form, they can only have so much precision.
So we've seen four different ways to generate the Fibonacci numbers here, and saw a few of the subtleties of algorithm analysis and space/memory concerns on our hunt towards the most efficient method we could find. We even saw some mathematics being put to good use here! It's all very interesting, isn't it?
(math/expt golden-ratio 1474)
;> 1.1163020658835235E308
(math/expt golden-ratio 1475)
;> Infinity
java.lang.Double/MAX_VALUE
;> 1.7976931348623157E308
.. we'll get Infinity from then on.
Also, using Doubles explains the zeros we were seeing earlier. As you can see from the scientific-notation form, they can only have so much precision.
Now we could attempt to solve both of those issues by using arbitrary precision decimals. However keep in mind that,
compared with the other methods where we were only dealing with
integers, this time we're dealing with floating-point approximations
to real numbers. In particular, the operations that we're performing
here – exponentiation, square-rooting, and even the simple
division – will not work so well with arbitrary precision (after
all, how could they? The whole point of many real numbers is that
they don't have a terminating decimal expansion. For example, is the
square root of 2 going to be 1.414214 ? Or 1.4142136 ? Or 1.41421356
? Or ...)
But we actually have another problem too. This formula isn't the computational panacea that our earlier proof might have suggested. It's probably not possible to raise a number to the nth power in O(1) time, and judging by the algorithm used by the numeric-tower library we have at least
O(log n) time-complexity anyway.
That's still pretty awesome in terms of computational efficiency, but we already had a logarithmic-time algorithm before, didn't we? And in that one, we had arbitrary precision integers too!
O(log n) time-complexity anyway.
That's still pretty awesome in terms of computational efficiency, but we already had a logarithmic-time algorithm before, didn't we? And in that one, we had arbitrary precision integers too!
This method is more interesting in a mathematical sense then, but not so practical for actual use.
No comments:
Post a Comment