Last time we'd just finished an augmenting recursion version of
substitute. Now we'll write the tail-recursive counterpart.
(defun my-substitute (new old input-list)
(reverse (my-substitute-helper new old input-list nil)))
(defun my-substitute-helper (new old input-list result)
(cond ((null input-list) result)
((eql old (first input-list))
(my-substitute-helper new old
(rest input-list) (cons new result)))
(T (my-substitute-helper new old (rest input-list)
(cons (first input-list) result)))))
But "my-substitute" is truly tail-recursive only if "reverse" is
tail-recursive. Here's a tail-recursive "reverse":
(defun my-reverse (input-list)
(my-reverse-helper input-list nil))
(defun my-reverse-helper (input-list result)
(cond ((null input-list) result)
(T (my-reverse-helper (rest input-list)
(cons (first input-list) result)))))
What if we want to write append? It appends two lists:
(defun my-append (list1 list2)
(cond ((null list1) list2)
(T (cons (first list1) (my-append (rest list1) list2)))))
Looking at this version of "append", you can see that appending something
onto the end of a long (or growing) list is computationally expensive.
You have to "cons" together a copy of the first list before you can
run a pointer from the end of that copy to the second list. So if
you have a choice of building a list by consing elements to the front
and reversing the list once versus appending elements to the end,
make sure you use the cons-and-reverse approach. It's not just a little
savings in both time and cons cells, it's a big big big (potentially
exponential) savings in time and cons cells. And it has nothing to do
with whether "append" is implemented with augmenting recursion or tail
recursion (that's about stack space, not time or cons cells). And how
would you make a tail-recursive version of "append"? That's part of
your homework assignment.
Just how expensive is it to continually append stuff rather than cons a
list and reverse it? Very expensive.
Let's take our substitute function and re-write it with append instead of
cons. That is,
(defun my-ugly-substitute (new old input-list)
(my-ugly-substitute-helper new old input-list nil))
(defun my-ugly-substitute-helper (new old input-list result)
(cond ((null input-list) result)
((eql old (first input-list))
(my-ugly-substitute-helper new old
(rest input-list) (append result (list new))))
(T (my-ugly-substitute-helper new old (rest input-list)
(append result (list (first input-list)))))))
Simple enough, right? I did some tests this morning to compare the
efficiency of the two for a rough estimate:
On a 100 element list:
- my-substitute took 0.02 seconds and 21k of memory.
- my-ugly-substitute took 0.04 seconds and 180k of memory. Which is
quite a bit more, but we might think we can deal with it.
Now I make the problem 5 times bigger: on a 500 element list:
- my-substitute took 0.09 seconds and 105k of memory. Five times the
memory requirements and run-time; about what we'd expect.
But look at the appending version:
- my-ugly-substitute took 3.06 seconds and 7.7 _megs_ of memory. Wow!
Those numbers got about 70x bigger for a list 5x bigger. Bad bad bad.
On a 1000-element list,
- my-substitute took 0.18 seconds and 209k of memory.
- my-ugly-substitute blew up after a minute or two.
The moral to this story is that simple little decisions like using
append instead of cons can cost you lots of time and lots of memory.
Whenever you're given a choice between constructing a list
element-by-element with cons, and then reversing the final result,
versus appending each new element to the end of the list, you should
always used the cons-and-reverse approach for the efficiency reasons
suggested above.
Multiple recursion
The next function we'll construct isn't already defined in
Common LISP, but most good LISP hackers have one of these
sitting around in their personal library of LISP functions,
or they can at least reconstruct it on demand. I call the
function "flatten", and its purpose is to take any list,
nested arbitrarily deep, and return a simple list of all the
atoms in the original list. In other words, it takes a
hierarchical list structure and returns a linear list
structure, with the order of the atoms intact. Or, in still
other words, it removes all the parentheses except the
outermost ones:
? (flatten '(a (b (c) d (e) f)))
(A B C D E F)
?
An aside...
People seem to always want to use flatten to fix solutions to their
problems. For example, often times when people are constructing a list
for one of the hw2 problems, they end up with nested lists they didn't
want. And they've seen flatten, so they try and use that to fix their
results.
Aside from the fact that, 95% of the time, the solution with flatten
still doesn't work right anyway, I want to point out that this is _not_
a good way to go about constructing a solution. Don't create a solution
that's kinda almost half right and then try and fix it at the end by
calling flatten on it. Doing so reminds me of those cartoons where the
character tries to put a square peg in a round hole using a hammer.
Another similar example from homework 1: the "ideal-weight" problem
wanted a decimal answer rather than a fraction. Some people had their
functions deal with fractions and then at the end took the result and
added 0.0 to it to make it a decimal. This works, but again, it's like
constructing a bad solution and then trying to fix it at the end. In
the case of ideal-weight, if you made your constant 704.0 (or whatever
it was) rather than 704, things worked out peachy from the get-go.
The special case of creating a backwards list and then reversing it at
the end is acceptable merely by virtue of the efficiency. But in
general, don't create solutions with the wrong structure and then try
and fix them in post-processing. Build the right things from the
start.
Back to flatten.
How the heck are you supposed to make flatten work? Well,
the trick is to use abstraction to break the problem into
three smaller problems: flattening the first element of the
list (which could be an atom or a list) into a linear list,
flattening the rest of the list into a linear list, and then
combining the results into a single linear list. We already
know that the way we join two lists into a single list is by
using "append", so that problem is solved, and the other two
problems are handled with recursive calls to "flatten", so
the whole thing turns out to be pretty easy. Maybe the hardest
part is figuring out what the termination conditions are. Here's
one way to do it:
(defun flatten (mylist)
(cond ((null mylist) nil)
((listp (first mylist)) (append (flatten (first mylist))
(flatten (rest mylist))))
(t (cons (first mylist) (flatten (rest mylist))))))
The first test condition was pretty straightforward. If "flatten"
is passed an empty list, it just returns the empty list and
terminates. There's really not anything else to do, and that
empty list will just be "append"ed onto other lists, resulting
in no change whatsoever. In the second test condition, we test
to see if the first element of the input list is itself a list.
If so, we flatten that list, and since the result of flattening
is also a list, we can append the result of flattening the first
element of the list to the result of flattening the rest of the
list. In the final case, if the first element proved not to be
a list, then it must be an atom. Then we just cons that atom
onto the front of the result of flattening the rest of the list.
It doesn't seem nearly as difficult now, does it? Once you
start to believe that recursion works without a whole lot
of attention to it, then the solutions to these little problems
become a whole lot more intuitive. But again, it takes practice...
lots and lots of practice. And that's why your homeworks are
soooooo big.
Here's another version that I tend to think of when I think
of flattening something:
(defun flatten (my-list)
(cond ((null my-list) nil)
((atom my-list) (list my-list))
(T (append (flatten (first my-list))
(flatten (rest my-list))))))
It's the same as the one above, except we've just reversed the
second and third conditions.
The type of recursion that's going on here has several names.
It can be called "multiple recursion" or "tree recursion", or
it can be called "car/cdr recursion", which is a special kind
of multiple or tree recursion.
"Multiple recursion" is an appropriate name because, from the
point of view of our substitution model of evaluation, each
call to "flatten" is substituted on the program stack by
multiple calls to "flatten".
"Tree recursion" is an appropriate name if we think about how
the original problem is broken down: we start by trying to
flatten the original list; that problem becomes two problems
of trying to flatten two parts of that list; each of those
problems spins off two more problems, and so on. If you were
to sit down and draw a little graph of how the original
problem was divided into two subproblems, and each of those
was further divided, and on and on, you'd get yourself a nice
little tree-like structure. (Also, note that a hierarchical
list structure can be interpreted as a tree structure, and
that "flatten" can be viewed as a preorder tree traversal
procedure. But we'll see more on that later.)
Finally, "car/cdr recursion" is appropriate in this
particular use of tree recursion because the initial problem
is broken down into recursive calls of "flatten" on the
"first" (or "car") of the list and the "rest" or ("cdr") of
the list. Today, we might be better off calling it
"first/rest" recursion, but we'd lose that sense of history
and tradition.
The important thing to remember here is that multiple or tree
recursion is a very standard way to travel around really
complex data structures, or to solve some problems with
really complex decompositions. We'll be seeing this
recursion template pop up in lots of different places.
Lecture notes by Kurt Eiselt, 1998.
Minor changes / additions by Brian McNamara, 1998.
Last updated on
Fri Jul 10 22:56:39 EDT 1998
by Brian McNamara