Fundamentals of Computer Science I: Media Computing (CS151.02 2007F)

Tail Recursion

This reading is also available in PDF.

Summary: You've now seen a bit of the basics of recursion as a program design strategy. We now consider an alternate technique for writing recursive procedures, one in which we carry along a partial result at each step. This technique is called tail recursion.

Contents:

Introduction

There are a few basic principles to writing recursive functions. As you may recall from the first reading on recursion, a recursive procedure is one that calls itself. To successfully write a recursive procedure, you need

A disadvantage of traditional recursion is that it is difficult to tell what is happening along the way. In the particular case of list recursion, we work all the way down to the end of the list, and then work our way back to the front of the list to compute a final result. Why not just compute the result as we go?

Detour: Scheme's Evaluation Strategy

Before we go much further, let's take a quick detour into an important concept: When Scheme has a nested expression to evaluate, how does it evaluate that expression? The technique it uses is pretty straightforward: In order to apply a procedure to parameters, those parameters must be in simple form (that is, values, rather than expressions to compute values). Hence, given (proc exp1 exp2 exp3), Scheme first evaluates exp1, exp2, and exp3 and then applies proc to the three resulting values.

In what order does it evaluate exp1, exp2, and exp3? It turns out that it depends on the implementation of Scheme. In our sample code, we'll always assume that Scheme evaluates a procedure from left to right.

