600.226 Data Structures (Spring 2004 - Prof. Eisner)

Homework 4 ... due 4pm Friday 27 Feb 2004

Summary

Goal of this homework: This homework focuses on implementing data structures (rather than using them for a task). First, you will get lots of practice at manipulating the nodes and pointers in a doubly linked list. This will prepare you to deal with more complicated pointer-based structures like trees. Second, you will get experience with the Iterator abstraction, including one rather difficult Iterator that enumerates the elements of a list in sorted order.

Collaboration policy for this homework: You must follow the CS Department Academic Integrity Code, except that for this homework, you may optionally work with one other student in the class. If you do so, you must say who your partner was at the top of your Readme file, and both of you will receive the same grade for the assignment.

How to hand in your work: instructions, upload page

What to hand in (all packaged up in a zip or tar file):

Acknowledgments: Many thanks to Alexandros Batsakis and Hadi Husain for helping to design this assignment and try it out.

Extendable arrays and amortized analysis

On page 207 of the textbook (or p. 191 of the 2nd edition), Proposition 5.2 says: "Let S be a vector implemented by means of an extendable array with initial length 1. The total time to perform a series of n push operations in S, starting from S being empty, is O(n)."

The textbook goes on to justify this proposition using an amortized analysis, in which each "push" operation deposits a constant amount of runtime in the bank, to be used later when the array is doubled. The bank account is never overdrawn, so the total amount of runtime used is at most proportional to the number of pushes.

In your Readme file:

  1. You might wonder why the textbook banks $2, not just $1, with each pushed element. As Figure 5.3 shows, it costs $8 of runtime to double the array capacity from 8 to 16 (specifically, to copy the existing array elements 0-7). The textbook gets $2 each from elements 4-7. Why not get $1 each from elements 0-7?
  2. Answer question R-5.3 from the textbook: "Rewrite the justification of Proposition 5.2 [not the proposition!] under the assumption that the cost of growing the array from size k to size 2k is 3k cyber-dollars. [The original assumption was that it cost k cyber-dollars.] How much should each push operation be charged to make the amortization work?"

Note: The above questions are unrelated to the rest of the assignment, but amortized analysis and extensible arrays are important topics.

Introduction to the rest of this homework

Chapter 5 of the textbook gives a nice List interface, and a partial implementation of it as a doubly-linked list class called NodeList. Each node or position of a NodeList is a DNode object. In this homework, you will do the following:

The files

Start by downloading the files from this source directory. Read the following descriptions carefully:

Finishing the NodeList class

The provided file NodeList.java does not implement all of the methods of the List interface. You should implement the remaining methods: after, isLast, insertAfter, and insertLast. This should be easy if you have read the other methods in the class. Test your implementation until you are sure it works correctly.

Improving the NodeList implementation

The NodeList class wastes a little memory since the fields header.prev and trailer.next aren't used for anything; they are always null.

Modify your NodeList class to use less memory by using only one sentinel (called sentinel) instead of two sentinels (header and trailer).

The list should be stored in a "circular" fashion: sentinel.next points to the first element, and sentinel.prev points to the last element. The old and new designs look like this:

From the perspective of a user, your new class should behave exactly like the old one, but internally the implementation is slightly different.

Extending NodeList with extra methods

You will now add additional methods to concatenate two lists, reverse a list, and iterate over a list in forward, reverse, or sorted order.

The additional methods are described by the FancyList interface that we have provided to you. You should implement that interface in a class FancyNodeList that extends NodeList. So the relationships among the classes and the interfaces have this rather typical form:

Therefore, your code should have this form:

  public class FancyNodeList extends NodeList implements FancyList {
    ...
  }

Appending lists

The append method concatenates two lists. So if a is the list (1,2,3) and b is the list (4,5), then a.append(b) is the list (1,2,3,4,5). Similarly b.append(a) is (4,5,1,2,3).

The method you will write is actually called append_destructive, to emphasize that it will change the lists a and b. In other words, a.append_destructive(b) will return (1,2,3,4,5), but it has a destructive side effect: once you have called it, a no longer refers to (1,2,3) and b no longer refers to (4,5)! In fact a and b will not even be valid list structures anymore; methods called on them will no longer work properly.

A non-destructive version would leave a and b alone, and create a new list (1,2,3,4,5).

Hints:

Answer the following in your Readme file:

  1. What are the worst-case time and space requirements of your append_destructive? Use big-Oh notation as usual.
  2. What would they be for a non-destructive append?
  3. Suppose you were using the old, non-circular list design (with two sentinels per list instead of one). How could you then make a non-destructive append more efficient? What would its time and space requirements be?

Reversing lists

The reverse method reverses a list. So if a is (1,2,3) as before, then a.reverse() is (3,2,1).

Again, you will write a destructive version, so you should actually call it reverse_destructive to remind the user not to use a anymore after calling a.reverse_destructive().

In class we discussed several ways to write such a method, but you are required to do it by manipulating the prev and next fields of the DNodes in the list, including sentinel. That is in order to give you practice with pointers. Draw your solution before implementing it, and think about boundary cases!

