INFS 519 - September 20, 2011

Table of Contents

Reading

This lecture covers material from chapters 6, 7, and 8.

Stacks

A stack is a data structure of ordered items. All items may only be inserted and removed from one end of the stack, called the top. There are three standard operations on stacks, push, pop, and peek.

push
Place an item onto the top of the stack.
pop
Remove the item currently on the top of the stack.
peek
Observe the item currently on the top of the stack.

Stacks are LIFO, that is, they are last-in, first-out. The last item pushed onto the stack will be the first item popped.

stack underflow
An error cause by attempting to pop an empty stack.

Example: Tower of Hanoi

The Tower of Hanoi is a mathematical puzzle involving discs and pegs. The objective is to move a stack of discs from one peg to another. However, the discs are different sizes and a larger disc may never sit on top of a smaller disc.

A basic version of this puzzle involves three pegs: A, B, and C; along with five discs: 1, 2, 3, and 4. The discs are all different sizes, with disc 1 being the smallest and the rest being increasingly larger - with disc 4 as the largest. The discs are piled on peg A with disc 1 on the top and disc 4 on the bottom.

ABC
1--
2--
3--
4--

The goal is to move the tower of discs over to peg C.

Since we can only take the top disc, our pegs are stacks. They support the pushing and popping of discs, with one extra constraint: a disc may only be pushed onto the stack if it is smaller than the current top of the stack.

Move two:

ABC
---
---
3--
412

Move six:

ABC
---
---
12-
43-

Move ten:

ABC
---
---
--1
234

Move fifteen:

ABC
--1
--2
--3
--4

Example: N-Queens Problem

Place n queens on a n/x/n chessboard, such that no queen can attack any other.

abcd
4----
3----
2----
1----

This problem can be solved in many fashions, but one involves stacks and a technique called backtracking. In the context of algorithms, backtracking refers to returning to a previous state after discovering that a previous choice was incorrect.

A solution to the n-queens problem can be found by using a stack to manage the state of our in-progress attempt at a solution.

We can begin by placing a queen at a1 and push that position on the stack.

a1
-
-
-

Now that we have made our selection for rank 1, let's place a queen on rank 2. We start at a2, and shift the queen to the right until we find a position that does not leave any queens ready to attack.

abcd
4----
3----
2Q---
1Q---

a2
a1
-
-

The position a2 doesn't work, and we can see that neither does b2. However, c2 is just fine given the current state of the board.

abcd
4----
3----
2--Q-
1Q---

c2
a1
-
-

We continue on and find that no position on rank 3 will satisfy the problem's constraint. Now it's time to backtrack! Our choice of c2 must be popped off the stack and we resume the search for a good position for the rank 2 queen. The only choice left is d2 and this does satisfy the constraint.

abcd
4----
3----
2---Q
1Q---

d2
a1
-
-

We continue on in this fashion and will arrive at a solution:

abcd
4--Q-
3Q---
2---Q
1-Q--

c4
a3
d2
a1

Implementations

Stacks can be implemented using arrays or linked lists. The top of the stack needs to be maintained.

Uses

Stacks are an immensely important data structure found throughout computer science.

They're used for:

  • compilers (parsing)
  • AI algorithms (keep track of solution state)
  • language runtimes (keep track of position in a running program)
  • programming (stack languages like Forth and Factor)
  • security exploits (buffer overflow)

Queues

A queue, like a stack, is an ordered data structure. Unlike a stack which is LIFO (last-in, first-out), a queue is FIFO (first-in, first-out). A queue has two ends, a front and an end. Items are added, or enqueued, at the end and removed, or dequeued at the front.

A line of people is a physical manifestation of a queue. New people join the line at the end and the person at the front of the line is the next to leave.

push
Place an item at the rear of the queue, synonymous with enqueue.
pop
Remove the item at the front of the queue, synonymous with dequeue.
peek
See the next item in the queue without removing it.

Both the front and end are tracked in order to provide efficient enqueueing and dequeueing.

Example: Palindromes

A palindrome is a sequence of words, or other items, that reads the same when reversed. For example, "Madam, I'm Adam," is the same sentence when reversed.

We can use queues to test whether a given sentence is a palindrome by enqueueing both the forward and reversed characters in respective queues and then dequeueing a character at a time and checking whether they are equivalent.

ForwardBackward
mm
aa
dd
aa
mm
ii
mm
aa
dd
aa
mm

It's easy to see that both queues contain the same characters in the same order.

Example: Coded Message

We can use queues to decode an encrypted message with a key. The encryption scheme is simple, the values in the key are the offset of the encrypted character from the actual character.

Decryption can be done by storing both the ciphertext and key in queues and then decrypting a character at a time by using the offset from the key value to find the plaintext character.

Ciphertextnovanjghl-mu-urxlv
Key317425317-42-53174

Example: Priority Queue

A priority is similar to a basic queue except items are added with a priority. Items are then dequeued based on their priority.

A collection of queues, one for each priority level, can be used to implement a priority queue.

Priority LevelFront ->
1------Alice
2----FrankCarolBob
3------Dave

In this state, Alice would be removed first, then Bob, then the rest of the names with priority 2, etc.

However, let's consider what happens if we dequeued Alice and Bob, but now enqueue a new name at a high priority.

Priority LevelFront ->
1------Evan
2-----FrankCarol
3------Dave

Evan will be the next item dequeued, not Carol.

