CPSC 322 - Lecture 9 - September 27, 2004

CPSC 322 - Lecture 9

Lists and Recursion


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