Description
I believe I have found an error in the book.
The Error (Exercise 3.57)
Exercise 3.57 is defined as:
How many additions are performed when we compute the _n_th Fibonacci number using the declaration of
fibs
based on theadd_streams
function? Show that this number is exponentially greater than the number of additions performed ifadd_streams
had used the functionstream_map_2_optimized
described in exercise 3.50.
This implies that the following would only require a linear number of additions:
function stream_map_2_optimized(f, s1, s2) {
return is_null(s1) || is_null(s2)
? null
: pair(f(head(s1), head(s2)),
memo(() => stream_map_2_optimized(f, stream_tail(s1), stream_tail(s2))));
}
function add_streams(s1, s2) {
return stream_map_2_optimized((x1, x2) => x1 + x2, s1, s2);
}
const fibs = pair(
0,
() => pair(
1,
() => add_streams(stream_tail(fibs), fibs)));
However, when I actually tried it I found that the number of addition operations is actually identical. In fact, the memoized functions are never called. There's a whole bunch of state and stored functions that make reasoning about it extremely convoluted even for small indices, but the root cause is that in the definition of fibs, we are not fully memoizing the stream tail functions. The following fix correctly runs with a linear number of additions:
const fibs = pair(
0,
memo(
() => pair(
1,
memo(() => add_streams(stream_tail(fibs), fibs)))));
This correctly memoizes all stream_tail functions.
Comparison to 1985 original
I checked the original Scheme version, which does not have this defect. Comparing to the original Scheme version, Chapters 3.5.1 and 3.5.2 are some of the most heavily edited: https://sicp.sourceacademy.org/chapters/3.5.1.html, and in particular the JS version gets rid of cons-stream
, instead usiong raw pair
operations for all stream construction.
The Scheme version of exercise 3.5.7 is:
How many additions are performed when we compute the _n_th Fibonacci number using the definition of
fibs
based on theadd-streams
procedure? Show that the number of additions would be exponentially greater if we had implemented(delay <exp>)
simply as(lambda () <exp>)
, without using the optimization provided by thememo-proc
procedure described in section 3.5.1
The question is in fact sort of reversed; the implementation of fibs
is optimal, and the exercise suggests thinking of a potential unoptimized version – namely, not using memoization. This works because the memoization is baked into the cons-stream
procedure, which becomes the primitive constructor for all streams.
Given the functions already available, a JS implementation would be something like this:
function cons_stream(sHead, sTailFunc) {
// memo function provided in Chapter 3.5.1
return pair(sHead, memo(sTailFunc));
}
By using this constructor everywhere that we make streams with pair
currently, the optimization becomes "baked in"; in fact, stream_map_2
can automatically take advantage of it without explicitly calling memo
:
function stream_map_2(f, s1, s2) {
return is_null(s1) || is_null(s2)
? null
: cons_stream(
f(head(s1), head(s2)),
() => stream_map_2(f, stream_tail(s1), stream_tail(s2)));
}
function add_streams(s1, s2) {
return stream_map_2((x1, x2) => x1 + x2, s1, s2);
}
const fibs = cons_stream(0,
() => cons_stream(1, () => add_streams(stream_tail(fibs), fibs)));
The definition of fibs
here is pretty much identical to the one given for Scheme:
(define fibs
(cons-stream 0
(cons-stream 1
(add-streams (stream-cdr fibs)
fibs))))
Potential fixes
Short term
The exercise 3.57 as presented is mistaken, so it should be fixed. Here is a sample rewritten version:
How many additions are performed when we compute the _n_th Fibonacci number using the declaration of
fibs
based on theadd_streams
function? Explain why this number grows exponentially in relation to n.
Usingmemo
, provide an implementation offibs
which only uses a linear number of additions in relation to n. You may need the functionstream_map_2_optimized
described in exercise 3.50.
Long term (2nd edition?)
I don't know the reasons that the authors decided to do away with cons-stream
in the JavaScript edition, but in my comparison this feels like a change that could be reverted. One strong message of the book as a whole is to use constructors and selectors as abstractions for the implementation details, so having con_stream
as a constructor which wraps pair(sHead, memo(sTailFunc))
seems reasonable.
I did run into one problem when trying to fully lean into copying the Scheme implementation. Namely, I tried to define cons_stream
as follows:
function cons_stream(sHead, sTail) {
return pair(sHead, memo(() => sTail));
}
I could not longer create streams that referenced themselves:
const fibs = cons_stream(0,
() => cons_stream(1, () => add_streams(stream_tail(fibs), fibs)));
// ReferenceError: Cannot access 'fibs' before initialization
I don't know if this is a difference between JS and Scheme on how expressions are treated, and I confess that my understanding is not deep enough to definitively show it one way or another. Skimming future sections, I see more material on delay
, normal vs applicative order etc. so perhaps there is an explicit reason for changing this implementation.
However from what I can tell, there would be no drawback by providing the memoized implementation of cons_stream
, where the two arguments must be a stream head, and a function returning the stream tail.