CMPS 144
Lecture Notes on Recursive Lists

The vision of a list as embodied in the LISP ("LISt Processing") programming language, due to John McCarthy, is as follows:

A list is either

  1. empty, or
  2. composed of an item, called the head, and a list, called the tail.

Notice the recursive nature of this definition: a (non-empty) list is defined to have a list as one of its components.

As an illustration, consider the following list of animals:

+-----+   +-----+   +-----+   +-----+   +-----+
| Dog |---| Cat |---| Cow |---| Emu |---| Bug |
+-----+   +-----+   +-----+   +-----+   +-----+
   
|     |   |                                   |
+-----+   +-----------------------------------+
   |                      |
   |                      |
 head                    tail 

The head of the list is the Dog node and the tail is the (sub-)list that begins with Cat. (Hence, with respect to the list pictured, the head of its tail is Cat. The head of the tail of its tail is Cow, etc.

Taking this as our basis, a set of operations that works nicely is embodied by this Java interface:

/* An instance of a concrete class that implements this interface represents 
** a list of items.  The list concept adopted here is based upon that of 
** John McCarthy's LISP programming language, which says that a list either
** --is empty, or 
** --has a head (an element) and a tail (that is itself a list)
**
** Author: R. McCloskey, November 2019
*/

public interface RecursiveList<T> {

   // observers
   // ---------

   /* Reports whether or not this list is empty (i.e., has no items in it)
   */
   boolean isEmpty();

   /* Returns the head of this list.
   ** Throws NoSuchElementException if this.isEmpty().
   */
   T headOf();


   // generators
   // ----------

   /* Returns the tail of this list.
   ** Throws NoSuchElementException if this.isEmpty().
   */
   RecursiveList<T> tailOf();

   /* Returns the list whose head is the specified item and whose tail
   ** is this list.
   */
   RecursiveList<T> cons(T item);


   /* Returns a new empty list
   */
   RecursiveList<T> emptyList();
}


Let's develop some methods based upon this interface.

Example 1a: Computing the length of a list. Here is a loop-based method that calculates the length of a list:

public static int lengthOf(RecursiveList list) {
   int lenSoFar = 0;
   while (!list.isEmpty()) {
      lenSoFar = lenSoFar + 1;
      list = list.tailOf();
   }
   return lenSoFar;
}

Note: Despite the formal parameter list being modified so that it eventually points to an empty list, this method does not have the undesired side effect of structurally modifiying the client program's list. Indeed, changing the value of a formal parameter inside a method has no effect upon the actual argument that was passed by the caller. As a general proposition, the object pointed to by a caller's actual argument can be modified, inside the called method, via mutator methods invoked upon the formal parameter, but that doesn't occur here, as no mutators are called (nor do any exist). End of note.

Example 1b: The code in the above method works correctly, but it is not "in the spirit" of the recursive list paradigm. Rather, it feels more like the positional paradigm, with the list formal parameter playing the role of a cursor that traverses the given list until it hits the rear. A more elegant recursive version of this method is as follows, based on the observation that the length of a non-empty list is one more than the length of its tail.

public static int lengthOf(RecursiveList list) {
   if (list.isEmpty()) 
      { return 0; }
   else 
      { return 1 + lengthOf(list.tailOf()); }
}

You could imagine the execution of this method upon the list of animals introduced earlier as going like this:

    lengthOf([Dog,Cat,Cow,Emu,Bug])

=   1 + lengthOf([Cat,Cow,Emu,Bug])

=   1 + 1 + lengthOf([Cow,Emu,Bug])

=   1 + 1 + 1 + lengthOf([Emu,Bug])

=   1 + 1 + 1 + 1 + lengthOf([Bug])

=   1 + 1 + 1 + 1 + 1 + lengthOf([])

=   1 + 1 + 1 + 1 + 1 + 0


Example 2: Filtering. Here's a method that "filters out" negative numbers from a given list, resulting in a list containing only the nonnegative numbers in the given list.

public static RecursiveList<Integer> filterOutNegatives(RecursiveList<Integer> list) {
   if (list.isEmpty()) 
      { return list; }
   else {
      // Form the list containing the nonnegatives in the tail of 'list'
      RecursiveList<Integer> rest = filterOutNegatives(list.tailOf()); 
      if (list.headOf() < 0)
         { return rest; }
      else {  // head is a nonnegative number
         { return rest.cons(list.headOf()); }
   }
}

As an example, suppose that this method were applied to the list [4, -5, 3, 7, -1, 6, -12]. Seeing as how this list is not empty, the else-branch would execute, the first step in which would be to recursively compute the list of nonnegative values in the list's tail (and assign that to local variable rest). The result of the recursive call should be the list [3, 7, 6], as that is the list one obtains by filtering out the negative values from [-5, 3, 7, -1, 6, -12].

Following the assignment to rest, the if-statement checks the head of list to see if it is negative. In this example, the head is 4, which is not negative. Hence, the method needs to return the list obtained by "inserting" 4 (the head of list) at the head of rest, which it does by using the cons() method. The result would be the list [4, 3, 7, 6].

Had the head of list been negative, it should not be included in the list returned by the method; hence it should simply return rest, as reflected by the first branch of the nested if-else statement.

Here is a slightly different version of the same method. Rather than using the local variable rest, it includes recursive calls in each of the recursive branches of the if-statement.

public static RecursiveList<Integer> filterOutNegatives(RecursiveList<Integer> list) {
   if (list.isEmpty()) 
      { return list; }
   else if (list.headOf() < 0)
      { return filterOutNegatives(list.tailOf()); }
   else {  // head is a nonnegative number
      { return filterOutNegatives(list.tailOf()).cons(list.headOf()); }
}

The first two examples illustrate a pattern that commonly occurs in recursive list programming. Given a (non-empty) list, you recursively apply the method to its tail (thereby solving a smaller instance of the same problem) and then you "adjust" the result to obtain what needs to be returned to your caller (which is likely to be yourself!). The adjustment almost always involves the list's head, as it plays no role in the result produced by the recursive call.)

In the case of lengthOf(), the adjustment was simply to add one to the result of the recursive call (accounting for the fact that the list's head counts as one element in the list). In the case of filterOutNegatives(), the adjustment was, in one case, to do nothing, and in the other, to "insert" the list's head at the beginning of the list returned by the recursive call.


Example 3: Appending two lists. An operation commonly applied to lists is to combine two of them by placing the elements of one after those of the other. This is the append operation. For example, the result of appending [a,b,c,d] and [e,f,g] is [a,b,c,d,e,f,g].

This problem is complicated by the fact that we have two lists to deal with rather than only one. To try to devise a solution that follows the pattern mentioned above, we should consider how to apply the append operation recursively so that it yields a result that can be easily "adjusted" to produce what we want.

Toward that end, consider our example again. Using '+' as though it were an infix append operator, we have

[a,b,c,d] + [e,f,g] = [a,b,c,d,e,f,g]

Notice that the tail of the desired result is [b,c,d] + [e,f,g], which is [a,b,c,d]'s tail appended with [e,f,g]. To get the result we want, we only need to "insert" a, the head of [a,b,c,d], as the new head.

So we have conceptually solved the recursive case. The base case occurs when the "left operand" is the empty list, in which case the result is simply the "right operand".

Translating this into Java, we get

/* Returns the list obtained by appending 'right' to the end of 'left'.
*/
public static RecursiveList append(RecursiveList left, RecursiveList right) {
   if (left.isEmpty()) 
      { return right; }
   else {
      RecursiveList tailOfResult = append(left.tailOf(), right);
      return tailOfResult.cons(left.headOf());
   }
}


Example 4: Reversing a list. As an example, the reverse of the list [a, b, c, d, e] is [e, d, c, b, a]. Thinking recursively, notice that this is obtained by taking the head of the given list, a, and placing it at the end of what we get by reversing [b, c, d, e], which is the tail of the given list. That is, using + again as though it were an infix append operator:

   reverse([a,b,c,d,e])
=  [e,d,c,b] + [a]
=  reverse([b,c,d,e]) + [a]
=  reverse(tailOf([a,b,c,d,e]) + [headOf([a,b,c,d,e])]

Translating into Java:

/* Returns a list that is the reverse of the given list.
*/
public static RecursiveList reverseOf(RecursiveList list) {
   if (list.isEmpty()) 
      { return list; }
   else {
      RecursiveList mostOfResult = reverseOf(list.tailOf());
      RecursiveList headList = list.emptyList().cons(list.headOf());
      return append(mostOfResult, headList);
   }
}

The purpose of the line of code in which headList is declared is to create a one-element list containing the head of the given list, which is then passed (as the second argument) to append(). Why was it necessary to do that? Couldn't the second argument in the call to append() be, more simply, list.headOf()? NO!! Why? Because the second argument passed in any call to append() must (like the first argument) be of type RecursiveList, and list.headOf() does not meet that description.1

Had we developed a method called, say, appendElem(), which returns the result of placing a specified element at the end of a specified list, we could have used it in reverseOf() to make the else-branch a little more transparent.

The asymptotic running time of reverseOf() is O(n2) (where n is the length of the list whose reverse is computed). (Perhaps at some point the reasoning leading to that conclusion will be inserted here.)

A linear-time (i.e. O(n)) algorithm would be much faster. A clever technique by which to achieve this is to use an accumulating parameter. The idea is that, as the recursion goes deeper and deeper, an "extra" parameter is used to "build up" the result (or something close to the result), so that when the base case is reached at the bottom, the method simply returns the accumulating parameter (or some object easily computed using the accumulating parameter).

/* Returns the reverse of the given list.
*/
public static RecursiveList reverseOf(RecursiveList list) {
   return reverseOfAux(list, list.emptyList());
}

/* Auxiliary to the method above, this method returns the list 
** obtained by appending the reverse of 'list' with 'resultSoFar'
** (the "accumulating parameter").  The idea is that 'list' is the 
** suffix of a list for which the reverse is wanted and that 
** 'resultSoFar' is the reverse of the complementary prefix of that list.
*/
private static RecursiveList reverseOfAux(RecursiveList list,
                                          RecursiveList resultSoFar) {
   if (list.isEmpty()) 
      { return resultSoFar; }
   else {
      return reverseOfAux(list.tailOf(), resultSoFar.cons(list.headOf());
   }
}

Here is an example of how this works:

    reverseOf([a,b,c,d,e])
=   reverseOfAux([a,b,c,d,e], [])
=   reverseOfAux([b,c,d,e], [a])
=   reverseOfAux([c,d,e], [b,a])
=   reverseOfAux([d,e], [c,b,a])
=   reverseOfAux([e], [d,c,b,a])
=   reverseOfAux([], [e,d,c,b,a])
=   [e,d,c,b,a] 

Notice as the recursion goes deeper, in each case the arguments passed to reverseOfAux() correspond, respectively, to a suffix of the list to be reversed (i.e., [a,b,c,d,e]) and the reverse of the complementary prefix of that list. For example, one instance of the method receives [d,e] (the suffix of length two) and [c,b,a] (the reverse of the complementary prefix).


Example 5: Merging two ascending-ordered lists. For the sake of concreteness, we will assume that we are dealing with lists containing integer values.

... to be continued


Footnotes

[1] Technically, it's possible for list.headOf() to be of type RecursiveList, because you can have a list one (or more) of whose elements is itself a list. But in the context of our method, the compiler will treat list.headOf() to be of type Object because the declaration of list did not specify what kind of elements it contains. This is an example of a strength of strongly-typed languages such as Java. Had we written this method in a language such as Python, which is not strongly-typed, and made the mistake of passing the equivalent of list.headOf() as a parameter to append(), this error would not become apparent until runtime. And, worse, if list.headOf() actually were a recursive list, the method would have run and produced an incorrect result.