CS 2360 - January 22, 1998

Lecture 6 -- More Fun with Recursion


List-consing recursion (a type of augmenting recursion)

LISP's "append" function is a very commonly used function.
It takes two lists as arguments and joins them together:

? (append '(a b) '(c d))
(A B C D)
?

It's not immediately obvious how to implement this...it takes
a little thought.  Folks just sort of naturally try to go at
it by getting to the last element of the first list and
tacking it on to the second one, then somehow going backwards
along the first list to tack on the previous element, and so
on.  But we need to take advantage of recursion here, along
with the fact that as recursion postpones computations, those
computations actually get performed in the reverse of the
order in which they were postponed (last-in-first-out or
LIFO).  Thus, we don't really want to go to the end of the
first list and work backward---we want to start with the
first element of the first list and work forward, postponing
our operations as we go.  Another insight that is useful here
is to think of some simpler operations that might get us this
same result, it becomes a little more obvious.  Consider that

? (cons 'a (cons 'b '(c d)))
(A B C D)
?

gets us the same result, and suddenly the light goes on:

(defun my-append (my-list1 my-list2)
  (cond ((null my-list1) my-list2)
        (T (cons (first my-list1)
                 (my-append (rest my-list1) my-list2)))))

What makes this particular template of interest is the nature
of the augmenting recursion.  The computation being done
outside of the recursive call is a "cons".  In this case, the
elements of the first list are being "cons"ed onto the second
list, resulting in a new structure combining the two original
structures.  List consing recursion is a commonly-used way of
constructing new structures from old ones.


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)
?

How the heck are you supposed to make that happen?  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.



Copyright 1998 by Kurt Eiselt.  All rights reserved.

Last revised: January 26, 1998