Answer the following in your Readme file:

  1. What are the worst-case time and space requirements of your reverse_destructive? Use big-Oh notation as usual.
  2. Suppose that you had implemented reverse_destructive differently, by changing the nodes' element fields (via List.swapElements) while leaving the prev and next alone. What would the worst-case time and space requirements have been then?
  3. You could implement a non-destructive reverse method by making a copy of the list in reverse order. (An easy way to do this is repeated calls to insertBefore.) What would the worst-case time and space requirements be?

Iterators and reversed iterators

Before implementing iterator and iteratorReverse methods of FancyList, you would be wise to review the Iterator interface and the tree iterators that we developed in class. Iterators are also discussed in section 5.2 of the textbook.

These methods of FancyList should return iterator objects. Specifically, objects of type FancyListIterator and FancyListReverseIterator, respectively. These are subclasses of java.util.Iterator that you will write. We have given you skeletons of these classes. In particular, we suggest that your iterator have the following instance fields:

  protected Position p;    // position of next element to return, or null if none
  protected FancyList fl;  // have to point to the list too so we can call its methods

  /* Since the list field only holds a pointer to an existing list, 
     the iterator only takes O(1) memory, not O(n) as you might think. */

iterator and iteratorReverse should be fairly easy once you understand iterators. (They are also almost identical to each other.) But be careful about the following:

The sorted iterator

The final problem is the hardest and most interesting exercise you have seen so far in this class. Design an iterator that iterates over the list's elements in ascending order. The fact that this is possible demonstrates the flexibility and power of iterators.

The sort should be in the same order (and throw the same ClassCastException) as Java's built-in sort method. However, unlike that method, you will not modify that list, nor will you sort it all at once. Instead your iterator will be able to return the elements one at a time, in increasing order.

Carefully read the javadoc comment for iteratorSorted, which explains that you must do a "stable sort":

  /** Returns an iterator over the elements of the list in sorted
   * order.  Elements are compared using the compareTo method
   * and the least one is returned first.  Elements that compare
   * as equal should be returned in their original left-to-right
   * order (this is called a "stable sort").  See the assignment
   * for discussion and hints.
   *
   * @throws ClassCastException if the list contains elements that
   *   cannot be compared to one another (e.g., strings and
   *   integers, or objects that cannot be cast to Comparable).
   */

The method should return an iterator of class FancyListSortedIterator, which you will design and write. You should probably store the same two instance fields as in your previous iterators. What is different is how you advance the Position p. That is complicated!

Try to figure it out yourself first. If you get stuck, try this hint.

Your iterator should only take O(1) space. But how about time? Answer the following in your Readme file:

  1. You could output a list in sorted order by calling the next method until hasNext() is false. In fact, that is how the test class below will print lists in sorted order and will create a new sorted version of a list.

    1. What is the worst-case runtime of FancyListSortedIterator's next method?
    2. What, therefore, is the worst-case runtime of sorting a length-n list by calling next repeatedly?

Remark: The sorting method we have just discussed is called selection sort, because it repeatedly scans the list to select the next biggest element. Many people use this method to sort their cards during a game of poker. One advantage of using an iterator is that the user could quit after getting the 10 smallest elements, which saves time if that's all he or she needs.

Testing your code

Finally, test your FancyNodeList implementation by running the NumberedString test class that was provided to you. This test class exercises FancyNodeList through the FancyList interface.

You will have to complete NumberedString by filling in a couple of methods (listify and iterFromList).

The NumberedString.main method will create two FancyLists of integer/string pairs, and will try various methods on them. For example, running java NumberedString yada yada yada blah blah yada will start by constructing this FancyList:

  0 / yada
  1 / yada
  2 / yada
  3 / blah
  4 / blah
  5 / yada
as well as the following "standard" example FancyList:
  0 / hello
  1 / i love you
  2 / hello
  3 / goodbye

The compareTo method on integer/string is defined so that 0 / hello and 2 / hello are considered to be equal. This lets you check that your sortedIterator really does do a stable sort and preserves their original order. In particular, iteratorSorted on the standard example above should yield the order

  3 / goodbye
  0 / hello
  2 / hello
  1 / i love you

which keeps 0 / hello ahead of 2 / hello. But if you first destructively reverse the standard example, so that 2 / hello precedes 0 / hello, then iteratorSorted should preserve that order too, yielding

  3 / goodbye
  2 / hello
  0 / hello
  1 / i love you

Here is the correct result of a sample run. You may want to check that you get the same answer.

$ java NumberedString yada yada yada blah blah yada
--- example ---
  0 / hello
  1 / i love you
  2 / hello
  3 / goodbye
--- example [in reverse order] ---
  3 / goodbye
  2 / hello
  1 / i love you
  0 / hello
--- example [in sorted order] ---
  3 / goodbye
  0 / hello
  2 / hello
  1 / i love you
