Stacks

    A stack is a dynamic (the elements can change) set for which the item to be removed is pre-specified: we are only allowed to make deletions from a certain location. Similarly, we can only access one element at a time. The delete operator of a stack removes the most recently added item. For this reason, a stack is often referred to as a "Last in, First Out" structure, or LIFO for short. Similarly, this is the only item we are allowed to look at in our stack. We come across stack-like objects every day: a set of trays at Union South; the scoops on an ice cream cone; or a Pez (tm) dispenser.

    We can actually use an array to implement our stack. Let S be an array of capacity elements. That is, the valid indices of S range from [0, 1, . . ., capacity-1]. We keep track of where we are going to be inserting with a special index called top. That is, the top is the index of the most recently added item. Thus, when the stack is empty, top == -1.

    Let us write methods which insert, remove, and access our stack. We begin with insertion, which is referred to as a "push" in stack-lingo.

    push(x)
    Input: the element to be added to our stack, S.
    Postcondition: S now contains x, and top has been updated appropriately

        if top == capacity - 1
            expandStack()
        top = top + 1
        S[top] = x
    

    We define expandStack() in a manner similar to how we defined expandArray() in the previous lecture: we double the capacity of the array, and copy all of the elements into the same indices.

    To remove an item, we "pop" it off of the stack:

    pop()
    Input: None
    Returns: The item deleted from the stack
    Postcondition: S no longer contains the popped item; top has been updated to refer to the correct item

        if top == -1
            error: underflow
        top = top - 1
        return S[top+1]
    

    As with previous structures, when we remove, we first make sure that there is an item to be removed. That is, we avoid underflow.

    Suppose we have a stack, initially empty, and have capacity = 3. The following sequence shows how the stack changes as we perform different operations. The top of the stack is drawn in red.

    Empty stack:
         

    push(3):
    3    

    push(1):
    3 1  

    pop():
    3    

    push(7):
    3 7  

    push(5):
    3 7 5

    push(2):
    3 7 5 2    

    pop():
    3 7 5      

    We are only allowed to access the top element of a stack. We can see what this top value is using the "peek" method:

    peek
    Returns: The element at the top of the stack

        if isEmpty()
            error: underflow
        else
            return S[top]
    

Queues

    A queue is another dynamic data structure. Just like a stack, we can only insert and delete into certain regions of a queue. However, for a queue the least-recently inserted element is always removed. For that reason, a queue is referred to as a "First in, first out" (FIFO) structure. The line at your local bank or grocery store is a lot like a queue.

    Thus, to implement a queue, we need to keep track of two things: the index of the last insert (the "tail"), and where to delete items from (the "head"). We initialize the tail to be -1, and the head to be 0.

    Again, we can use an array to implement our queue. An insert operation is known as "enqueue", while delete is called "dequeue". We see an example below, with the array capacity at 4. The location of the tail is red, while the location of the head is blue, and elements which are part of the queue but neither the head nor tail are in yellow. Elements which are not part of the queue will have a white background.

    empty:
           

    enqueue(5)
    5      

    Here, our tail and our head overlap, which is why the background is purple.

    enqueue(3)
    5 3    

    enqueue(7)
    5 3 7  

    dequeue()
      3 7  

    dequeue()
        7  

    enqueue(4)
        7 4

    Now what happens if we want to insert (enqueue) another item. There is seemingly no room for the new item: we are at the end of the array. But clearly the queue is not full: there aren't 4 elements in the queue. So the next time we want to insert, we can add our item at the front of the array:

    enqueue(8)
    5   7 4

    We say that our array is a "circular array": when we get to the end, we circle back to the beginning.

    enqueue(1)
    5 1 7 4

    Hence, all items in the array are part of the queue, and the tail has wrapped around so that it is next to our head. Notice that this is a similar state to how our empty queue started: head == tail+1. The difference was that there were no elements in our queue.

    Hopefully we are now ready to implement our queue as an array. We name our array Q, and suppose it has room for capacity elements. We need to keep track of the head and tail indices. The head starts at 0, and the tail starts at -1. We also have a counter, size, which is the number of items currently in our queue. Thus, when tail+1 == head we know our queue is either empty or full, depending on what size is. We can now implement all of our functions:

    enqueue(x)
    Input: x, the item to be added to our queue
    Postcondition: x has been added to the queue

        if size == capacity   // check for overflow
            expandQueue()
        Q[tail] = x           // copy our new item into the queue
        tail = tail + 1       // shift the tail to its new location
        size = size + 1       // update the number of elements in the queue
        if tail == length(Q)  // Check for wrap-around
            tail = 0
    

    We again always check for overflow on an insert. This time, however, we have overflow we perform our expansion in a different way. We can not simply copy every element in Q into the new expanded array at the same index. Consider the case where capacity = 4, head = 3 and tail = 2. If we did perform the expand in the manner described above, our next deque would move the head to 4, which would be an invalid index. Instead, it is necessary to restructure the elements in the array such that the head is at index 0, and the tail is at size - 1, but in such a way that all items are still in the correct order:

    expandQueue()
    Postcondition: Q has been doubled in capacity
    Assumption: size == capacity

        create a temporary array of size 2*capacity, tmp
        for i=0 to size-1
            tmp[i] = Q[(i+head) mod capacity]
        head = 0
        tail = size-1
        Q = tmp
    
    There are several important things to point out about this function: first of all, it is not necessary for the user to know that the items are rearranged in a specific order. It is not necessary because the user does not need to know how the queue is implemented to begin with. As long as it works correctly, thats all that matters to the user. Next, note the use of the mod operator. Provided both arguments are positive, a mod b returns a value in the range [0, b-1]. This feature is what allows us to reorder things correctly. Consider our example from before, with 4 == size == capacity == head + 1. The third element in the queue (that is the third item to be dequeued) is at index 2. Note that the use of the mod gives us the correct value: (3 + 3) mod 4 = 2.

    The rest of our functions are outlined below:

    dequeue()
    Returns: the recently deleted item from our queue
    Postcondition: the item which had been in the queue the longest is deleted

        if size == 0
            error: underflow
        x = Q[head]           // copy our element for return
        head = head + 1       // Shift the head to its new location
        size = size - 1       // Update the number of elements in the queue
        if head == capacity   // Check for wraparound
            head = 0
        return x
    

    isEmpty() Returns: True if there are no elements in the queue, false otherwise

        return size == 0
    

    peek() Returns: The next element to be removed from the queue

        return Q[head]
    

Analyses

    All of the operations that have been described for stacks and queues can be performed in constant (O(1)) time. So, stacks and queues allow for fast insert at the expense of where we can perform our operations.

    So, where are stacks and queues used in computer science? There was a reason I have used the phrase "call stack" in the past. Recall, the call stack is where we keep track of all methods we are currently executing. Each time we call a new function, this is pushed onto the call stack. When the method finishes, the call stack is popped and execution returns to the top of this popped stack.

    Queues are used any time we want to perform things in a certain order. For instance, each time you ask to print something your print job is placed in a queue, and your job does not get printed until it reaches the head of this print queue.


© 2000 Michael Wade
All rights reserved