Implementations

Like stacks, queues can also be implemented with arrays or linked lists. There are many ways to implement a queue, but here we will review an implementation using a circular buffer and two array indexes, front and next.

Our circular buffer is implemented with an array and is needed because items can be added and removed from two different ends.

Here is an empty queue which will be implemented with an array, store, and both front and next variables which are initialized to 0.

We don't have an explicit rear index, but next - 1 is equivalent to rear in this implementation.

------

Note that we can test if the queue is empty because front == next.

The member variables of this implementation resemble this:

public class Queue<E> {
    int front;
    int next;
    E[] store;
...
}
public boolean isFull() {
    return front == (next + 1) % store.length;
}
public boolean isEmpty() {
    return front == next;
}

Before proceeding, here is code for a few methods.

public void push(item) {
    store[next] = item;
    next = (next + 1) % store.length;
}
public E pop() {
    E result = store[front];
    front = (front + 1) % store.length;
    return result;
}
public int getSize() {
    int size = 0;

    if (next > front) {
        size = next - front;
    } else if (next < front) {
        size = (next + store.length) - front;
    } else {  // next == front
        size = 0;
    }
    return size;   
}

If we push several items on the queue we'll end up in the following state.

frontnext
04

xxxx--

Now let's dequeue an item.

frontnext
14

-xxx--

Calculating the size, we see next is greater than front, so size is 4 - 1 = 3.


Now, if we add two more items, our queue will be full.

frontnext
10

-xxxxx

Why?


Let's make room and dequeue some items.

frontnext
40

----xx

Calling getSize now causes us to execute a different branch because next is less than first.

The size is 0 + 6 - 4 = 2

Uses

Queues are generally used for scheduling or buffering of tasks.

Queues can be found in:

  • thread libraries
  • OS kernels (time-slicing)
  • distributed systems (producers and consumers)

Recursion

In computer science, recursion involves defining an algorithm in terms of itself. This is used to divide a problem into smaller sub-problems that can be combined to form the correct solution for the original problem.

There are two parts to any recursive definition, the base case and the recursive case. It is in the recursive case that a self-reference occurs.

The recursive case decomposes the problem and the base case handles the simplest sub-problem.

Example: Factorials

A factorial is equal to itself multiplied by one less, multiplied by one less, etc.

E.g., 4! = 4 * 3 * 2 * 1.

We can implement this recursively like so:

public long factorial(long n) {
    // The base case
    if (n == 0) {
        return 1;
    } 
    // The recursive case
    else {
        return n * factorial(n - 1);
    }
}

The factorial for any number, n, is just n * (n-1)!). The simplest factorial to calculate, 0! = 1, is also the smallest. As we recurse down, we want to be sure that we don't pass by 0. Our base case then checks for when we've reached 0 and ensures that we recurse no further.

Example: Fibonacci Sequence

The Fibonacci sequence is a sequence of integers defined by a recurrence relation. Subsequent values in the sequence are determined by previous values.

The nth number in the sequence is calculated like so: f(n) = f(n-1) + f(n-2).

The first two values are given f(0) = 0 and f(1) = 1.

public long fib(long n) {
    // Base cases
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } 
    // Recursive case
    else {
        return fib(n-1) + fib(n-2);
    }
}

Example: Greatest Common Divisor

The Euclidean algorithm for calculating the greatest common divisor of two integers can be implemented with recursion in Java.

public int gcd(int x, int y) {
    if (y == 0) {
        return x;
    } else {
        return gcd(y, x % y);
    }
}

This is an example of a specific kind of recursion, tail recursion. A function is tail-recursive if its recursive call is a tail call, that is the call is run at the end of the function.

For comparison, here is an iterative implementation of gcd.

public int gcd(int x, int y) {
    int remainder = 0;

    while (y != 0) {
        remainder = x % y;
        x = y;
        y = remainder;
    }

    return x;
}

While every algorithm may be implemented with either recursion or iteration, tail-recursive functions may be safely converted to iterations by a compiler to avoid building up the call stack.

Tail Recursion vs. Augmenting Recursion

Tail recursions was introduced with the gcd function. The factorial and fib functions are both examples of augmenting recursion. Both definitions use recursion to calculate the solution to a sub-problem, but then use that value before returning from the function.

Example: Towers of Hanoi

While working through the Towers of Hanoi puzzle when discussing stacks, you may have noticed a pattern of moving smaller discs out of the way so that larger discs could be moved.

That pattern is recursive and is defined as follows:

  1. Move n-1 smallest discs from A to B.
  2. Move the nth disc (the largest) from A to C.
  3. Move n-1 smallest discs from B to C.

As a simpler example, consider this hanoi function that computes the minimum number of moves necessary to get n discs from A to C.

public int hanoi(n) {
    if (n == 1) {
        return 1;
    } else {
        return (2 * hanoi(n - 1)) + 1;
    }
}

Returning to the stack example, we used 15 moves for n = 4 discs. It looks like we did it optimally, because:

hanoi(4) = (2 * hanoi(3)) + 1 = (2 * 7) + 1 = 15
hanoi(3) = (2 * hanoi(2)) + 1 = (2 * 3) + 1 = 7
hanoi(2) = (2 * hanoi(1)) + 1 = (2 * 1) + 1 = 3
hanoi(1) = 1

Reasoning about Recursion

Org version 7.7 with Emacs version 23

Validate XHTML 1.0