============================================================
--- sort(example) ---
  3 / goodbye
  0 / hello
  2 / hello
  1 / i love you
--- sort(example) [in reverse order] ---
  1 / i love you
  2 / hello
  0 / hello
  3 / goodbye
--- sort(example) [in sorted order] ---
  3 / goodbye
  0 / hello
  2 / hello
  1 / i love you
============================================================
--- args ---
  0 / yada
  1 / yada
  2 / yada
  3 / blah
  4 / blah
  5 / yada
--- args [in reverse order] ---
  5 / yada
  4 / blah
  3 / blah
  2 / yada
  1 / yada
  0 / yada
--- args [in sorted order] ---
  3 / blah
  4 / blah
  0 / yada
  1 / yada
  2 / yada
  5 / yada
============================================================
--- reverse(args) ---
  5 / yada
  4 / blah
  3 / blah
  2 / yada
  1 / yada
  0 / yada
--- reverse(args) [in reverse order] ---
  0 / yada
  1 / yada
  2 / yada
  3 / blah
  4 / blah
  5 / yada
--- reverse(args) [in sorted order] ---
  4 / blah
  3 / blah
  5 / yada
  2 / yada
  1 / yada
  0 / yada
============================================================
--- reverse(args) + example + args ---
  5 / yada
  4 / blah
  3 / blah
  2 / yada
  1 / yada
  0 / yada
  0 / hello
  1 / i love you
  2 / hello
  3 / goodbye
  0 / yada
  1 / yada
  2 / yada
  3 / blah
  4 / blah
  5 / yada
--- reverse(args) + example + args [in reverse order] ---
  5 / yada
  4 / blah
  3 / blah
  2 / yada
  1 / yada
  0 / yada
  3 / goodbye
  2 / hello
  1 / i love you
  0 / hello
  0 / yada
  1 / yada
  2 / yada
  3 / blah
  4 / blah
  5 / yada
--- reverse(args) + example + args [in sorted order] ---
  4 / blah
  3 / blah
  3 / blah
  4 / blah
  3 / goodbye
  0 / hello
  2 / hello
  1 / i love you
  5 / yada
  2 / yada
  1 / yada
  0 / yada
  0 / yada
  1 / yada
  2 / yada
  5 / yada
============================================================

Note that the NumberedString test class only tests the methods specific to FancyList. It does not necessarily use all of your basic NodeList methods. You are responsible for figuring out how to test those yourself and finding any bugs before the graders do!

A final remark about iterators

The niftiness of iterators is illustrated by the NumberedString.printIter and NumberedString.listFromIter methods, which can take any kind of iterator as an argument. Because of dynamic method lookup, they don't have to know what the particular iterator's next method does -- they can just call that method.

Giving an iterator to a method such as printIter is very much like giving it a singly-linked list of elements. However, an iterator is a "virtual" list that takes only O(1) space. Its next method will simply construct and return successive elements as needed, without storing them all in memory. This is most dramatic in the case of your iteratorSorted, where there is never an explicit sorted list in memory. As another example, you could easily write an iterator to return the sequence of numbers 1, 3, 5, 7, ... and so on forever. printIter would happily keep printing those numbers until you turned off the computer, just as if it were traversing an infinitely long list, but of course no such list has been built in the computer's memory.

Extra credit #1

For extra credit, implement and test the remove method for each iterator. (That is, actually remove something rather than throwing an UnsupportedOperationException.) Note that remove is a standard part of Java's Iterator interface; read the documentation for how it is supposed to work.

This is not a hard problem, since you already have a List.remove method. But make sure your implementations work exactly as the interface requires! Calling remove should not affect the sequence of elements that the next method will return in future. It should only remove the element that was just returned (throwing IllegalStateException if there's no such element or it was previously removed).

Extra credit #2

Here is a different extra credit problem. You may do either or both problems if you like.

Write a class MergedIterator that implements Iterator. The constructor MergedIterator(Iterator iter1, Iterator iter2) should produce an iterator that returns all the elements of both iter1 and iter2. If iter1 and iter2 both produce elements in sorted order, then the merged iterator should iterate through all the elements of both in sorted order.

To accomplish this, the merged iterator's next() method should return the next element of either iter1 or iter2, whichever is smaller according to compareTo. Ties should be broken in favor of iter1.

To test your class, add the following code to the end of NumberedString.main. Note that this code does not use any destructive operations. Instead of destructively appending two lists and creating a sorted iterator over the result, it creates a sorted iterator over each list and then merges them.

System.out.println("*** Non-destructive merge of two sorted lists ***");
fl = listify(args);   // back to the original list
printIter("args", fl.iterator());
printIter("example", example.iterator());
printIter("args [in sorted order]", fl.iteratorSorted());
printIter("example [in sorted order]", example.iteratorSorted());
printIter("args + example [in sorted order]", 
          MergedIterator(fl.iteratorSorted(), example.iteratorSorted()));

Jason Eisner - jason@cs.jhu.edu - $Date: 2004/02/18 07:20:18 $