;; hilo: num, num --> num ;; Return the hidden number, and integer in ;; in the range [lo, hi]. (That is, the range including lo and hi.) ;; (define (hilo-v1 lo hi) (local [(define mid (floor (/ (+ hi lo) 2))) (define answer (ask-ian mid lo hi))] (cond [(= 0 (- hi lo)) lo] [(equal? > (first answer)) (hilo-v1 lo mid)] [(equal? <= (first answer)) (hilo-v1 mid hi)] [else (error 'hilo-v1 "something wrong.")]))) ;; ask-ian: num, num, num --> (list {>, <=} num 'target) ;; Play the part of the MC: ;; Given a guess, tell wehtehr it's greater than the target, ;; or less-or-equal the target. ;; ;; The last two inputs (lo,hi) aren't actually used, ;; but the MC prints out annoying messages which include them ;; (helpful for debugging). ;; (define (ask-ian guess lo hi) ...) (define MAX 1000) (define MIN 0) (hilo-v1 MIN MAX)Although it sometimes works (on inputs 250 and 125), it didn't always work (on input 374) -- it got in an infinite loop, knowing the answer was in [374,375], and repeatedly guessing 375.
There are several ways to try to fix this.
When dealing with ranges of numbers from a..b, it's convenient to have your functions use the half-open interval [a,b+1). This is convenient for several reasons:- [lo,mid) and [mid,hi) are exactliy the interval [lo,hi); (Rather than [lo,mid],[mid+1,hi]; deciding when you need to add or subtract one from a range is annoying, and occurs less often when you use the half-open interval.
- The size of the interval is (hi-lo), not (hi-lo+1). It also makes the empty interval [a,a), rather than [a,a-1].
- You still need to think a bit about computing the midpoint -- indeed (lo+hi)/2, with fractions truncated down (floor)
Here's the fixed version, which happens to use the last of these approaches:
(define (hilo-v2 lo hi) (local [(define mid (floor (/ (+ hi lo) 2))) (define answer (ask-ian mid))] (cond [(<= (- hi lo) 1) lo] [(equal? > (first answer)) (hilo-v2 lo mid)] [(equal? <= (first answer)) (hilo-v2 mid hi)] [else (error 'hilo-v2 "something wrong.")]))) (hilo-v2 MIN (add1 MAX)) ;; Argument of termination: ;; hi-lo is a smaller integer on every call: ;; - If hi-lo >= 2, then [lo,mid) and [mid,hi) are each *strictly* ;; smaller than [lo,hi), and we recur on one of these ranges. ;; (If hi-lo >= 2, then mid = (hi+lo)/2 = lo + (hi-lo)/2 is at least ;; 1 bigger than lo, and hi - (hi-lo)/2 is at least 1 less than hi, ;; so taking the floor it will still be at least 1 away from ;; each of lo,hi.) ;; - if hi-lo < 2, we terminate. ;; ;; A FAULTY argument of termination: ;; - Every time we recur on a smaller range, ;; so eventually we'll hone in on the answer, ;; and taking the floor still narrows the range. ;; This is WRONG since it applies equally well to our first, ;; buggy version.But the real up-shot is not how to fix this one particular program; the question becomes "if we don't recur on sub-structures of the input, how do we know when to stop?"
Let's consider another example that fundamentally does not follow the template:
Have you heard of fractals? Fractals are objects with fractal dimension. They are interesting to us because they show similar structure on several different scales. Let's consider a simple example of a fractal, known as the Sierpinski triange (see the section on fractals, page 367 in the text, for the picture of the Sierpinski triangle).
We want to write a program that consumes the three vertices of the original (equilateral) triangle and draws the Sierpinski triangle, returning true. The program only draws triangles whose sides are longer than a certain small threshold, say 3. Assume you have a function draw-triangle, which consumes three points, draws a triangle between them, and returns true.
How do we get started? Well, our description of the problem suggests two cases: one if a side of the triangle is smaller than three and one if it is not. Let's defer the size check to a helper function for now:
(define (sierpinski p1 p2 p3) (cond [(too-small? p1 p2 p3) true] [else ... (draw-triangle p1 p2 p3) ...]))How do we fill in the else case? The problem said that we need to find the midpoints of the three sides, then fill in the three outer triangles. The following program does this:
(define (sierpinski p1 p2 p3) (cond [(too-small? p1 p2 p3) true] [else (local ((define p1-p2 (mid-point p1 p2)) (define p2-p3 (mid-point p2 p3)) (define p3-p1 (mid-point p1 p3))) (and (draw-triangle p1 p2 p3) (sierpinski p1 p1-p2 p3-p1) (sierpinski p2 p1-p2 p2-p3) (sierpinski p3 p3-p1 p2-p3)))]))(see entire code)
Mergesort:
We saw insert-sort as followed from template.
But are there other ways of sorting?
Yes.
Consider:
Divide your stack into two halves;
sort each half (recursively);
then merge together the two halves.
What is the base-case -- that is,
for what lists are we done before we start?
(See entire code.
Note that we've already written
merge
in class, and
unzip
in homework.)
Note on efficiency: Usually a factor of two doesn't matter. Even a better speedup -- (as is achieved by switching from insert-sort to mergesort: see lab) -- is useful only if your code spends most of its time in the improved function. Sorting is one of the few cases where this is true: Search engines and nintendos often sorts list of hundreds-of-thousands, or millions. So sorting is one of the few problems were people spend a lot of time being clever to get things faster. The rest of the time, you'll spend more time writing the "sped-up" code (and debugging it), only to find that your code isn't as sped-up as you thought, or that it has minimal impact on the overall program because that function is called only occasionally. (See also book discussion on generative recursive algorithms vs structrual.)
Where did the recursion come from in the programs that we've been writing all semester? From the data. We used recursively defined data, so we needed recursive programs to process that data. Where does the recursion come from in today's programs? From the problems that we are writing programs to solve (even though the data is also recursively defined, as in the case of qsort).
Both of today's programs use a common problem solving technique called "divide-and-conquer": we take a problem, break it into smaller instances of the same problem, solve the smaller instances, and combine the results. We use recursion because we are solving instances of the same problem, but we cannot break the problem into pieces based solely on the structure of the data. Instead, we break the problem into pieces based on the insights that we have about the problem itself.
Thus, today we introduce a new form of recursion called "generative recursion". In generative recursion, we generate new instances of a problem based on some creative insight and solve them recursively. Our prior problems use what we call "structural recursion": the recursion comes solely from the recursive structure of the data.
We know the design recipes for structural recursion. What about
generative recursion? Let's look at qsort
and
sierpinski
and try to
find some commonality in their organization. Notice that both
programs use a cond
,
with one case for "the problem is small enough to solve immediately",
and another for "decompose the problem into smaller problems".