Fundamentals of Computer Science II (CSC-152 99S)


Notes on Assignment 7: Sorting Algorithms

In this assignment, you will apply your understanding of sorting algorithms.

1. Write a program that reads a sequence of strings (one per line) from input, and writes them out in sorted order as output. Your program should use either Quicksort or merge sort (it's up to you which one).

2. Develop an appropriate test plan for your program.

Turn in your program code (printed and emailed), your test plan, and the results of your test plan.

This is another assignment that I think you learn more from the doing than from the grading. If you were careful to choose appropriate test cases, you will have found all of your coding errors. That leaves only commenting and other design errors. If you'd like such careful reading, I'd prefer to sit down together.

Both sorting methods have their own problems, in regard to difficulty of implementation. Quicksort is somewhat harder conceptually, and requires that confusing partition procedure. Merge sort requires you to create new arrays. I've suffered through both.

Code

For those of you who just want to look at code, here are

Helpers

Input

A few of you asked whether you could limit the number of input strings, and I said that was fine. However, it's better to expand your array, when necessary (or better yet, to use a Vector). A number of you chose a special string (such as the empty string or 0) as the ``I'm done'' symbol, although as you'll see from my answer, there are other possibilities.

The clever ones among you said something like ``he didn't specify how to input the strings; I'll just use that nice args array that I get''. Such folks ended up with something like:

  public static void main(String[] args) {
    SimpleOutput out = new SimpleOutput();
    Sorter MySortProgram = new MySortProgram();
    sorter.quickSort(args);
    for (int i = 0; i < args.length; ++i) {
      out.println(args[i]);
    }
  } // main(String[])

Here's a simple class that can read the array of strings. Note that I take advantage of SimpleInput's choice to return null when it hits the end of input. (On our Unix boxes, you can generate an end-of-input signal with control-D.) To make life nicer, I build a new array at the end, of just the right size.


import SimpleInput;
import SimpleOutput;

/**
 * Reads in an array of strings (one per line) from standard
 * input.  Stops when it runs out of strings.  Created as part
 * of the answer key for homework 7 of Grinnell's CSC152 99S.
 *
 * Although this is intended as a utility class, it also includes
 * a main method so that it can be used to test itself.
 *
 * @author Samuel A. Rebelsky
 * @version 1.0 of March 1999
 */
public class StringArrayInput {

  // +--------+--------------------------------------------------
  // | Fields |
  // +--------+

  /** The array of strings we've read so far. */
  public String[] strings;

  /** The number of things we've read. */
  int thingsRead;


  // +--------------+--------------------------------------------
  // | Constructors |
  // +--------------+

  /**
   * Build a new thing that can read strings.
   */
  public StringArrayInput() {
    // Start with some reasonable size.
    strings = new String[100];
    // Hey!  We haven't read anything.
    thingsRead = 0;
  } // StringArrayInput()


  // +----------------+------------------------------------------
  // | Public Methods |
  // +----------------+

  /**
   * Read in an array of strings.  The array is terminated
   * by the end of input.
   */
  public String[] readStrings(SimpleInput in) {
    String str;
    // Keep reading until you hit the null string.
    while ((str = in.readString()) != null) {
      store(str);
    } // while
    // Build and return the appropriate subarray.
    return stringsRead();
  } // readStrings(SimpleInput)

  /**
   * Read in an array of strings until you hit a specified
   * string.  The array is terminated by the given string
   * or by end of input.
   */
  public String[] readStrings(SimpleInput in, String terminator) {
    String str;
    // Keep reading until you hit the null string.
    while ( ((str = in.readString()) != null) &&
            (!terminator.equals(str)) ) {
      store(str);
    } // while
    // Build and return the appropriate subarray.
    return stringsRead();
  } // readStrings(SimpleInput,String)


  // +----------------+------------------------------------------
  // | Helper Methods |
  // +----------------+

  /**
   * Store a string in the array, increasing the size if
   * necessary.
   */
  protected void store(String str) {
    // Make sure the array is big enough.
    if (thingsRead >= strings.length) {
      // Remember the old array.
      String[] tmp = strings;
      // Create a new one.
      strings = new String[2*tmp.length];
      // Copy over the old elements.
      for (int i = 0; i < strings.length; ++i) {
        strings[i] = tmp[i];
      }
    }
    // Fill in the new thing
    strings[thingsRead] = str;
    // And update our count
    ++thingsRead;
  } // store(String)

  /**
   * Get a subarray of the strings read.
   */
  protected String[] stringsRead() {
    // Create the new array.
    String[] stuff = new String[thingsRead];
    // Fill it in.
    for (int i = 0; i < thingsRead; ++i) {
      stuff[i] = strings[i];
    }
    // Return it.
    return stuff;
  } // stringsRead()

  // +------+----------------------------------------------------
  // | Main |
  // +------+

  public static void main(String[] args) {
    SimpleOutput out = new SimpleOutput();
    SimpleInput in = new SimpleInput();
    StringArrayInput sai = new StringArrayInput();
    String[] strings;
  
    // Get the list of strings (either using a specified terminator
    // or not.
    if (args.length > 0)
      strings = sai.readStrings(in,args[0]);
    else 
      strings = sai.readStrings(in);
    
    // Print out the result.
    for (int i = 0; i < strings.length; ++i) {
      out.println(i + ": " + strings[i]);
    }
  } // main(String[])

} // StringArrayInput


Comparing Strings

How do you compare two strings? With the compareTo method provided by the String class. If x and y are strings, then

Of course, this treats uppercase and lowercase letters differently (and, in fact, uses an ordering like A...Za...z, so that Zebra comes before alphabet). If you want case-insensitive comparison, you'll need to convert to lower case first, as in

  /**
   * Determine if one string should precede another 
   * (alphabetically).
   */
  public boolean precedes(String alpha, String beta) {
    alpha = alpha.toLowerCase();
    beta = beta.toLowerCase();
    return alpha.compareTo(beta) < 0;
  } // precedes(String,String)

Sorting Input

Since I had to write two different solutions, I decided to come up with a general mechanism for using those two different solutions. I started with an interface, StringSorter.


/**
 * Things that can sort arrays of strings.  Created as part of a
 * too-long answer key for Grinnell College's CSC 152 99S.
 *
 * @author Samuel A. Rebelsky
 * @version 1.0 of March 1999
 */
public interface StringSorter {
  /**
   * Sort an array of strings, returning a sorted version.
   */
  public String[] sort(String[] strings);
} // interface StringSorter


Next, I created a class that reads input, sorts using a StringSorter, and then prints the sorted output.


import SimpleInput;
import SimpleOutput;
import StringArrayInput;
import StringSorter;

/**
 * A helper that allows you to read a list of strings from input, 
 * and print a sorted version of that list to output.
 */
public class InputSorter {
  public void sortInput(StringSorter sorter) {
    SimpleOutput out = new SimpleOutput();
    SimpleInput in = new SimpleInput();
    StringArrayInput sai = new StringArrayInput();
    String[] strings;

    strings = sai.readStrings(in);
    strings = sorter.sort(strings);
    for (int i = 0; i < strings.length; ++i) {
      out.println(strings[i]);
    }
  } // sortInput(String[])
} // InputSorter


Finally, I created classes that used the two sorting methods along with the input sorter. For example,


import QuicksortStrings;
import InputSorter;

/**
 * A test of QuicksortStrings.  Reads standard input and sorts
 * to standard output.  Created as part of the answer key to
 * assignment 7 of Grinnell College's CSC152 99S.
 * 
 * @author Samuel A. Rebelsky
 * @version 1.0 of March 1999
 */
public class QuicksortInput {
  public static void main(String[] args) {
    QuicksortStrings sorter = new QuicksortStrings();
    InputSorter helper = new InputSorter();
    helper.sortInput(sorter);
  } // main(String[])
} // QuicksortInput


Sorting Packages

Quicksort

What are some of the common problems I saw? Many of you had trouble with the partition method (there's no shame in that; I had trouble with the partition method, not just the first time I wrote it, but when I tried to write it for the first draft of the lab manual). There are many subtle errors that can be introduced as you move the cursors around during partitioning.

If you wondered why I had all the comments in the original partition method, that's why. Note also that I've updated them slightly for clarity's sake.

How should you choose a pivot? Optimally, you choose a ``random'' element of the subarray to sort. For this stage of your career, using the first element is acceptable.

Oh, there were some problems with static methods. We'll cover those in class.

Here's my solution. It's based fairly closely on the outline of class 21. I've made allowances for strings, and written the better way of choosing a pivot.


import SimpleInput;
import SimpleOutput;
import StringArrayInput;
import java.util.Random;
import StringSorter;

/**
 * Tools for sorting lists of strings using Quicksort.  Created 
 * for the answer key for assignment 7 in Grinnell's CSC152 99S.
 *
 * @author Samuel A. Rebelsky
 * @version 1.1 of March 1999
 */
public class QuicksortStrings 
  implements StringSorter
{

  // +--------+--------------------------------------------------
  // | Fields |
  // +--------+

  /** A random number generator, used to pick the pivot. */
  protected Random generator;

  // +--------------+--------------------------------------------
  // | Constructors |
  // +--------------+

  /**
   * Build a new thing that can sort arrays of strings.
   */
  public QuicksortStrings() {
    generator = new Random();
  } // QuicksortStrings()


  // +----------------+------------------------------------------
  // | Public Methods |
  // +----------------+

  /**
   * Sort an array using Quicksort.  Included to support the
   * StringSorter interface.
   */
  public String[] sort(String[] strings) {
    // Make a copy.
    String[] stuff = new String[strings.length];
    for (int i = 0; i < strings.length; ++i) {
      stuff[i] = strings[i];
    }
    // Sort it.
    quickSort(stuff);
    // Return it.
    return stuff;
  } // sort(String[])
   
  /**
   * Sort an array in place using Quicksort.
   * Pre: All elements in the array can be compared to each other.
   * Post: The vector is sorted (using the standard meaning).
   */
  public void quickSort(String[] A) {
    quickSort(A, 0, A.length-1);
  } // quickSort(String[])
 
 
  // +----------------+------------------------------------------
  // | Helper Methods |
  // +----------------+

  /**
   * Sort part of an array using Quicksort.
   * Pre: All elements in the subarray can be compared to each other.
   * Pre: 0 <= lb <= ub < A.length
   * Post: The vector is sorted (using the standard meaning).
   */
  protected void quickSort(String[] A, int lb, int ub) {
    // Variables
    int mid;		// The position of the pivot
    // Base case: size one arrays are sorted.
    if (lb == ub) return;
    // Pick a pivot and put it at the front of the array.  
    putPivotAtFront(A,lb,ub);
    // Determine the position of the pivot, while rearranging the array.
    mid = partition(A, lb, ub);
    // Recurse.
    if (mid-1>=lb) quickSort(A, lb, mid-1);
    if (mid+1<=ub) quickSort(A, mid+1, ub);
  } // quickSort
  
  /**
   * Split the array given by [lb .. ub] into ``smaller'' and
   * ``larger'' elements, where smaller and larger are defined by
   * their relationship to a pivot.  Return the index of the pivot 
   * between those elements.  Uses the first element of the array 
   * as the pivot.
   */
  protected int partition(String[] A, int lb, int ub) {
    //  Use the first element of the subsequence as the pivot value.
    String pivotval = A[lb];
    int l=lb; // Elements [lb..l] are all <= pivotval
    int r=ub; // Elements [r+1..ub] are all > pivotval
    // Keep going until we run out of elements to put in the correct place.
    while (l < r) {
      // At this point, we know that 
      //   (1) l < r
      //   (2) Elements [lb..l] are all <= pivotval
      //   (3) Elements [r+1..ub] are all > pivotval
  
      // Skip over any large elements in the right half
      while ((pivotval.compareTo(A[r]) < 0) && (r > l)) {
        --r;
      }
  
      // At this point, we know that
      //    (1) l <= r (we stop moving r left when we hit l or run out
      //                of large elements)
      //    (2) elements [lb..l] are all <= pivotval (we haven't moved l)
      //    (3) elements [r+1..ub] are all > pivotval (by the for loop)
      //    (4) element r is <= pivotval (we either stopped moving when
      //        we hit such an element or (a) r = l and (b) l indexes such 
      //        an element)
  
      // Skip over any small elements in the left half.
      while ((A[l].compareTo(pivotval) <= 0) && (l < r)) {
        ++l;
      }
  
      // At this point, we know that
      //    (1) l <= r (we stop moving r left when we hit l or 
      //        possibly sooner; we stop moving l right when we hit
      //        r or possibly sooner)
      //    (2) elements [lb..l-1] are all <= pivotval (by the for loop)
      //    (3) elements [r+1..ub] are all > pivotval (we haven't moved r)
      //    (4) element r is <= pivotval (we either stopped moving when
      //        we hit such an element or r = l (and l indexes such an
      //        element)
      //    (5) if l < r then element l is > pivotval (by the for loop)
      //    (6) if l = r then element l is <= pivotval
    
      // Do we have a large element in the left and a small element
      // on the right?
      if (A[l].compareTo(A[r]) > 0) {
        swap(A,l,r);
      } 
    } // while
  
    // At this point, we know that
    //    (1) elements [lb..l] are all <= pivotval 
    //    (2) elements [l+1..ub] are all > pivotval
  
    // Put the pivot in the middle.  Note that at this point, element l is
    // <= pivotval, so this is a safe swap
    swap(A,lb,l);
  
    // And we're done
    return l;
  } // partition(String[], int, int)

  /**
   * Pick a pivot, and put it at the front of the subarray.
   * Pre: The subarray is nonempty.
   * Post: No elements are added.
   * Post: No elements are removed.
   * Post: Elements may have been rearranged.
   */
  protected void putPivotAtFront(String[] A, int lb, int ub) {
    // Determine the length of the subarray.
    int length = (ub - lb) + 1;
    // Pick a random number between 0 and length-1
    int r = Math.abs(generator.nextInt()) % length;
    // And swap
    swap(A,lb,lb+r);   
  } // putPivotAtFront(String[], int, int)

  /**
   * Swap two elements of an array.
   * Pre: Both indices are in the array.
   * Post: Afterwards, the two elements have been swapped.
   */
  protected void swap(String[] A, int x, int y) {
    String tmp = A[x];
    A[x] = A[y];
    A[y] = tmp;
  } // swap(String[], int, int)

} // class QuicksortStrings


Merge sort

I didn't get many questions on this. I expect that the hard part was making the arrays. (Decoding my code was also hard.)

Note that I used a slightly different strategy (albeit a less efficient one). Rather than keeping track of subarrays, I just built new arrays at every step.


import SimpleInput;
import SimpleOutput;
import StringArrayInput;
import java.util.Random;

/**
 * Tools for sorting lists of strings using merge sort.  Created
 * as part of the answer key for assignment 7 in Grinnell College's
 * CSC152 99S.
 *
 * @author Samuel A. Rebelsky
 * @version 1.1 of March 1999
 */
public class MergeSortStrings 
  implements StringSorter
{

  // +----------------+------------------------------------------
  // | Public Methods |
  // +----------------+

  /**
   * Sort an array, creating a new sorted version of the array.
   * Pre: The elements in the array can be compared to each other.
   * Pre: There is sufficient memory to complete the creation of the
   *   new array (and the other steps of the algorithm).
   * Post: Returns a sorted version of the array (where sorted is
   *   defined carefully elsewhere).
   * Post: Does not affect the original array.
   */
  public String[] sort(String[] A) {
    int middle;                   // Index of middle element
    // Base case: array of size 0 or 1.  Make a fresh copy so that
    // it's safe to modify (and is the appropriate size.
    if (A.length <= 1) {
      String[] B = new String[A.length];
      for (int i = 0; i < A.length; ++i) {
        B[i] = A[i];
      }
      return B;
    } // base case
    // Recursive case: split and merge
    else {
      // Find the middle of the subarray.
      middle = A.length / 2;
      // Build the two halves.
      String[] left = new String[middle];
      for (int i = 0; i < middle; ++i) {
        left[i] = A[i];
      }
      String[] right = new String[A.length-middle];
      for (int i = 0; i < A.length-middle; ++i) {
        right[i] = A[middle+i];
      } // for
      // Sort the two halves.
      String[] sortedLeft = sort(left);
      String[] sortedRight = sort(right);
      // And merge.
      return merge(sortedLeft, sortedRight);
    } // recursive case
  } // sort(String[])
 
  // +----------------+------------------------------------------
  // | Helper Methods |
  // +----------------+

  /**
   * Merge two sorted arrays into a new single sorted array.
   * Pre: Both vectors are sorted.
   * Pre: Elements in both vectors may be compared to each other.
   * Pre: There is sufficient memory to allocate the new array.
   * Post: The returned array is sorted, and contains all the
   *   elements of the two arrays (no more, no less).
   * Post: The two arguments are not changed
   */
  public String[] merge(String[] left, String[] right) {
    // Create a new array of the appropriate size.
    String[] result = new String[left.length + right.length];
    // Create indices into the three arrays.
    int leftIndex=0;     // Index into left array.
    int rightIndex=0;    // Index into right array.
    int index=0;          // Index into result array.
    // As long both vectors have elements, copy the smaller one.
    while ((leftIndex < left.length) && (rightIndex < right.length)) {
      if(left[leftIndex].compareTo(right[rightIndex]) < 0) {
        result[index++] = left[leftIndex++];
      } // first element in left subvector is smaller
      else {
        result[index++] = right[rightIndex++];
      } // first element in right subvector is smaller or equal
    } // while both vectors have elements
    // Copy any remaining parts of each vector.  
    while(leftIndex < left.length) {
      result[index++] = left[leftIndex++];
    } // while the left vector has elements
    while(rightIndex < right.length) {
      result[index++] = right[rightIndex++];
    } // while the right vector has elements
    // That's it
    return result;
  } // merge(String[], String[])

} // class MergeSortStrings


Testing

What? You want testing, too? The most systematic way to test would resemble the testing we used for trinary search in assignment 6. We would build a number of sorted arrays of varying lengths. For each such array, we would generate every permutation, and try sorting it. By comparing the sorted permutation to the original (sorted) list, we would be able to tell whether or not the sorting routine worked.

One advantage of such a technique is that it catches lots of errors, because it's relatively thorough in its testing. Another is that you don't have to read the output to make sure that (1) it's sorted and (2) it contains all of the input strings (which are two common failings of erroneous sorting algorithms). A disadvantage is that you have to write the permutation code. Another is that it might be somewhat slow.

Here's a compromise. It builds sorted arrays, permutes them (but doesn't generate all permutations), and then sorts and compares them to the original. One should also test already sorted (and reverse sorted) lists as input, but that's left for another day.


import StringSorter;
import SimpleOutput;
import QuicksortStrings;
import MergeSortStrings;
import java.util.Random;

/**
 * Test a sorting routine.  Fun fun fun.  Created as part of the
 * answer key for assignment 7 of Grinnell College's CSC152 99S.
 * This can either be used as a helper class, in which case you
 * need to create a StringSorter and pass it to the constructor, 
 * or as the main class, in which case you need to specify the 
 * sorting method on the command line (quick or merge).
 *
 * The technique we use for sorting is to start with a sorted
 * list, mix the list up, sort it, and see if we got the same
 * list.  We do it again and again and again, for lists of
 * varying sizes.
 *
 * @author Samuel A. Rebelsky
 * @version 1.0 of March 1999
 */
public class SortTester {

  // +--------+--------------------------------------------------
  // | Fields |
  // +--------+

  /** The sorting method we use. */
  protected StringSorter sorter;

  /** The maximum size of list to try to sort. */
  protected int listSize;

  /** The number of times we try to sort each list. */
  protected int repetitions;

  /** A string of many A's.  Used to generate sample strings. */
  protected String As;

  /** Are we testing our tester? */
  protected boolean TESTING = false;

  /** A random number generator. */
  protected Random generator;


  // +--------------+--------------------------------------------
  // | Constructors |
  // +--------------+

  /**
   * Build a tester that uses a default maximum size and
   * number of repetitions.
   */
  public SortTester(StringSorter sorter) {
    this(sorter, 20, 10);
  } // SortTester(StringSorter)

  /**
   * Build a tester that uses a specified sorting routine,
   * maximum list size, and number of repetitions.
   */
  public SortTester(StringSorter sorter, int size, int reps) {
    this.sorter = sorter;
    this.listSize = size;
    this.repetitions = reps;
    this.As = "AAAAA";
    this.generator = new Random();
  } // SortTester(StringSorter, int, int)


  // +----------------+------------------------------------------
  // | Public Methods |
  // +----------------+

  /**
   * Run the tests, reporting the results.
   */
  public void runTests(SimpleOutput out) {
    String[] sorted;	// A sorted array
    String[] mixedup;	// A "mixed up" version
    String[] resorted;	// That array, resorted

    // For each list size
    for (int size = 1; size <= listSize; ++size) {
      // Build a sorted list of the appropriate size
      sorted = makeSortedList(size);
      // Print some information
      out.println();
      out.println("Checking list of size " + size);
      // Print it out when testing
      if (TESTING) printArray(out, sorted);
      // Make a copy
      mixedup = copyArray(sorted);
      // Run the tests
      for (int test = 0; test < repetitions; ++test) {
        // Some feedback so that you can tell it's running
        out.print(".");
        // Build a permutation.
        mixup(mixedup);
        // Sort the permutation
        resorted = sorter.sort(mixedup);
        // Print out an example when testing
        if ((TESTING) && (test == 0)) {
          out.println();
          out.println("Mixed up");
          printArray(out, mixedup);
          out.println();
          out.println("Resorted");
          printArray(out, resorted);
        }
        // See if it's correctly sorted.
        if (different(sorted,resorted)) {
          out.println("Problem sorting!");
          out.println("=== ORIGINAL ARRAY ===");
          printArray(out, mixedup);
          out.println("=== 'SORTED' ARRAY ===");
          printArray(out, resorted);
        } // if it's not correctly sorted
      } // for each test
      out.println();
    } // for each list size
  } // runTests(SimpleOutput)


  // +----------------+------------------------------------------
  // | Helper Methods |
  // +----------------+

  /**
   * Copy an array of strings.
   */
  protected String[] copyArray(String[] strings) {
    String[] copy = new String[strings.length];
    for (int i = 0;i < strings.length; ++i) {
      copy[i] = strings[i];
    }
    return copy;
  } // copyArray(String[])

  /**
   * See if two arrays of strings are different.
   */
  protected boolean different(String[] alpha, String[] beta) {
    if (alpha.length != beta.length) return true;
    for (int i = 0; i < alpha.length; ++i) {
      if (!alpha[i].equals(beta[i])) return true;
    }
    // If we've made it this far, they must be the same.
    return false;
  } // different(String[], String[])

  /**
   * Make a sorted list of an appropriate size.
   */
  protected String[] makeSortedList(int size) {
    String[] sorted = new String[size];
    for (int i = 0; i < size; ++i) {
      sorted[i] = nAs(i+1);
    }
    return sorted;
  } // makeSortedList(int)

  /**
   * Mix up the elements of an array.
   */
  protected void mixup(String[] strings) {
    // Randomly swap some number of times.
    for (int i = 0; i < strings.length; ++i) {
      swap(strings, 
           Math.abs(generator.nextInt() % strings.length),
           Math.abs(generator.nextInt() % strings.length));
    } // for
  } // mixup(String[])

  /**
   * Create a string of n "A"s.
   */
  protected String nAs(int n) {
    while (As.length() < n) {
      As = As.concat(As);
    }
    return As.substring(0,n);
  } // nAs(int)

  /**
   * Print an array of strings.
   */
  protected void printArray(SimpleOutput out, String[] strings) {
    for (int i = 0; i < strings.length; ++i) {
      out.println(strings[i]);
    }
  } // printArray(SimpleOutput, String[])

  /**
   * Swap two elements.
   */
  protected void swap(String[] strings, int x, int y) {
    String tmp = strings[x];
    strings[x] = strings[y];
    strings[y] = tmp;
  } // swap(String[], int, int)


  // +------+----------------------------------------------------
  // | Main |
  // +------+

  public static void main(String[] args) {
    SimpleOutput out = new SimpleOutput();
    SortTester tester = new SortTester(new MergeSortStrings());
    if (args.length < 1) {
      out.println("You must specificy quick or merge");
      return;
    }
    if (args[0].equals("quick")) {
      tester = new SortTester(new QuicksortStrings());
    }
    else if (args[0].equals("merge")) {
      // already set
    }
    else {
      out.println("Invalid sorting method: " + args[0]);
      out.println("You must specificy quick or merge");
      return;
    }
    tester.runTests(out);
  } // main(String[])
} // SortTester



Disclaimer Often, these pages were created "on the fly" with little, if any, proofreading. Any or all of the information on the pages may be incorrect. Please contact me if you notice errors.

This page may be found at http://www.math.grin.edu/~rebelsky/Courses/CS152/99S/Assignments/notes.07.html

Source text last modified Sun Mar 14 14:11:29 1999.

This page generated on Sun Mar 14 14:14:06 1999 by SiteWeaver. Validate this page's HTML.

Contact our webmaster at rebelsky@math.grin.edu