Recursion, Revisited

Summary: We've learned a number of basic techniques for writing recursive functions, including the common pattern of recursion. But these techniques aren't always the clearest or most elegant for every case. Here, we extend your recursion toolbox to include a few more techniques, particularly the identification of special base cases and ways to write recursive predicates.

Contents:


Singleton Base Cases

Sometimes the problem that we need an algorithm for doesn't apply to the empty list, even in a vacuous or trivial way, and the base case for a direct recursion instead involves singleton lists -- that is, lists with only one element. For instance, suppose that we want an algorithm that finds the leftmost element of a given non-empty list of spots. (The list must be non-empty because there is no leftmost element of an empty list.)

> (spots.leftmost 
(list (spot.new 10 0 color.white)
(spot.new 3 5 color.white)
(spot.new 6 2 color.white)
(spot.new 1 50 color.white)
(spot.new 8 5 color.white)))

(1 50 -1)

The assumption that the list is not empty is a precondition for the meaningful use of this procedure, just as a call to Scheme's built-in quotient procedure requires that the second argument, the divisor, be non-zero. When you learn to write documentation for procedures, you should form the habit of noting and detailing such preconditions as you write the initial comment for a procedure.

;;; Procedure:
;;; spot-list.leftmost
;;; Parameters:
;;; spots, a list of spots.
;;; Purpose:
;;; Find the leftmost spot in spots.
;;; Produces:
;;; leftmost, a spot.
;;; Preconditions:
;;; spots is not empty.
;;; All the values in spots are spots. That is, each is a list of
;;; three values, two integers and an RGB color. The first
;;; integer represents the column, the second the row, and the
;;; third the color.
;;; Postconditions:
;;; leftmost is an element of spots (and, by implication, is a spot).
;;; For each spot in spots, leftmost is either in the same column as
;;; the spot, or to the left.

If a list of spots is a singleton, the answer is trivial -- its only element is its leftmost. Otherwise, we can take the list apart into its car and its cdr, invoke the procedure recursively to find the leftmost of the cdr, and then try to figure out which comes first. How do we figure whether or not one spot is to the left of another? We compare their columns. Let's use a helper procedure.

;;; Procedure:
;;; spot.leftmost
;;; Parameters:
;;; spot1, a spot
;;; spot2, a spot
;;; Purpose:
;;; Determine the leftmost of spot1 and spot2.
;;; Produces:
;;; leftmost, a spot.
;;; Preconditions:
;;; spot1 and spot2 have the correct form for spots.
;;; Postconditions:
;;; leftmost is equal to either spot1 or spot2.
;;; leftmost is either in the same column as both spot1 and spot2 or
;;; has the same column as one, and is to the left of the other.
(define spot.leftmost
(lambda (spot1 spot2)
(if (< (spot.col spot1) (spot.col spot2))
spot1
spot2)))

We can test whether the given list is a singleton by checking whether its cdr is an empty list. The value of the expression (null? (cdr spots)) is #t if spots is a singleton, #f if color has two or more elements.

Here, then, is the procedure definition:

(define spot-list.leftmost
(lambda (spots)
(if (null? (cdr spots))
(car spots)
(spot.leftmost (car spots) (spot-list.leftmost (cdr spots))))))

If someone who uses this procedure happens to violate its precondition, applying the procedure to the empty list, DrScheme notices the error and prints out a diagnostic message:

> (spot-list.leftmost null)
Error: cdr: argument 1 must be: pair

Singleton Values and Difference

If you think back to the tail-recursive version of difference, you may note another time that we had a special singleton case. When we compute n1 - n2 - n3 - nk, the base case is not we have nothing to subtract, but rather we have nothing to subtract from n1.

We didn't note the need for a singleton base case until we tried to write a tail-recursive version, but the need was there. Of course, that means that we might consider rewriting the non-tail-recursive version, but that version gave us the wrong answer, anyway.

Some Common Forms of Recursive Procedures

