I. The power of recursion At the end of the previous lecture, we explored the amazing and incredible power of recursion as well as the benefits of setting up your knowledge representation just right. We did this in the domain of finding a path through a maze drawn upon an 8 x 8 grid. You can see what the maze looks like in the slides for this lecture. Today we compared the behavior of two different proof procedures when applied to this problem. The first approach used simple linear recursion to solve the problem, and the second approach used tree (or multiple or car/cdr) recursion. We had used both approaches last time in the very simple house wiring domain, and we noticed that the tree-recursive approach took noticeably more time to reach a solution, although this difference was no more than a few seconds. What we wanted to see today was how the two approaches compared when applied to a much more complex problem. Sure enough, the simple recursive approach, which looks like this: path(X,Y) <- nowall(X,Z) & path(Z,Y). path(X,Y) <- nowall(X,Y). found a path between the start and the goal in the blink of an eye, while the tree-recursive approach: path(X,Y) <- path(X,Z) & path(Z,Y). path(X,Y) <- nowall(X,Y). failed to come to a conclusion in a couple of minutes, so we killed the process. The moral to the story is simply this: either approach will eventually arrive at a solution to this problem, and they both have an elegant look about them, as recursive solutions tend to do, but one is blazingly fast, and the other is profoundly inefficient. (Next lecture we'll look quickly at a reduced-size version of this problem just to show you that the solution can be found...it just takes awhile.) We might all harbor beliefs about which programming language, operating system, computer, or whatever is faster, but no matter how we make those choices, they can't overcome fundamentally flawed solution approaches. A CILOG procedure that won't come to a conclusion on my Macintosh before the Sun burns out isn't going to do much better on your Intel-powered box, even if you're wielding weapons of mass destruction like C++ or Windows XP. II. The incredible list Since we're going to be doing some CILOG programming in the weeks to come, and given that CILOG is more or less Prolog which is a functional language (i.e., no state, so no loops), we've been putting in some time getting comfortable with recursion in CILOG. And since we're talking about recursive programming, it's a good time to talk about recursive data structures. Many of you learned about computation in an introductory course using the Scheme programming language (another functional language), so your familiar with that favorite functional data structure, the list. A list is an ordered sequence of zero or more elements, any of which could itself be a list. It's a data structure that's defined recursively. Students often wonder what the fascination with lists is. Lists are very flexible data structures which allow us to represent the complicated, tangled, sort-of-hierarchical relationships that are posed by the real world. That sort of thing is a whole lot harder to do using traditional flat, static data structures such as arrays. In CILOG a list is a sequence of elements separated by commas and bounded by square brackets, like this: [larry, curly, moe] The list with no elements is called the empty list, and it looks like this: [] All well-formed lists (there are lists that aren't well-formed...don't worry about those now) are built from the empty list. For example, if we take the element "moe" and add it to the front of the empty list, we get: [moe] If we add "curly" to the front of that list, we get: [curly, moe] And if we add "larry" to that, we get the example we started with above: [larry, curly, moe] Every list is built up in similar fashion, by adding to the front of some other list, and ultimately every list started out by the addition of some element to the empty list. Thus, every list ends with or terminates with the empty list. The operation that adds an element to the front of a list is traditionally called "cons"; we say that we're "consing" something onto a list. The tradition comes from the actual name of the operation in Lisp and Scheme programming. Since every list is the result of consing an element onto another list we can look at any list as being composed of two parts: (1) the first element of the list and (2) the rest of the list, which is the list that the aforementioned first element was consed onto to make the list being decomposed...got it? For example, the list [larry, curly, moe] can be deconstructed into the first element "larry" and the list [curly, moe]. If we cons that first element back onto [curly, moe], we get the list we started with. The two parts of a list can be called "head" and "tail", or "first" and "rest", or "car" and "cdr" (from the old Lisp days). CILOG provides us with an alternate syntax for representing lists that makes the distinction explicit by using the "|" symbol to separate the first element from the rest of the list. So, the list [larry, curly, moe] can also be represented like this [larry | [curly, moe]] The element to the left of the bar is the "first" of the list, while what's to the right of the bar is the "rest" of the list. And since what's to the right can in turn be decomposed, we could write the same list as [larry | [curly | [moe]]] or even [larry | [curly | [moe | []]]] Here's some CILOG to back me up: CILOG Version 0.12. Copyright 1998, David Poole. CILOG comes with absolutely no warranty. All inputs end with a period. Type "help." for help. cilog: tell [a,b,c]. cilog: listing. [a, b, c]. cilog: tell [a | [b,c]]. cilog: listing. [a, b, c]. [a, b, c]. cilog: tell [a | [b | [c]]]. cilog: listing. [a, b, c]. [a, b, c]. [a, b, c]. cilog: tell [a | [b | [c | []]]]. cilog: listing. [a, b, c]. [a, b, c]. [a, b, c]. [a, b, c]. cilog: See? Good. Now here's a little table that I adapted from another book which shows how the two different types of list syntax are related: Cons pair syntax Element syntax [a | [ ]] [a] [a | [b | [ ]]] [a,b] [a | [b | [c | [ ]]]] [a,b,c] [a | X] [a | X] [a | [b | X]] [a,b | X] As we'll see as we write recursive CILOG programs, what we've seen so far gives us the equivalent of the car, cdr, and cons operations in Scheme, but in case you want to make your own just to reassure yourself, here's how to give Scheme-like names to the CILOG operations: cilog: tell car([X|Y], X). cilog: listing. car([A|B], A). cilog: ask car([a,b,c],S). Answer: car([a, b, c], a). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask car([],S). No. car([], A) doesn't follow from the knowledge base. Runtime since last report: 0 secs. cilog: tell cdr([X|Y], Y). cilog: listing. car([A|B], A). cdr([A|B], B). cilog: ask cdr([a,b,c],S). Answer: cdr([a, b, c], [b, c]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask cdr([],S). No. cdr([], A) doesn't follow from the knowledge base. Runtime since last report: 0 secs. cilog: tell cons(X, [Y|Z], [X,Y|Z]). cilog: listing. car([A|B], A). cdr([A|B], B). cons(A, [B|C], [A, B|C]). cilog: ask cons(a, [b,c,d], S). Answer: cons(a, [b, c, d], [a, b, c, d]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask cons(a, [], S). No. cons(a, [], A) doesn't follow from the knowledge base. Runtime since last report: 0 secs. cilog: tell cons(a,[],[a]). cilog: listing. car([A|B], A). cdr([A|B], B). cons(A, [B|C], [A, B|C]). cons(a, [], [a]). cilog: Notice that these relations don't return values in the sense that functions can return a wide variety of values (as in Scheme, for example). These relations return only true or false; they're predicates. The relations you create are actually proof procedures, and so it makes sense that they return only true ("your theorem is proven") or "false" ("your theorem could not be proven"). With the "car" predicate defined above, for example, the intent is not to return the first element of the list, but to prove that, given a list and an element, the element is the first thing in the list. When we plop a variable into the place where we're supposed to put an element, CILOG finds the value to bind to the variable that makes the proof possible...that is, it finds the first element of the given list and binds that to the variable. So remember when you're writing CILOG "functions", you're really writing proof procedures that return only true or false. The value you may be looking for will show up bound to a variable in the argument list for the "function". That distinction should help you with the syntax for your definitions, and it should inform your thinking about how to build these things. III. Recursive programming with lists Once you know about lists, you want to start doing things with them. How about proving that a list is in fact a list? That's a fine place to start. The essence of proving that something is a list is to show that it ends with the empty list...that is, it was built by adding something onto the empty list, then adding something onto that, and so on. So to see if something is a list, you want to yank out the first element and see if what remains is the empty list. If that's not the case, then you yank the first element out of that list, and so on, until you find the empty list. So the base case or termination condition is when you've reached the empty list, and the rest of the proof procedure is to prove that the rest of the given list is a list. It looks like this: cilog: tell list([X|Y]) <- list(Y). cilog: tell list([]). cilog: ask list([a,b,c,d]). Answer: list([a, b, c, d]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask list([]). Answer: list([]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. Oh, by the way, here's something you want to watch out for. It's real easy when working with lists to wrap a set of square brackets around something when they shouldn't be there. For example, I've written the first line of the definition just above like this by accident more than once: cilog: tell list([X|Y]) <- list([Y]). The Y on the right hand side is already a list. Putting square brackets around the Y puts the list inside a list, which isn't what I intended. And if I continue on with the definition, this is what happens: cilog: tell list([]). cilog: ask list([a,b,c]). Query failed due to depth-bound 30. Runtime since last report: 0 secs. [New-depth-bound,where,ok,help]: ok. Back in the days when I was teaching youngsters how to program in Lisp and Scheme, I found that the easiest way to introduce recursion was to introduce the list itself, then have students try to figure out how to determine if some element was a member of the list. Of course all they had in the way of tools were operations like car and cdr, which constrained their thinking considerably. So they'd think through their own member function like this: to see if something is a member of a list, first check to see if the thing is the same as the first element of the list. If so, then the function can stop and return true. If the given thing is not the same as the first element, then the function should check the second element of the list. "And how would the function get to the second element of the list?" I'd ask. That's when the lightbulbs would start to go on over their heads. "By looking at the first element of the rest (the car of the cdr) of the list!" they'd say. "How would you do that?" I'd ask. "By calling the member function with the thing it's looking for and just the rest (the cdr) of the list!" The concept of recursion would just sort of erupt out of that process without my ever having to explain it or name it first. The point is that you can think about a member relation or proof procedure in CILOG in exactly the same way. Given some item and a list, if the first element of the list matches the item, then the item is a member of the list: cilog: tell member(E,[E|R]). If not, then the way to prove that the item is a member of the list is to prove that it's a member of the rest (the cdr) of the list: cilog: tell member(E,[F|R]) <- member(E,R). Here's the member predicate in action: cilog: tell member(E,[E|R]). cilog: tell member(E,[F|R]) <- member(E,R). cilog: ask member(x,[a,b,c,x,d,e]). Answer: member(x, [a, b, c, x, d, e]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask member(x,[a,b,x,c]). Answer: member(x, [a, b, x, c]). Runtime since last report: 0 secs. [ok,more,how,help]: how. member(x, [a, b, x, c]) <- 1: member(x, [b, x, c]) How? [Number,up,retry,ok,prompt,help]: 1. member(x, [b, x, c]) <- 1: member(x, [x, c]) How? [Number,up,retry,ok,prompt,help]: 1. member(x, [x, c]) is a fact member(x, [b, x, c]) <- 1: member(x, [x, c]) How? [Number,up,retry,ok,prompt,help]: ok. Answer: member(x, [a, b, x, c]). Runtime since last report: 0 secs. [ok,more,how,help]: ok. cilog: ask member(x,[a,b,c]). No. member(x, [a, b, c]) doesn't follow from the knowledge base. Runtime since last report: 0 secs. cilog: ask member(Z,[a,b,c]). Answer: member(a, [a, b, c]). Runtime since last report: 0 secs. [ok,more,how,help]: more. Answer: member(b, [a, b, c]). Runtime since last report: 0 secs. [ok,more,how,help]: more. Answer: member(c, [a, b, c]). Runtime since last report: 0 secs. [ok,more,how,help]: more. No more answers. Runtime since last report: 0 secs. cilog: ask member(x,[]). No. member(x, []) doesn't follow from the knowledge base. Runtime since last report: 0 secs. cilog: That's enough for one day. More recursion next time.
Last revised: September 30, 2004