Of course, for some operations (which we call syntax), Scheme uses a different evaluation strategy. For example, when evaluating an if, Scheme does not evaluate all three parameters. Instead, it evaluates them in a specified order (first the test, then only one of the consequent and alternate). Similarly, when evaluating an and or an or, Scheme evaluates the parameters one at a time, stopping as soon as it knows an answer (in the case of and, when it hits a #f; in the case of or, when it hits a #t).

But there's more. To understand how Scheme evaluates expressions, we must also understand how it applies the procedures you define and how it processes definitions in general.

The naming scheme that Scheme uses is relatively straightforward. Scheme keeps a table of correspondences between names and values. For each name, it stores a reference to the value, which means that two names can refer to the same value. (This is imporant to know, because if you change a value with a side-effecting function, then all references to that value seem to change.) If you write another definition using the same name, it changes the reference, but not the underlying value. When Scheme sees a name while evaluting an expression, it looks up the name in the table, and uses the value the name refers to.

The way procedures work takes advantage of this naming Scheme. When you call a procedure on some arguments, Scheme first updates the name table, mapping each name in the lambda to the corresponding argument. For example, if you apply (lambda (a b c) ...) to 3, 1, and 6, the table will now have a refer to 3, b refer to 1, and c refer to 6. After updating the table, Scheme evaluates the body of the procedure. When it finishes evaluating the body, it cleans up the name table, undoing any changes it made.

Why is the way Scheme evaluates and understands expressions important for recursion? First, it may help you understand how recursion works. Because if (or cond), delays the evaluation of the consequent and alternate, we can safely have a procedure call itself without worrying about it calling itself again and again and again, ad infinitum. Second, it clarifies how we can have the same names (the parameters) mean different things at different times.

An Alternative Sum

We'll consider the question of computing results as we go with the summation example from the previous reading. As you may have noted, an odd thing about the sum procedure is that it works from right to left. Traditionally, we sum from left to right. Can we rewrite sum to work from left to right? Certainly, but we may need a helper procedure (another procedure whose primary purpose is to assist our current procedure) to do so.

If you think about it, when you're summing a list of numbers from left to right, you need to keep track of two different things:

Hence, we'll build our helper procedure with two parameters, sum-so-far and remaining. We'll start the body with a template for recursive procedures (a test to determine whether to use the base case or recursive case, the base case, and the recursive case). We'll then fill in each part.

(define new-sum-helper
  (lambda (sum-so-far remaining)
     (if (test)
         base-case
         recursive-case)))

The recursive case is fairly easy. Recall that since remaining is a list, we can split it into two parts, the first element (that is, the car), and the remaining elements (that is, the cdr). Each part contributes to one of the parameters of the recursive call. We update sum-so-far by adding the first element of remaining to sum-so-far. We update remaining by removing the first element. To continue, we simply call new-sum-helper again with those updated parameters.

(define new-sum-helper
  (lambda (sum-so-far remaining)
     (if (test)
         base-case
         (new-sum-helper (+ sum-so-far (car remaining))
                         (cdr remaining)))))

The recursive case then gives us a clue as to what to use for the test. We need to stop when there are no elements left in the list.

(define new-sum-helper
  (lambda (sum-so-far remaining)
     (if (null? remaining)
         base-case
         (new-sum-helper (+ sum-so-far (car remaining))
                         (cdr remaining)))))

We're almost done. What should the base case be? In the previous version, it was 0. However, in this case, we've been keeping a running sum. When we run out of things to add, the value of the complete sum is the value of the running sum.

(define new-sum-helper
  (lambda (sum-so-far remaining)
     (if (null? remaining)
         sum-so-far
         (new-sum-helper (+ sum-so-far (car remaining))
                         (cdr remaining)))))

Now we're ready to write the primary procedure whose responsibility it is to call new-sum-helper. Like sum, new-sum will take a list as a parameter. That list will become remaining. What value should sum-so-far begin with? Since we have not yet added anything when we start, it begins at 0.

(define new-sum
  (lambda (numbers)
    (new-sum-helper 0 numbers)))

Putting it all together, we get the following.

;;; Procedure:
;;;   new-sum
;;; Parameters:
;;;   numbers, a list of numbers.
;;; Purpose:
;;;   Find the sum of the elements of a given list of numbers
;;; Produces:
;;;   total, a number.
;;; Preconditions:
;;;   All the elements of numbers must be numbers.
;;; Postcondition:
;;;   total is the result of adding together all of the elements of numbers.
;;;   If all the values in numbers are exact, total is exact.
;;;   If any values in numbers are inexact, total is inexact.
(define new-sum
  (lambda (numbers)
    (new-sum-helper 0 numbers)))

;;; Procedure:
;;;   new-sum-helper
;;; Parameters:
;;;   sum-so-far, a number.
;;;   remaining, a list of numbers.
;;; Purpose:
;;;   Add sum-so-far to the sum of the elements of a given list of numbers
;;; Produces:
;;;   total, a number.
;;; Preconditions:
;;;   All the elements of remaining must be numbers.
;;;   sum-so-far must be a number.
;;; Postcondition:
;;;   total is the result of adding together sum-so-far and all of the 
;;;     elements of remaining.
;;;   If both sum-so-far and all the values in remaining are exact, 
;;;     total is exact.
;;;   If either sum-so-far or any values in remaining are inexact, 
;;;     total is inexact.
(define new-sum-helper
  (lambda (sum-so-far remaining)
     (if (null? remaining)
         sum-so-far
         (new-sum-helper (+ sum-so-far (car remaining))
                         (cdr remaining)))))

Watching New Sum

Does this change make a difference in the way in which the sum is evaluated? Let's watch.

   (new-sum (cons 38 (cons 12 (cons 83 null)))) 
=> (new-sum-helper 0 (cons 38 (cons 12 (cons 83 null)))) 
=> (new-sum-helper (+ 0 38) (cons 12 (cons 83 null)))
=> (new-sum-helper 38 (cons 12 (cons 83 null)))
=> (new-sum-helper (+ 38 12) (cons 83 null))
=> (new-sum-helper 50 (cons 83 null))
=> (new-sum-helper (+ 50 83) null)
=> (new-sum-helper 133 null)
=> 133

Note that the intermediate results for new-sum were different, primarily because new-sum operates from left to right.

The Technique, Generalized

Okay, how does one write one of these procedures? First, you build a helper procedure that takes as parameters the list you're recursing over, which we've called remaining, a new parameter, ____-so-far, and any other useful parameters. The helper recurses over the list, updating that list to its cdr at each step and updates ____-so-far to be the next partial result.

Then you build the main procedure, which calls the helper with an appropriate initial ____-so-far and remaining. For example,

(define proc
  (lambda (lst)
    (proc-helper initial-value lst)))
(define proc-helper
  (lambda (so-far remaining)
    (if (null? lst)
        so-far
        (proc-helper (combine so-far (car remaining))
                     (cdr remaining)))))

However, as we'll see in the next section, you sometimes need to use different initial values for so-far and lst.

Another Application: Difference

We've now seen two strategies for doing recursion: We can combine the result of a recursive call into a final result, or we can pass along intermediate results as we recurse, and stop with the final result. Which should you use? For many applications, it doesn't matter. However, there are times that one technique is much more appropriate than the other. Consider the problem taking a list of numbers, n1, n2, n3, ... nk-1, nk, and computes n1 - n2 - n3 - ... - nk-1 - nk.

Suppose we use the first technique. We might write:

(define difference
  (lambda (lst)
    (if (null? lst)
        0
        (- (car lst) (difference (cdr lst))))))

Unfortunately, this doesn't quite work as you might expect. Consider the computation of (difference (list 1 2 3)). As you may recall, subtraction is left associative, so this should be (1-2)-3, or -4. Let's see what happens.

   (difference (cons 1 (cons 2 (cons 3 null))))
=> (- 1 (difference (cons 2 (cons 3 null))))
=> (- 1 (- 2 (difference (cons 3 null))))
=> (- 1 (- 2 (- 3 (difference null))))
=> (- 1 (- 2 (- 3 0)))
=> (- 1 (- 2 3))
=> (- 1 -1)
=> 2

Hmmm ... that's not quite right. So, let's try the other technique.

(define new-difference
  (lambda (lst)
    (new-difference-helper 0 lst)))
(define new-difference-helper
  (lambda (difference-so-far remaining)
    (if (null? remaining)
        difference-so-far
        (new-difference-helper (- difference-so-far (car remaining))
                               (cdr remaining)))))

Okay, what happens here?

   (new-difference (list 1 2 3))
=> (new-difference-helper 0 (cons 1 (cons 2 (cons 3 null))))
=> (new-difference-helper (- 0 1) (cons 2 (cons 3 null)))
=> (new-difference-helper -1 (cons 2 (cons 3 null)))
=> (new-difference-helper (- -1 2) (cons 3 null))
=> (new-difference-helper -3 (cons 3 null))
=> (new-difference-helper (- -3 3) null)
=> (new-difference-helper -6 null)
=> -6

Nope. Still incorrect. So, what happened? We started with 0, then we subtracted 1, then we subtracted 2, then we subtracted 3. It looks like we did those last two subtractions in order. But wait! Why are we subtracting 1? That's the value we're supposed to subtract everything else from. That means we need to choose a better initial difference-so-far and remaining. Why not make the car the difference-so-far and the cdr remaining?

(define newer-difference
  (lambda (lst)
    (new-difference-helper (car lst) (cdr lst))))

Does this work?

   (newer-difference (list 1 2 3))
=> (new-difference-helper 1 (cons 2 (cons 3 null)))
=> (new-difference-helper (- 1 2) (cons 3 null))
=> (new-difference-helper -1 (cons 3 null))
=> (new-difference-helper (- -1 3) null)
=> (new-difference-helper -4 null)
=> -4

It works! Well, it works for this example. We'll see if it works for other examples.

 

History

 

Disclaimer: I usually create these pages on the fly, which means that I rarely proofread them and they may contain bad grammar and incorrect details. It also means that I tend to update them regularly (see the history for more details). Feel free to contact me with any suggestions for changes.

This document was generated by Siteweaver on Mon Dec 3 09:54:29 2007.
The source to the document was last modified on Mon Oct 1 10:33:05 2007.
This document may be found at http://www.cs.grinnell.edu/~rebelsky/Courses/CS151/2007F/Readings/tail-recursion-reading.html.

You may wish to validate this document's HTML ; Valid CSS! ; Creative Commons License

Samuel A. Rebelsky, rebelsky@grinnell.edu

Copyright © 2007 Janet Davis, Matthew Kluber, and Samuel A. Rebelsky. (Selected materials copyright by John David Stone and Henry Walker and used by permission.) This material is based upon work partially supported by the National Science Foundation under Grant No. CCLI-0633090. Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation. This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/2.5/ or send a letter to Creative Commons, 543 Howard Street, 5th Floor, San Francisco, California, 94105, USA.