If you consider the examples you've studied over the past few days, you will see that there is a common form for most of the procedures. The form goes something like this

(define recursive-proc
(lambda (val)
(if (base-case-test?)
(base-case-computation val)
(combine (partof val)
(recursive-proc (simplify val))))))

For example, for the spot-list.leftmost procedure,

Similarly, consider the first complete version of sum.

(define sum
(lambda (numbers)
(if (null? numbers)
0
(+ (car numbers) (sum (cdr numbers))))))

In the sum procedure,

When you write your own recursive procedures, it's often useful to start with the general structure and then to fill in the pieces. When you are recursing over lists (as you have in our first explorations of recursion), partof is almost always car and simplify is almost always codr. There's a bit more to the base-case-test, since we've used both (null? ___) and (null? (cdr? ___)). We may also find other techniques.

However, when you work with other kinds of information (as you will do soon), you'll have different techniques for extracting a piece of information, for simplifying the argument, and for deciding when you're done.

Note, also, that examples like filtering suggest a similar, but more complex structure for recursive procedures.

(define recursive-proc
(lambda (val)
(cond
((one-base-case-test?)
(one-base-case-computation val))
((another-base-case-test?)
(another-base-case-computation val))
...
((special-recursive-case-test-1?)
(combine-1 (partof-1 val)
(recursive-proc (simplify-1 val))))
((special-recursive-case-test-2?)
(combine-2 (partof-2 val)
(recursive-proc (simplify-2 val))))
...
(else
(combine (partof val)
(recursive-proc (simplify val)))))))

However, in practice you will find that you rarely have more than two base-case tests (and mostly one) and rarely more than two recursive cases.

When we write tail-recursive procedures, we simply use the result of the recursive call, and don't combine it with anything. Here's a simple tail recursive pattern.

(define procedure
(lambda (val)
(procedure-helper initial-result val)))
(define procedure-helper
(lambda (so-far remaining)
(if (base-case-test? remaining)
(final-computation so-far)
(procedure-helper (combine (part-of remaining) so-far)
(simplify remaining)))))

Using And and Or

Of course, these common forms are not the only way to define recursive procedures. In particular, when we define a predicate that uses direct recursion on a given list, the definition is usually a little simpler if we use and- and or-expressions rather than if-expressions. For instance, consider a predicate all-dark? that takes a given list of colors and determines whether all of them are dark. As usual, we consider the cases of the empty list and non-empty lists separately:

Thus all-dark? should return #t when the given list either is empty or has a dark first element and all dark elements after that. This yields the following definition:

;;; Procedure:
;;; rgb.all-dark?
;;; Parameters:
;;; colors, a list of rgb colors.
;;; Purpose:
;;; Determine whether all of the elements of a list of colors
;;; represent dark colors.
;;; Produces:
;;; all-dark?, a Boolean.
;;; Preconditions:
;;; All the values in the list are rgb colors.
;;; Postconditions:
;;; all-dark? is #t if all of the elements of values are dark.
;;; all-dark? is #f if at least one element is not dark.
(define rgb.all-dark?
(lambda (colors)
(or (null? colors)
(and (rgb.dark? (car colors))
(rgb.all-dark? (cdr colors))))))

When colors is the empty list, all-dark? applies the first test in the or-expression, finds that it succeeds, and stops, returning #t. In any other case, the first test fails, so all-dark? proceeds to evaluate the first test in the and-expression. If the first element of colors is not dark, the test fails, so all-dark? stops, returning #f. However, if the first element of colors is dark, the test succeeds, so all-dark? goes on to the recursive procedure call, which checks whether all of the remaining elements are dark, and returns the result of this recursive call, however it turns out.


Janet Davis (davisjan@cs.grinnell.edu)

Created September 29, 2007 based on http://www.cs.grinnell.edu/~rebelsky/Courses/CS151/2007F/Readings/recursion-revisited-reading.html
Last revised September 29, 2007