Return to lecture notes index
March 17, 2003 (Lecture 24)

Overview

Today we did a review for the test. Below are the topics that will be covered. The practice exam for Exam #2 has been posted. It can be found here. We strongly suggest you go over this, as it covers mostly everything except recursion.

LinkedLists

By now, you have not only used the java API's linked list, but you have also implemented your own. Things to make sure you are comfortable with is how to solve simple problems (see practice exam) using a singly or doubly LinkedList.

Queues

A Queue is a datastructure that has the "first in, first out" property, like a line at a box office, for instance. When you line up, the first person in line is the next person to buy a ticket. The methods that would be included in the Queue class would be "enqueue" to add to the end of the queue, and "dequeue", to remove from the head of the queue.

How would you implement a queue? Well, in prior lectures we talked about implementing with a LinkedList verses with an Array or Vector. If we're just talking about straight Arrays and Vectors, then the LinkedList would be a much easier datastructure to use. Why? Because it's much easier to add and remove from opposite ends of a LinkedList than an Array or Vector- in the latter two, you would have to shift all the elements over to accomodate adding or removing one. In a LinkedList, however, you have (or can write) very simple methods to just add to the head, and remove from the tail, which would then not even require ever traversing the LinkedList.

What if you wanted to rid yourself of the overhead? Is there some way to get around the shifting in Arrays and LinkedLists? Yes- if you set a maxmimum size for your Queue, you can implement it using a circular Vector. All you need then is two integers to keep track of where the "head" and "tail" are, which you then move accordingly- each time you add an element, you shift the tail forward one, and everytime you remove an element, you shift the head forward by one. What if you reach the size of the Vector? In order to make sure that tail, for instance, doesn't run off the end of the Vector, you would increment it by saying the following:

tail = (tail+1) % v.size();

What this does is it adds one to the current tail, and then takes the remainder of dividing that new number by the size in the Vector. So if you have 8 elements in your list, and tail is 7, when you increment it, tail becomes 0, effectively wrapping it around to the beginning.

Keep in mind that while you do this, you also have to check to make sure that tail is not overwriting head. If tail is equal to head and that index is not empty, then you know the Queue is full. If you need a visualization of the circular Vector, make sure you take a look at Lecture 19

Stacks

A Stack is a datastructure that has the "last in, first out" property, like a pile of plates, for instance. As you place them in a "stack", you have to remove a plate from the top rather than the bottom, unless you want the rest of the dishes to fall. The corresponding methods are "push", to put an Object onto the Stack, and "pop", which removes the Object from the Stack.

Again, just like with Queues, it is much simpler to implement Stacks using a LinkedList than an Array or a Vector because of all the shifting that is involved with the latter two, but you could also use a circular Vector that would only have a tail to make sure the Stack was not full.

What would you use a stack for anyway? Let's take a look at prefix, infix, and postfix notation.

Prefix, Infix, and Postfix Notation

So for this topic, if you don't quite understand, you should really thoroughly review Lecture 17 and e-mail us with any questions. Here's the basic idea:

Infix notation is the notation that we are all used to: 5*((4+2)/3). To fully parenthesize this expression for conversion (or evaluation using Stacks), add an opening parentheses to the beginning, and a closing one to the end to get (5*((4+2)/3)).

In prefix notation, the operators come right before the two operands that it is to be evaluated with. You can convert infix to prefix by hand by taking each operand, and moving it to the opening parentheses of the sub-expression where it lies to get the following: *5/+423

