|
Comp210: Principles of Computing and Programming
|
For review, go to the previous lecture.
Let's try something a bit harder than we did last time:
First, because we're going to need it later, let's write a function to append an element to the end of a list:
;;appendTo: list-of-any any --> list-of-any ;; returns a new list which is the original list with dat appended dat to the end (define (appendTo a-list dat) ...) "appendTo test cases:" (equal? (cons 'z empty) (appendTo empty 'z)) (equal? (list 42 'a) (appendTo (list 42) 'a)) (equal? (list 'a 'b 'c 'd) (appendTo (list 'a 'b 'c) 'd))
How is this like an accumulator style (forward accumulation) algorithm?
Reminder: The difference between using forward vs. reverse accumulation is the difference between whether or not your algorithm needs to move information (data) from the front of the list to the end of the list (forward accumulation) or from the end of the list to the front of the list (reverse accumulation). Some algorithms use both forward and reverse accumulation, such as (arguably) above or say, removing the minimum or maximum element from a list.
Now let's write a function, pathTo, that traces ancestory back to a person with a specified name. Suppose here is Bart's family tree:
Bart 1979 |_ Marge 1956 | |_ Jackie 1926 | | |_ ? | | |_ Bubba 1908 | | |_ ? | | |_ ? | |_ ? |_ Homer 1955 |_ Mona 1929 | |_ ? | |_ ? |_ Abe 1920 |_ ? |_ Bubba 1900 |_ ? |_ ?
pathTo test cases:
(equals? (list 'Bart 'Homer 'Mona) (pathTo Bart 'Mona))
(equals? (list 'Bart 'Marge 'Jackie 'Bubba) (pathTo Bart 'Bubba)) ;; We'll take the first path we find
(equals? (list 'Bart 'Homer 'Abe) (pathTo Bart 'Abe))
(equals? empty (pathTo Bart 'Zoe)) ;; No path can be found
One way to think about the problem is as an accumulator that accumulates the path on the way down to the desired ancestor: Note that the name at any level must be appended to the path accumulated so far:
;;pathTo: FamTree sym --> list of sym ;; Returns a list of names encountered on the path through the FamTree from the beginning until ;; and including when name is encountered. ;; If name is not in the tree, then empty is returned. (define (pathTo ft name) (cond [(Unknown? ft) empty] [(Child? ft) (pathTo_help ft name empty)])) ;; pathTo_help: FamTree sym list-of-sym --> list-of-sym ;;Returns a list which is the path of names from the top of the supplied tree ;;to and including the tree whose name matches the suppled name appended ;; after the supplied path. ;; returns an empty list if the name is not found in the tree. (define (pathTo_help ft name path) (cond [(Unknown? ft) empty] [(Child? ft) (cond [(symbol=? name (Child-name ft)) (appendTo path name )] [(empty? (pathTo_help (Child-ma ft) name (appendTo path (Child-name ft)))) ;; If not down ma branch, go to pa branch (pathTo_help (Child-pa ft) name (appendTo path (Child-name ft))) ] [else (pathTo_help (Child-ma ft) name (appendTo path (Child-name ft)))])])) ;; it was down the ma branch
Can this algorithm be done without an accumulator? Sure, if we think of building the path on the way back out of the recursion after we have found the name:
;; Alternative implementation of pathTo that doesn't use an accumulator. (define (pathTo2 ft name) (cond [(Unknown? ft) empty] [(Child? ft) (cond [(symbol=? name (Child-name ft)) (cons name empty)] [(empty? (pathTo2 (Child-ma ft) name)) (cond [(empty? (pathTo2 (Child-pa ft) name)) empty] [else (cons (Child-name ft) (pathTo2 (Child-pa ft) name))])] [else (cons (Child-name ft) (pathTo2 (Child-ma ft) name))])])) ;; Alternative implementation of pathTo2 with simplified conditionals. (define (pathTo3 ft name) (cond [(Unknown? ft) empty] [(Child? ft) (cond [(symbol=? name (Child-name ft)) (cons name empty)] [(cons? (pathTo3 (Child-ma ft) name)) (cons (Child-name ft) (pathTo2 (Child-ma ft) name))] [(cons? (pathTo3 (Child-pa ft) name)) (cons (Child-name ft) (pathTo3 (Child-pa ft) name))] [else empty])])) Note that the original accumulator implementation could have used a similar cond structure as well.
Are these implementations "better"? It depends on whether or not you think they violate the First Law of Programming.
The code for the above functions can be downloaded here: familytree2.scm
And now for something completely different...
What if a function took two input parameters, both of which were recursively defined. Hmmm....that presents a problem our templates haven't dealt with yet.
Let's look at the simple example appending one list onto another:
;;appendList: list-of-any list-of-any --> list-of-any ;; appends list2 to the end of list 1. ;; If either list is empty, the other list is returned. (define (appendList list1 list2) (cond [(empty? list1) list2] [(cons? list1) (cons (first list1) (appendList (rest list1) list2))])) "appendList test cases:" (equal? empty (appendList empty empty)) (equal? (list 1 2) (appendList empty (list 1 2))) (equal? (list 3 4) (appendList (list 3 4) empty)) (equal? (list 'a 'b 'c 'd 'e) (appendList (list 'a 'b) (list 'c 'd 'e)))
The fact that list2 is recursively defined didn't come into play here. It was just along for the ride, to be inserted "en masse" at the end of list1.
Incidentally, appendList is built into Scheme and is called simply, "append".
Not all functions with non-trivial inputs treat their inputs as anything more than trivial.
So let's move onto something a little more complicated, such as merging two lists of numbers together. Merge assumes that the two lists it is given are already sorted in descending order. Let's look at the test cases first:
(define myList1 (list 5 4 1)) (define myList2 (list 6 3)) "merge test cases:" (equal? empty (merge empty empty)) (equal? myList1 (merge myList1 empty)) (equal? myList2 (merge empty myList2)) (equal? (list 6 5 4 3 1) (merge myList1 myList2)) (equal? (list 6 5 4 3 1) (merge myList2 myList1))
To analyze, what merge must do, we do a "process flow analysis" which is just a fancy term to mean that we walk through the process and think about what needs to be done at each step. So, we know that for one list, there is a fine template to be used, so we will simply ignore the second list for a bit:
;; merge: list-of num list-of-num --> list-of-num ;; Takes two list-of-nums already sorted in descending order ;; and creates a new list with all the values of both lists combined ;; in descending order. (define (merge lon1 lon2) (cond [(empty? lon1) lon2] ;; It doesn't matter what lon2 is here. [(cons? lon1) (merge_help lon1 lon2)])) ;; delegate to another function
The empty case is easy. The cons case is harder because it depends on what lon2 is. So, rather than violate encapsulations, we simply delegate to some other function, merge_help.
;; merge_help: cons list-of-num --> list-of-num ;; Takes two list-of-nums already sorted in descending order ;; and creates a new list with all the values of both lists combined ;; in descending order. ;; Merge_help only checks lon2 for empty/cons and thus uses merge to check both ;; lon1 and lon2 when needed. lon1 is assumed to be a cons list. (define (merge_help lon1 lon2) (cond [(empty? lon2) lon1] [(cons? lon2) (cond [(> (first lon1) (first lon2)) (cons (first lon1) (merge (rest lon1) lon2))] [else (cons (first lon2) (merge lon1 (rest lon2)))])]))
Here, we check for the type of . The lon2 empty case is trivial once again and the cons case takes a little more work. Depending on which first is larger, the recursion is down lon1 or lon2. Notice that that recursive call is to merge, not merge_help because only merge will (eventually) check the types of both input lists.
We will see more of this type of function later, but for now, let's take a closer look at what's really going on. We could define a template for this type of function:
;; Process Flow Templates for a function of two lists ;; f-2lists: list list --> ... ;; work on list1, leave list2 encapsulated (define (f-2lists list1 list2) (cond [(empty? list1) (f_empty_help list2)] [(cons? list1) (f_cons_help list1 list2)])) ;; f-empty_help: list --> ... ;; list1 known to be empty, work on list2 (define (f-empty_help list2) (cond [(empty? list2) ...] [(cons? list2) ... (first list2)... (rest list2)...])) ;; f-cons_help: cons list --> ... ;; list1 known to be non-empty, work on list2. (define (f-cons_help list1 list2) (cond [(empty? list2) ...(first list1)...(rest list1)...] [(cons? list2) ...(first list1)...(rest list1)... (first list2)... (rest list2)...]))
Note that in general, the initial empty case delegates to another function as well as the cons case. Process flow analysis is an example of "delegation model" style programming.
Notice also, that the first function is 100% written already!
Now, this is all well and good, and is guaranteed to work, but could it be simplified?
Yes.....but perhaps at a price....
Merge treats the two input lists identically, so we have to think about what cases are involved here:
The table shows the two cases for both lists and what merge should return in each of the 2 x 2 = 4 total cases:
List1
|
||||
List2 |
Empty
|
Cons
|
||
Empty
|
Empty
|
List1
|
||
Cons
|
List2
|
merged
|
Fundamentally, we see that each list has two cases and for each case for one list, there are the two cases of the other list, so there are2 x 2 = 4 cases, and that our cond statement should reflect that structure. That is, (ignoring the rest of the code body) the cond statement in the template should look like this:
;; Multi-way cond Template (define (f-2lists list1 list2) (cond [(and (empty? list1)(empty? list2)) ...] [(and (cons? list1)(empty? list2)) ...] [(and (empty? list1)(cons? list2)) ...] [(and (cons? list1)(cons? list2)) ...]))Merge is thus (renamed so that we won't run into trouble with DrScheme):
;; merge1: list-of num list-of-num --> list-of-num ;; Takes two list-of-nums already sorted in descending order ;; and creates a new list with all the values of both lists combined ;; in descending order. (define (merge1 lon1 lon2) (cond [(and (empty? lon1)(empty? lon2)) empty] [(and (cons? lon1)(empty? lon2)) lon1] [(and (empty? lon1)(cons? lon2)) lon2] [(and (cons? lon1)(cons? lon2)) (cond [(> (first lon1) (first lon2)) (cons (first lon1) (merge1 (rest lon1) lon2))] [else (cons (first lon2) (merge1 lon1 (rest lon2)))])]))
Notice how the merge shortens only one of the two lists at any given call of merge. You can think of the recursion as bouncing back and forth between the two lists.
The Price: Process Flow Analysis works for any type and any number of of inputs with any number of sub-types. The 4-way cond above works only for 2 inputs with 2 sub-types each.
But let's continue on anyway...
An examination of the above table for the merge function, however, tells us that two of the cases are identical and can thus be represented by a single piece of code. This simplifies the program but perhaps at the cost of understandability, flexibility, extensibility, robustness and possibly correctness:
;; Simplified merge implementation (define (merge2 lon1 lon2) (cond [(empty? lon1) lon2] [(empty? lon2) lon1] [else (cond [(> (first lon1) (first lon2)) (cons (first lon1) (merge2 (rest lon1) lon2))] [else (cons (first lon2) (merge2 lon1 (rest lon2)))])]))
Let's look at another example:
Suppose we had two lists and we wanted to make a new list that held the corresponding pairs ("tuples") from the first two lists:
(equal? (list (list 'Bart 1979) (list 'Homer 1955)(list 'Marge 1956)) (make-tuples (list 'Bart 'Homer 'Marge) (list 1979 1955 1956)))
Using our process flow template from above, we can build the function:
;; make-tuples: list-of-any list-of-any --> list-of-list-any or symbol ;; Takes two lists and returns a single list ;; which consists of two element lists (tuples) consisting of the ;; corresponding pairs from the input lists. ;; If both input lists are empty, the result is an empty list. ;; If one list is longer than the other, the result is truncated at the shorter list. (define (make-tuples list1 list2) (cond [(empty? list1) empty] [(cons? list1) (make-tuples_help list1 list2)])) ;; list1 is known to be non-empty (define (make-tuples_help list1 list2) (cond [(empty? list2) empty] [(cons? list2) (cons (list (first list1) (first list2)) (make-tuples (rest list1) (rest list2)))])) "make-tuples test cases:" (equal? empty (make-tuples empty empty)) (equal? (list (list 'a 1) (list 'b 2)) (make-tuples (list 'a 'b) (list 1 2 3))) (equal? (list (list 1 'a) (list 2 'b)) (make-tuples (list 1 2 3) (list 'a 'b))) (equal? (list (list 1 'a) (list 2 'b) (list 3 'c)) (make-tuples (list 1 2 3) (list 'a 'b 'c))) (equal? (list (list 'Bart 1979) (list 'Homer 1955)(list 'Marge 1956)) (make-tuples (list 'Bart 'Homer 'Marge) (list 1979 1955 1956)))
We could also do the same thing using the template for a multi-way cond statement:
;; make-tuples2: list-of-any list-of-any --> list-of-list-any or symbol ;; Takes two lists and returns a single list ;; which consists of two element lists (tuples) consisting of the ;; corresponding pairs from the input lists. ;; If both input lists are empty, the result is an empty list. ;; If one list is longer than the other, the result is truncated at the shorter list. (define (make-tuples2 list1 list2) (cond [(and (empty? list1)(empty? list2)) empty] [(and (cons? list1)(empty? list2)) empty] [(and (empty? list1)(cons? list2)) empty] [(and (cons? list1)(cons? list2)) (cons (list (first list1) (first list2)) (make-tuples2 (rest list1) (rest list2)))])) "make-tuples2 test cases:" (equal? empty (make-tuples2 empty empty)) (equal? (list (list 'a 1) (list 'b 2)) (make-tuples2 (list 'a 'b) (list 1 2 3))) (equal? (list (list 1 'a) (list 2 'b)) (make-tuples2 (list 1 2 3) (list 'a 'b))) (equal? (list (list 1 'a) (list 2 'b) (list 3 'c)) (make-tuples2 (list 1 2 3) (list 'a 'b 'c))) (equal? (list (list 'Bart 1979) (list 'Homer 1955)(list 'Marge 1956)) (make-tuples2 (list 'Bart 'Homer 'Marge) (list 1979 1955 1956)))
Notice how the template automatically guarantees you robust (predictable) behavior for all possible inputs because you are forced to write code for all contingencies.
If we say that the two list must be of the same length, we can shorten our code somewhat:
;; Simplified make-tuples -- assumes that list2 is not shorter than list1 (define (make-tuples3 list1 list2) (cond [(empty? list1) empty] [(cons? list1) (cons (list (first list1) (first list2)) (make-tuples3 (rest list1) (rest list2)))])) "make-tuples3 test cases:" ;;(equal? (list (list 1 'a) (list 2 'b)) (make-tuples3 (list 1 2 3) (list 'a 'b))) ;; Can't do this test!
While the shortened code doesn't completely require that both lists be of the same length, it does suffer from a loss of robustness.
Bottom line: A Process Flow Analysis will work no matter what you are dealing with. If you have two lists, then you can always start with the full 4-way template. Shorten your code later, knowing that it may come at the price of robustness.
The above code can be downloaded here: merge.scm
For more fun than you can shake a stick at, check out the unzip function in the above download. Unzip uses two accumulators at once, swapping them back and forth to effect the alternating behavior of "unzipping" a list into two lists! Unzip2 accomplishes the same thing but with no accumulators (thanks to Craig Fratrik!). Mergesort shows how merge and unzip can be used together to sort a list of numbers.
Last Revised Tuesday, 24-Aug-2004 13:49:08 CDT
©2004 Stephen Wong and Dung Nguyen