Last class, we saw our first examples of generative recursion. We are going to start this class by discussing some aspects of the relationship between structural and generative recursion. First, the design recipe. How does our design recipe for structural recursion carry over to generative recursion? The structural design recipe had the following steps: 1. Data analysis and design, including examples of data 2. Contract, purpose, header 3. Examples/Test cases 4. Template 5. Body 6. Test Which of these still make sense in generative recursion? Actually, everything but the template does. We still need to understand our data, even if we plan to process it generatively. Similarly, contracts, purposes, and headers apply to all programs. Therefore, we can reuse most of the design recipe, with a few small modifications. First, when we write examples, we should write two kinds of examples: our usual test cases are one kind. The other kind are examples of how the program operates (as in the diagrams that we drew in class to demonstrate qsort). Second, the template changes a bit, as we discussed in the last class. The new template takes the following form (from page 357 of text): (define (generative-recursive-fun problem-data) (cond [(trivially-solvable? problem-data) (determine-solution problem-data)] [else (combine-solutions problem-data (generative-recursive-fun (generate-problem1 problem-data)) ... (generative-recursive-fun (generate-problemN problem-data)))])) This is a slightly different style of template. In the old template, we just plugged in holes. In the new template, we replace the parts of the template with code that does what that part of the template requires. What sorts of questions are useful to ask? - What is a trivially solvable problem? - What is the corresponding solution? - How do we generate new problems that are easier to solve than the original problem? - Do we generate one new problem or several new problems? - Is the solution to a new problem the solution of the original problem, or do we need to combine results from new problems? Answering these questions tells us how to fill in the template for generative recursive programs. This gives us a revised design recipe for programs that use generative recursion: 1. Data analysis and design, including examples of data 2. Contract, purpose, header 3. Test cases and examples of the program 4. The Generative Template 5. Body 6. Test With this in hand, let's write another program. We'll write a program to implement a simple number guessing game. The program consumes two numbers (a low value and a high value) and produces a number. The program runs until it guesses a "hidden" number; when it guesses correctly, it returns the "hidden" number. Most of us have played this game before: we guess the number in the middle to get to the hidden number as quickly as possible. This approach is known as binary search; it arises frequently in programming problems. Assume we have a helper function guess that takes a number, compares it to the hidden number, and returns one of 'higher, 'lower, or 'equal, depending on the relative value between the input number and the hidden number. ;; guess: num -> 'higher or 'lower or 'equal ;; Given a number, tells if the hidden number is higher, lower, or equal. ;; hi-lo: int int -> int ;; Given a low & high bound, return the hidden number (in [lo,hi]). (define (hi-lo lo hi) (local [(define mid (/ (+ lo hi) 2)) (define answer (guess mid))] (cond [(symbol=? answer 'equal) mid] [(symbol=? answer 'higher) (hi-lo (truncate mid) hi)] [(symbol=? answer 'lower) (hi-lo lo (truncate mid))]))) Let's try this program, with a hidden number of 3 (hi-lo 0 15) mid=7.5 = (hi-lo 0 7) mid=3.5 = (hi-lo 0 3) mid=1.5 = (hi-lo 1 3) mid=2 = (hi-lo 2 3) mid=2.5 = (hi-lo 2 3) now we're in an infinite loop What went wrong here? Our method for generating new problems failed to generate a new problem -- it generated an old problem again, so the program fails to terminate. Why was this never a problem in structural recursion? Because recursive calls always operated on a smaller piece of data (such as the rest of the list) than the original call. This example demonstrates that, when using generative recursion, we can more easily write programs that fail to terminate. This is bad, and requires an extra piece of documentation in the design recipe: a termination argument. The termination argument should explain why your divide-and-conquer approach will eventually yield a trivially-solvable problem. You should write it as a comment just after the description of the program examples. What could we use as a termination argument on sierpinski from last class? Something like the following would do: "at each step, sierpinski partitions the input triangle into three triangles whose sides are strictly smaller than those of the original. The trivial problem test checks whether the sides are smaller than a certain threshold. Since the sides grow smaller with each recursive call, the trivial problem test must eventually succeed, so the algorithm must terminate." How would we fix hi-lo such that we could write a reasonable termination condition? One idea is to check whether the number we are trying to guess is one of the endpoints: (define (hi-lo lo hi) (cond [(symbol=? (guess lo) 'equal) lo] [(symbol=? (guess hi) 'equal) hi] [else (local [(define mid (/ (+ lo hi) 2)) (define answer (guess mid))] (cond [(symbol=? answer 'equal) mid] [(symbol=? answer 'higher) (hi-lo (truncate mid) hi)] [(symbol=? answer 'lower) (hi-lo lo (truncate mid))]))])) What would the termination argument look like? Something like: "at each step, hi-lo checks whether the number to guess is one of the endpoints of the range. If not, then the number must be strictly between the endpoints. If there is a number strictly between the endpoints, then the recursive call to hi-lo must be over a strictly smaller range. If the range gets strictly smaller on each call, it must eventually find the number, or reach a case where the endpoints are one number apart. At that point, the number to guess must be one of lo or hi and the algorithm will reach the trivially solvable case and terminate." Here's another way that we could have fixed hi-lo. Notice in the trace we did earlier that 3 showed up as the boundary point early in the computation. Why didn't our check of mid catch this? Because mid had value 3.5 instead of 3. If we let mid take non-integer values, we're guaranteeing that guess can't produce 'equal. What if we instead wrote hi-lo to check the truncated mid, instead of mid? (define (hi-lo lo hi) (local [(define mid (truncate (/ (+ lo hi) 2))) (define answer (guess mid))] (cond [(symbol=? answer 'equal) mid] [(symbol=? answer 'higher) (hi-lo mid hi)] [(symbol=? answer 'lower) (hi-lo lo mid)]))) (hi-lo 0 15) mid=7 = (hi-lo 0 7) mid=3 = 3 (because guess returned 'equal) What's the termination argument for this version? It should be somewhat similar. Notice that after each call, the new values of lo and hi are either the original endpoints or values of mid. Since we check our guess against each value of mid, we should always terminate. Thus, this version should give us the desired behavior, without our having to check the values of lo and hi on each pass. This argument has a flaw though. If an endpoint came from a computation of mid, we do indeed check it. What if our guess is one of the endpoints though? Will the program still terminate? Let's try it with hidden number 0 in the range 0..2 (hi-lo 0 2) mid=1 = (hi-lo 0 1) mid=0 = 0 This seems fine. What's happening here? When the range gets to two consecutive numbers, mid becomes the lower number. Therefore, the original lo endpoint is not a problem. What about the original hi endpoint? Let's try again with the hidden number being 2. (hi-lo 0 2) mid=1 = (hi-lo 1 2) mid=1 = (hi-lo 1 2) ... oops So, this tells us that we need a special case for the original hi endpoint, but the current algorithm will catch all other guesses. Here's the corrected program with a termination statement. ;; hi-lo: int int -> int ;; Given a low & high bound, return the hidden number (in [lo,hi]). ;; ;; Termination: On each recursive call to hi-lo-help, lo and hi are ;; either one of the original endpoints or a value computed as mid. ;; The program explicitly checks all values of mid and the original hi ;; endpoint against the guess. Thus, the program will terminate if ;; the hidden number must become one of these values. On each ;; recursive call, the range between lo and hi gets smaller and always ;; includes the hidden number. Thus, the smallest case we can reach ;; has lo and hi being consecutive numbers. If the hidden number is ;; lo, the algorithm will terminate when it calculates mid because the ;; mid of lo and lo+1 is lo. If the hidden number is hi, the ;; algorithm will terminate because the hi endpoint is either the ;; original hi endpoint (which we checked explicitly) or some value of ;; mid, which we also checked explicitly. (define (hi-lo lo hi) (local [(define (hi-lo-help lo hi) (local [(define mid (truncate (/ (+ lo hi) 2))) (define answer (guess mid))] (cond [(symbol=? answer 'equal) mid] [(symbol=? answer 'higher) (hi-lo-help mid hi)] [(symbol=? answer 'lower) (hi-lo-help lo mid)])))] (cond [(symbol=? (guess hi) 'equal) hi] [else (hi-lo-help lo hi)]))) Yet another common way to fix hi-low would be to have the recursive calls exclude mid from the new range, as follows: (define (hi-lo lo hi) (local [(define mid (truncate (/ (+ lo hi) 2))) (define answer (guess mid))] (cond [(symbol=? answer 'equal) mid] [(symbol=? answer 'higher) (hi-lo (add1 mid) hi)] [(symbol=? answer 'lower) (hi-lo lo (sub1 mid))]))) This version has the advantage of an easier termination condition. ;; Termination: The range between the numbers gets strictly smaller on ;; every iteration. In the worst case, the hi and lo values ;; eventually become the same (in which case the hidden number is ;; hi=lo=mid), in which case the algorithm terminates. This exercise demonstrates the benefit in thinking about termination justifications. In this case, thinking about termination located an error in our program. The extent to which you think about termination conditions and other aspects of whether your programs are correct determine whether you are a recreational or professional programmer. Many students are uneasy with the creative aspect of generative recursion: if you don't see how to divide the problem, you can't write a generative recursive solution. Generative recursive programs span a whole spectrum of problems, some for which the divide-and-conquer step is easy (as in hi-lo), and others for which it takes real genius and insight. This makes generative recursive solutions both fun and sometimes difficult. How often will you need to write generative recursive programs? Most problems have structural recursive solutions. Consider sort. In lab, you wrote a sort using structural recursion. The generative recursion sorting algorithms are faster, but that might not matter to your context. As a rule of thumb, look for structural recursive solutions first. If they prove to be too slow for your needs, try to think of a generative solution. Otherwise, use the structural solution and be content that it took you less time to develop. We spent the rest of class starting on find-root, which is described in detail starting on page 376 of the text.