In postfix notation, the operators come right after the two operands that it is to be evaluated with. You can convert infix to postfix by hand by taking each operand, and moving it to the closing parentheses of the sub-expression where it lies to get the following: 542+3/*

To convert between postfix and prefix, the easiest thing to do is to convert one to infix, and then convert from infix to the other. When converting by hand on the exam, remember to show all work to get full credit.

Suppose you're not converting from infix to postfix by hand. How would you accomplish this? As we mentioned above, this is where Stacks come in handy. With a precedence table and a Stack, you can convert a fully parenthesized infix to the equivalent postfix expression. Again, please see Lecture 17 for the full details. Make sure you understand the steps, and remember the precedence table, as Greg may not give it to you freely on the exam.

Precedence
Operator Stack Input
) N/A 0
( 0 5
+,- 2 1
*,/ 4 3

As with converting by hand, make sure to show all steps (like in the example in Lecture 17) in conversion.

One more application of the Stack is to evaluate a fully parenthesized infix expression. The general idea is that you will have three stacks, one for open parentheses, one for operands, and one for operators. Whenever you see each of the three, push it onto the appropriate stack. When you see a closed parentheses, you push the right operand from the operand stack, and operator from the operator stack, and then the left operand from the operand stack. You evaluate that subexpression, and push the result back onto the operand stack. Then you pop a parentheses from the parentheses stack. If the parentheses stack and the operator stack are then empty, then what's left in the operand stack is the final result. The order in which you place the popped operands is very important, since changing the order will change the outcome of any division or subtraction. So always remember- the first operand out is always on the right. Again, show all work if asked to solve a problem using this algorithm.

Recursion

Remember the Blob problem? Problems like the Blob (and like the Maze you all did as a lab) are most easily solved by recursion. Sometimes, though, it's easier (and more efficient) to write a recursively intuitive method iteratively instead. Let's look at an example:

n! = n * n-1 * n-2 * ... * 1

The base case for this is when n = 0, and then n! = 1

How would we implement this recursively?

  
  public int factorial(int n) throws MyException{
    if(n < 0)
      throw new MyException ("Invalid number: cannot take factorial of negative number");
    else if(n == 0)
      return 1;
    else return n * factorial(n-1);
  }

  

So how would we implement this iteratively?

  
  public int factorial(int n) throws MyException{
    int result = 1;
    if(n < 0)
      throw new MyException ("Invalid number: cannot take factorial of negative number");
    for (int i = 1; i <= n; i++)  //can also start at n and count down
      result *= i;
    return result;
  }

  

Especially when written this way with counting from 1 up to n instead of from n to 1, when looking at the inside of each block of code it might be more intuitive to realize that you're finding the factorial of the parameter. However, whenever you have a recursive method, each time it's called, that call is pushed onto a code stack, to be popped later and dealt with. This uses a small amount of memory, but as, for instance n gets larger and larger, it becomes more efficient to use the iterative method to solve.

Sorts and Big-O

Bubble Sort

A bubble sort traverses the array, or a selected portion of the array, length-1 times (a less efficient version of the bubble sort could pass through the array length times and still produce a sorted array). With each pass through the array, the bubble sort compares adjacent values, swapping them if necessary. This actually results in "bubbling" the highest value in the array up to the highest index (length-1) by the end of the first pass through the array, the second-highest value up to the second-highest index (length-2) after the second pass through the array, and so on. By the time the bubble sort has made length-1 passes through the array (or a selected portion of the array), every item, including the lowest item, is guaranteed to be in its proper place in the array. What is the runtime for this sort?

Let's say we have 10 numbers. The outer for loop of the bubble sort has to run 9 times (once when last_one = 9, once when it = 8, etc., up until it = 1, and when it hits 0 it stops). If we had 100 numbers, the outer loop would run 99 times. If we had 1000 numbers, the outer loop would run 999 times. In general, if there are N numbers, the outer loop will run (N-1) times, but to simplify our math we will say that it runs N times.

If the outer loop runs N times, then the total running time is N times the amount of work done in one iteration of the loop. So how much work is done in one iteration? Well, one iteration of the outer for loop includes one complete running of the inner for loop. The first time through, the inner loop goes 9 times, the second time through it goes 8 times, then 7, and so on. On average, the inner loop goes N/2 times, so the total time is N for the outer loop times N/2 for the inner loop times the amount of work done in one iteration of the inner loop.

For one iteration of the inner loop, we either do nothing if the number in the less than the one after it, or we set three values if we need to swap. In the worst case, we will need to swap at every step, so we will say that the one iteration of the inner loop requires 3 operations. That makes the total time for the bubble sort algorithm N*(N/2)*3 operations, or (3/2)*N2

Selection Sort

In the selection sort, we find the smallest value in the array and move it to the first index, then we find the next-smallest value and move it to the second index, and so on. We start at the first index and walk through the entire list, keeping track of where we saw the lowest value. If the lowest value is not currently at the first index, we swap it with the lowest value. What is the runtime for this sort?

Selection sort slightly better than the bubble sort, because we swap at most once for every index, instead of potentially once for each item. We find the correct index for that particular number. Then we swap it into its correct place in the array.

But the Big-O is exactly the same: O(n2). Each pass through the list, we fix the position of only one more item, so we still need to make n passes. And, each pass, we must compare ourselves to, n/2 other items (amortized cost, since the list shrinks from n to 1). As before, this leaves us with 1/2*n2, but we drop the coefficient and focus on the outer-bound behavior for Big-O.

Insertion Sort

Insertion sort works, as before, by viewing the list as two sub-lists, one of unsorted items, initially full, and one of unsorted items, initially empty. As before, each iteration, the next item from the unsorted list enters the sorted list.

Bascially, we remove the next item from the unsorted list, and that gives us an extra slot in the sorted list. We then move each item, one at a time, into that slot, until we make a "hole" in the right place to insert the new item. When we do, we place it there.

Much like Bubble sort and Selection sort, Insertion sort is O(n2). We move one item to the sorted list each time, so we need to make n passes. And then, each pass, we need to compare it to n/2 items (amortized over all runs). So, it requires 1/2*n*n operations for a Big-O of O(n2).

It is however, quite a bit more expensive in real time. Displacing an item in an array is quite expensive -- everything after it needs to be copied. But, what is special about insertion sort is that it can be started before all of the data arrives. New data can still be sorted, even if it is lower (or higher) than those already fixed in place. Also, the cost of displacing an item isn't present, if a Linked List is being sorted.

Quick Sort

In QuickSort, you choose an arbitrary element of the array to act as a "pivot". You then move this pivot to the end of the list by swapping it with the last number. At this point, you run two pointers down the list of numbers, one starting from the left and going right, and the other starting from the right and going left. Your first step is to start with the one that moves from left to right, and check the element it is pointing to, and see if it is less than the pivot. If it is, simply advance the pointer to the next item in the list. If it is more than the pivot, however, you now begin to check the elements that the other pointer is referring to. If you find a number that is in fact less than the pivot, then you can swap it with the element that the left pointer is pointing to. This, little by little, will begin to sort the list of numbers.

When you get through one iteration, and the pointers are pointing to the same element, you know that must be where the pivot goes, and you swap that element with the pivot (which is now in the last position). Of course, after one iteration of this algorithm, the list will be in no means sorted. So now, you divide the list in half where the pivot was just placed, and you run this quicksort algorithm on the two halves. You do this again and again, until you are calling quicksort on a bunch of lists of only one element, and then you know that it must be sorted.

The average case and worst cases of quicksort actually turn out to be very different, because of the recursive nature of the algorithm. In the average case, when the numbers are in random order, you can assume that the arbitrary pivot you choose will indeed be close to the middle. If this is indeed the case, then you split up the size of the list in half every iteration. This will therefore produce Log N levels (base 2). Since you have to scan the list of numbers in each iteration, then you will have N * Log N = O(N LOG N). Now, for instance, imagine an already sorted list being sent into a Quicksort algorithm. If you were to pick, say, the highest number every time as your pivot (if you were choosing the right most as the pivot), then you would get nowhere. Each iteration you would only actually sort one element. So, you would actually have N levels, and performing N instructions per iteration, thus giving you O(N2).

One thing to be careful of- in most cases, the runtime is the same as the Big-O notation. However, when dealing with Quicksort, since it is the only unstable sort, asking for the average runtime is different from asking about the Big-O of the sort. Big-O means only the worst case runtime in the most general form (no constants or coefficients). So make sure on the exam you know exactly what Greg is asking for.