| CS 261 | University of Puget Sound | Spring, 2020 |
|
Computer Science II
|
||
|
Abstract Data Types and their Implementations,
Some Basic Algorithms,
Object-oriented Problem Solving, and Efficiency |
||
Warning:
This course is under development.
Although the basic structure of this course is largely established,
nothing on this Web site should be considered official or even possibly
correct.
DO NOT MAKE PLANS BASED ON THE CONTENTS OF THIS SITE UNTIL JANUARY, 2020.
This reading introduces the concept of the queue abstract data type and provides experience with an array implementation and a stack implementation of this ADT.
Most of this reading is an edited version of Henry M. Walker, Introduction to Computing and Computer Science with Pascal, Little, Brown, and Company, 1986, Sections 17.1, 17.5-17.6, with programming examples translated from Pascal to C and then Java. This material is used with permission from the copyright holder.
The stack abstract data type, described in the session on stacks, introduced the concept of an abstract data type (ADT) and discussed the stack as an example that stored and retrieved data in a first-in, last-out (FILO) manner. This lab describes a queue abstract data type that stores and retrieves data in a first-in, first-out (FIFO) manner.
This queue ADT models the checkout counter of a store. A clerk works with one customer at a time, until the customer's bill has been computed and paid. Then the clerk goes on to the next customer. In this situation, while the customer is being served by the clerk, other customers may get into the checkout line to wait for their turn. Normally, customers do not get into line until they have selected all items they wish to buy, and once a customer gets into line, the customer waits until the clerk finishes with those ahead. When we consider this processing at the cash register, we can identify these characteristics.
In addition, if a line becomes too long, customers may decide to purchase their items at another time rather than wait in line. In this situation, we might want to specify a maximum size for the queue, and we might want to test if the queue is full.
Unlike stacks where the operational names Push and Pop are standard, the operations for queues are commonly called by several names. For example, the addition of a customer to a queue may be called Enter, Insert, or Enqueue; the leaving of a customer after being served may be called Delete, Remove, or Dequeue. For parallelism in terminology, we use Enqueue and Dequeue here.
More formally, a queue is defined as the abstract data type that has data of a specified type, and operations described as follows:
create/initialize—a constructor
Create a new, empty queue object.
isEmpty
Determine whether the queue is empty; return true if it is and
false if it is not.
isFull
Determine whether additional items can added to the queue;
return true if additions are possible and
false if it not.
enqueue
Add a new element at the rear of a queue.
dequeue
Remove an element from the front of the queue and return it. (This
operation cannot be performed if the queue is empty.)
Normally, queue operations do not allow access to the last or middle items on the queue, only the first. Thus, queues have no equivalent to the Top operation for stacks.
With these operations, queues provide a rather different pattern of data storage and retrieval than we find with stacks. In particular, once an item is placed on a queue, the item is not retrieved until all items ahead of it have already been removed. Here, the first item placed into a queue is the first one processed, and subsequent items must wait for their turn. We say queues provide First-in, First-out (FIFO) storage or Last-in, Last-out (LILO) storage, in contrast to the FILO or LIFO storage of stacks.
A typical interface for a generic queue with entries of type E follows a format generally parallel to a stack, with adjusted method names.
/* a constructor for an empty queue
*/
/* return whether the queue is empty
pre: queue is an initialized queue
post: returns true if queue is empty;
false otherwise
*/
public boolean isEmpty ()
/* return whether the queue is full
pre: queue is an initialized queue
post: returns true if an additional item
cannot be added to the queue;
false otherwise
*/
public boolean isFull ()
/* insert given item into back of the queue
pre: the specified queue is initialized
returns: null if queue is full
otherwise, reference to item added
*/
E enqueue (E item)
/* removes front item from queue referenced by parameter
pre: the specified queue is initialized
returns: null if queue is empty
otherwise, reference to the item removed
*/
E dequeue ()
As with stacks, one common implementation of a queue involves the use of an array. Although this implementation is reasonably straightforward, a few details require some care.
Our basic approach is fairly simple. Abstractly, we may think of an array as extending to the right indefinitely, and we store our data items in order in this array. We use variables first and last to mark where our first element was added and where the last or most recent element was added. The figure on the right shows this setup, where we have placed four items on the queue. In the figure, item 0 was inserted first, followed by item 1, item 2, and item 3 in that order. The first item is marked by the variable first and the final item added is marked by last.
From this figure, we can trace what happens in our enqueue and dequeue operations. For the enqueue operation, we must add 1 to last to mark a new end for the queue, and insert the specified item at this new location. Similarly, to dequeue, we must return the first item specified, and add 1 to first to to mark the new head of the queue. With this basic picture, we can tell if a queue is empty by checking if first > last. Further, in this figure, the queue has enough space, so it is never full.
In practice, this basic algorithm is complication by the limitation that an array has a finite size; the array does not extend indefinitely to the right. With this limitation, we have two choices.
When we delete an item from the queue, we could move all of the other items to the left to fill in the extra space. In this way, data in our queue would always start at the left end of our array, and we could keep inserting new items until the array was full. No space would be wasted.
We could think of the element at position 0 of the array as following the last element. When last gets to the end of the array, we reuse the space that has been left at the beginning of the array when items have been removed. This approach is shown in the figure below. Here items 0 through n-1 are waiting in an array, and some room is available at the start of the array. When a new item is added, there is no room at the right end of the array, so we reset last to 0 and add the new item in the vacant space at the start.
Of these two alternatives, the first approach involves much shifting of data and thus is inefficient. The second approach allows our code to run much more quickly; however, we do need to be sure that we do not store new items on top of old ones, before the old ones are deleted from the queue. This check can be handled in several ways. One of the easiest is to keep a count of the number of items waiting in the queue. When this number reaches the maximum size of the array, the array is full, and further insertions are impossible. This count also allows us to check if the queue is empty.
As with the implementation of stacks, our implementation of queues
in Java utilizes a class Queue to package together the
various fields needed:
public class Queue <E> {
protected first;
protected last;
protected count;
E queueArray [] ;
...
Also, in this code, we need to be able to increment first and last by 1 easily, with the first element of the array following the last array element. Since queueArray.length gives the size of the array, then this incrementing can be done using modular arithmetic. For example, assuming a queue object has been declared and initialized, then incrementing first would use the statement:
this.first = (this.first + 1) % queueArray.length;
The second approach for implementing queues resolves some of these queue size problems by using the dynamic storage allocation that is available by constructing new objects. As with the discussion of stacks, we want to retain the same operations and calling formats defined earlier when queues were implemented by arrays.
/* constructor for an empty queue */ public boolean isEmpty () public boolean isFull (stringQueue queue) public E enqueue (E item) E dequeue ()
In this structure, we must work with both ends of the queue, inserting items at the tail and deleting them from the head. Here, we view the queue as ordering items from the head to the tail; the head is the first item we will remove, and the tail is the last item. The figure at the right shows how this might work.
In the picture, the queue consists of a list of nodes, where each node
contains an item of data and each record points to the record that comes
after it. This node has the same structure as
class StackNode, although we might prefer a clearer name,
class QueueNode. In addition, for the overall queue, we
will need a class Queue with fiels for the front and
back of the queue must be specified. The appropriate declarations are
public class QueueNode {
E data;
StackNode next;
/* getters and/or setters follow */
}
public class Queue {
QueueNode head;
QueueNode tail;
/* method definitions follows */
}
With these declarations, a constructor should set fields head and tail to null, and the Boolean expression
queue.head == null;
tests whether the queue is empty. The enqueue operation then proceeds by adding an element at the tail end of the list. Also, the dequeue operation proceeds by returning the data at the head of the list, moving the head pointer to the next elemen. Each of these operations also requires some care for processing the special cases when the queue is empty and when it contains only one item.