Goals: This laboratory provides further analysis of recursion and tail recursion-- giving examples of how non-tail-recursive procedures can be rewritten to become tail recursive.
The lab proceeds by considering several solutions to various problems:
Problem -- Sum: Find the sum of a list of numbers.
Solution 1:The first solution seems to follow a now-familiar recursive format:
(define sum (lambda (ls) (if (null? ls) 0 (+ (car ls) (sum (cdr ls))) ) ) )While this procedure works correctly, it is not terribly efficient either in terms of time or required memory within the machine. To understand why, we trace the execution of this procedure on the list (1 2 3 4).
Here, when we type (sum '(1 2 3 4)) the machine checks for a null list, recognizes that '(1 2 3 4) is not null, and goes the the else clause of the if. This means that sum will be called recursively with parameter (2 3 4). However, once (sum '(2 3 4)) is computed, we still will have to add 1 to the result to get the final answer. Thus, the machine will need to store 1 until the recursive step is completely done. Similar comments apply at each stage. Thus, when the machine finally evaluates (sum '()) and obtains 0, the sum has been called 5 times, and intermediate values are stored at each stage.
While this solution is correct, after the base case is computed (at the right of the above diagram), the machine must come back one call at a time, using previous results and making further computations.
Solution 2: The next solution adds a running sum parameter.
(define sum (lambda (ls) (sum-kernel ls 0) ) ) (define sum-kernel (lambda (ls running-sum) (if (null? ls) running-sum (sum-kernel (cdr ls) (+ running-sum (car ls))) ) ) )In this approach, recursion proceeds from (1 2 3 4) toward the null list (). However, once this base case is reached, the result (10) is returned directly by each preceding procedure call. In this case, the machine does not need to combine the result at one stage with values at a previous stage, so earlier values need not be stored during recursion. This direct return of a result following recursion is called tail recursion. The following diagram provides a graphical picture of this processing.
Since Scheme is sophisticated enough to identify when tail recursion is present, tail recursion can run particularly efficiently within Scheme.
Change the define statement to trace-define for both versions of sum above. (For the second version, also use trace-define to declare sum-kernel.) Run each version of sum on the list '(1 2 3 4), as in the above example. Comment on how the traces of these versions relate to the call diagrams above.
Problem -- Maximum: Find the maximum value within a list of numbers.
To find a maximum, there must be at least one item on the list. Otherwise, a maximum will be undefined. Thus, the base case arises when a list contains just one element.
Solution 1: The first solution is particularly unsophisticated:
(define maximum (lambda (ls) (cond ((null? (cdr ls)) (car ls)) ((< (car ls) (maximum (cdr ls))) (maximum (cdr ls))) (else (car ls)) ) ) )While this code produces the correct answer, it calls maximum recursively twice in the case that the largest value occurs later in the list.
Change define to trace-define and run the code for (maximum '(2 6 4)). How many times is maximum called? Explain briefly how this number is obtained.
Draw a diagram, such as the ones above, to trace the procedure calls for the procedure call (maximum '(2 6 4)). Be sure your diagram agrees with the count determined from step 2.
(define maximum (lambda (ls) (maximum-kernel (car ls) (cdr ls)) ) ) (define maximum-kernel (lambda (max-so-far lst) (cond ((null? lst) max-so-far) ((< (car lst) max-so-far) (maximum-kernel max-so-far (cdr lst))) (else (maximum-kernel (car lst) (cdr lst))) ) ) )
As before, change define to trace-define and run the code for (maximum '(2 6 4)). How many times is maximum called? Explain briefly how this number is obtained.
Draw a diagram, such as the ones above, to trace the procedure calls for the procedure call (maximum '(2 6 4)).
Explain why this approach is more efficient that the previous solution.
Solution 3: The following is a variation of the Solution 2.
(define maximum (lambda (ls) (maximum-kernel (car ls) (cdr ls)) ) ) (trace-define maximum-kernel (lambda (max-so-far lst) (if (null? lst) max-so-far (maximum-kernel (if (< (car lst) max-so-far) max-so-far (car lst)) (cdr lst) ) ) ) )
In this approach, the base case of the recursion is handled in one if statement. Also, since the recursive case always calls maximum-kernel with (cdr lst), the only question is which value should be used for the new maximum value in this call. Placing the if statement in the call as the first parameter clarifies the value to be used.
Again, change define to trace-define and run the code for (maximum '(2 6 4)). How many times is maximum called? Explain briefly how this number is obtained.
Draw a diagram, such as the ones above, to trace the procedure calls for the procedure call (maximum '(2 6 4)).
Compare the efficiency of Solution 3 with that of Solutions 1 and 2. What conclusions can you make about the various solutions? Which solution do you prefer? Justify your preference briefly.
Problem -- Average: Find the average of the numbers within a list.
Solution Outline: Since an average requires both a sum of the items and a count of the number of items present, any solution must do both tasks. A tail recursive approach to this problem uses parameters to keep a running sum and a running count of the number of items processed.
Comment: Since three results are desired, we must decide how these results should be returned. One natural approach would be to place all three results on a single list, with the maximum first, the minimum second, and the average third.
Write a tail recursive solution to this problem, using a single kernel procedure. The kernel procedure may have as many parameters as desired.
Work to be turned in:
This document is available on the World Wide Web as
http://www.math.grin.edu/~walker/courses/153.sp00/lab-tail-recursion.html