CPS222 Lecture: Queues and deques - Last Revised 1/15/2013
Objectives:
1. To introduce the data structure queue.
2. To introduce STL support for queues
3. To show two ways of implementing the ADT queue - a circular array, and
a linked list.
4. To introduce variants of queues - eg deque, priority queues.
Materials:
1. Queue operations/axioms to project
2. My implementation of queues using arrays to project
3. My implementation of queues using linked lists to project
I. Introduction
- ------------
A. We have seen that one particularly useful form of sequential structure is
the stack, which results from restricting our access to a sequential
structure to those operating on the item most recently added.
B. There is a second kind of restricted sequence that is also very
useful. This is the queue. A queue is a linear structure in which we
limit ourselves to the following basic operations:
1. Create an empty structure
2. Ask whether the structure is empty
3. Insert a new item at the "rear" of the structure. This operation
goes by a variety of names in various books - our textbook author
uses the term "enqueue"
4. Examine the "front" item in the structure
5. Remove the front item from the structure. Again, this goes by
a variety of names - our textbook author uses the term "dequeue".
C. We can specify the operations of the ADT queue as follows: (PROJECT)
1. CREATE returns queue
Preconditions - None
Postcondition - Queue is empty
2. EMPTY (queue) returns boolean
Preconditions - None
Postconditions - The result is true iff the queue is empty.
3. ENQUEUE(item, queue) modifies the queue
Preconditions - None
Postcondition - Item is added to the rear of the queue
4. DEQUEUE (queue) modifies the queue
Precondition - Queue is not empty
Postcondition - Front item is removed from the queue
5. FRONT (queue) returns item
Precondition - Queue is not empty
Postconditions - The front item in the queue is returned,
but the queue is not affected.
6. It is sometimes useful to also define an operation SIZE which
gives the number of items in the queue
D. The queue is of use whenever one has to model a waiting line. Indeed, the
name queue is the British term for a waiting line. At any time, the
person at the front of the waiting line is the next to be served. New
arrivals are added at the end of the line.
1. Example: Consider the following series of operations
CREATE
ENQUEUE A
ENQUEUE B
ENQUEUE C
DEQUEUE
ENQUEUE D
DEQUEUE
What is the current value of FRONT?
We can trace the behavior of these as follows:
CREATE (Empty)
ENQUEUE A A
ENQUEUE B A B
ENQUEUE C A B C
DEQUEUE B C
ENQUEUE D B C D
DEQUEUE C D
So FRONT is C
2. "Queue behavior" corresponds to the following axioms:
For any item I and queue Q
a. EMPTY(CREATE) ::= true
b. EMPTY(ENQUEUE(I,Q)) ::= false
c. FRONT(CREATE) ::= error
d. FRONT(ENQUEUE(I,Q)) ::= if EMPTY(Q) then I
else FRONT(Q)
e. DEQUEUE(CREATE) ::= error
f. DEQUEUE(ENQUEUE(I,Q)) ::= if EMPTY(Q) then Q
else ENQUEUE(I,DEQUEUE(Q))
g. SIZE(CREATE) ::= 0
h. SIZE(ENQUEUE(I,Q)) ::= SIZE(Q) + 1
i. SIZE(DEQUEUE(Q)) ::= SIZE(Q) - 1
Example: the series of operations we just looked at could be
analyzed axiomatically as follows:
FRONT(DEQUEUE(ENQUEUE('D',DEQUEUE(ENQUEUE('C',
ENQUEUE('B',ENQUEUE('A',CREATE)))))))
We can apply axiom 'f' (non-empty variant) to the inner dequeue/
enqueue sequence
FRONT(DQUEUE(ENQUEUE('D',ENQUEUE('C',
DEQUEUE(ENQUEUE('B',ENQUEUE('A',CREATE)))))))
We can apply axiom 'f' twice - once to each dequeue/enqueue sequence.
Since in each case the queue being added to was not empty prior to
the enqueue, we use the not empty variant each time. Thus, we have:
FRONT(ENQUEUE('D',DEQUEUE(ENQUEUE('C',
ENQUEUE('B',DEQUEUE(ENQUEUE('A',CREATE)))))))
We can apply Axiom 'f' twice again. In the outer case, we use
the "not empty to begin with" variant, but in the inner case,
we use the "empty to begin with variant"
FRONT(ENQUEUE('D',ENQUEUE('C',DEQUEUE(ENQUEUE('B',CREATE)))))
Now we apply Axiom 'f' - "empty to begin with" variant - again:
FRONT(ENQUEUE('D',ENQUEUE('C',CREATE)))
By Axiom 'd' - "not empty to begin with" variant - we have:
FRONT(ENQUEUE('C',CREATE))
By axiom 'd' - "empty to begin with" variant - we end up with 'C' as
our result
E. The following are some typical applications of queues:
1. Queues are used extensively in multi-user operating systems for the
management of shared resources - eg peripheral devices such as
printers attached to a multi-user computer or a network can be
managed by a queue. When a user sends a file to the printer, it is
placed at the end of a queue. When the printer finishes one file, it
goes to the queue to get the next file to print.
2. Queues are used extensively in computer simulations. For example,
suppose the state is trying to decide how to adjust the timing of
traffic lights to optimize traffic flow through the center of a city.
One approach is to try various patterns using a computer simulation in
which car movements are modelled according to empirically determined
probability distributions. In such a simulation program, the line of
cars waiting for a given light is modelled by a queue.
II. Implementing the ADT Queue
-- ------------ --- --- -----
A. As was the case with stacks, the C++ STL includes a queue template,
which can be instantiated to contain items of any desired type.
1. #include
2. Declare queue variables by: queue < type > variable;
3. Or declare a queue type by: typedef queue < type > typename;
4. The names of the operations are a bit unusual, though. They include
the following, where type is the type specified when the template
is instantiated:
a. Constructor
b. bool empty()
c. void push(type) // Note the name!
d. void pop() // Note the name!
e. type front()
5. As was the case with stacks, it is useful to also look at how we
could implement queues directly.
B. When we first implemented the ADT stack, we used an array, then we looked
at an alternate implementation using linked lists. We will also do this
with queues, and for the same reasons.
C. Suppose we were to try to implement a queue using an array, in a way
similar to the way we implemented stacks. Clearly, we would not be able
to work with a single index, since while all stack operations are
performed at one end (the top), queue operations are performed at both
ends. Thus, will need two indices: front and back.
1. Working with these, our queue implementation class would include:
class queue
{
...
private:
int front;
int back;
sometype theArray [ somesize ];
}
2. Of course, we need to assign some interpretation to the indices front
and back.
a. In the stack, top indicated the top item - i.e. the item which was
next to be popped. Thus, it makes sense in the queue for front to
be the index of the the first item in the queue - i.e. the one
that is next to be removed.
b. Two interpretations are possible for back: it can either be the
index of the LAST ITEM ADDED (as was true of top for the stack) or
else the index of the NEXT SLOT TO BE FILLED. (Notice that these
two interpretations differ by one slot.) Because the code is just
a bit simpler, we will adopt the latter interpretation.
D. Having assigned an interpretation to the indices, we can begin to
develop our queue operations.
1. To create an empty queue, we can initialize front to 0 and back to
0. (Note that there is not, of course, a front item as yet. But
the fact that back and front point to the same slot guarantees that
the first item inserted will automatically become the front item,
as desired.)
2. To determine if the queue is empty, we need only ask if front == back.
(I.e. if the slot where the front item is to be found is the next
slot we are to fill, then we have no front item as yet, and the
queue is empty.)
3. To examine the front item in the queue Q, we look at theArray[front].
(Provided, of course, the queue is not empty)
4. To remove this item from the queue, we increment front.
5. To insert an item in the queue, we put it in theArray[back], and then
we increment back. (Note the order!)
E. Unfortunately, if we pursue this course of action long enough, we will
eventually have a situation in which back has gone past the end of
the array, but we may have free space between the beginning of the array
and front which we can no longer use. That is, the queue "migrates"
within the array - e.g. consider what happens as a result of the
following sequence of operations on the queue shown below:
Initial: A B C _ _ _ (3 free slots at end)
Operations: Dequeue, Dequeue, Enqueue D, Enqueue E, Dequeue, Enqueue F,
Dequeue, Dequeue
Result: _ _ _ _ _ F
The array now contains 5 unused slots, but any attempt to insert a new
item will fail because q.back has reached the maximum possible value.
F. A partial solution to the problem of the migrating queue can be obtained
by resetting both front and back pointers to their initial values
whenever the queue becomes empty. However, as the above example shows,
this may not help. A better approach is to use a circular
implementation in which we conceive of the array we have allocated for
storing the queue as wrapping around on itself:
_ _
_ _
_ _
Thus, when we fill the last slot, the next slot we fill becomes
slot 0.
1. Now, when we increment front or back, we use code like:
front = (front + 1) % arraySize;
or
back = (back + 1) % arraySize;
2. However, we have introduced a new problem! In the straight array
implementation, we had the possibility of front "catching up"
with back. In particular, front == back implied an empty
queue. But this is not necessarily the case with a circular queue.
Not only can front catch up with back, but back can also catch
up with front.
a. Consider a queue with 6 slots, as in the above examples. If we
did six successive enqueues, back would again == front, but
this time the meaning would not be that the queue is empty, but
rather than it is FULL!
b. back == front has two possible interpretations:
- It could signal an empty queue: the front item in the queue
is the next slot to fill - i.e. it hasn't been put there yet -
i.e. there is no front item - i.e. the queue is empty.
- It could signal a totally full queue: the next slot to fill
is the front item - i.e. the next item to remove - i.e. an
item must be removed to make room for the next item to be
inserted - i.e. the queue is full.
3. We can resolve the ambiguity in one of two ways:
a. We can waste a slot in the queue, declaring the queue to be
full whenever we would be inserting an item in the slot just
before q.front. This would prevent back from catching up
with front.
b. Or, we can use an additional field in our class.
- We could use a bool field called (say) empty - true just when
the queue is empty.
- We could use an int field called (say) currentSize - which
contains the number of elements in the queue
Either solution would allow us to distinguish the two possible
meanings of back == front.
G. Having adopted the above conventions, we can implement the type queue
using the following apprach:
PROJECT EXAMPLE QUEUE IMPLEMENTATION - ARRAYS
H. There are a several ways to implement queues using linked lists.
1. When we represented a stack using a linked list, we used a single
external pointer to the top item, with each item being linked to
its successor - i.e. the one that is next to be popped..
2. For much the same reason that we used two indices in our array
implementation of queues, we could use two external pointers in a
linked implementaiton - one to the front item and one to the rear- e.g.
assume our queue contains Smith, Franklin, Jones, and Wilson in
that order
Rear
o--------------------------------------------------
__________ ____________ __________ \ __________
| Smith | | Franklin | | Jones | \-->| Wilson |
Front | | | | | | | |
o------>| o-|----->| o-|--->| o-|----->| o-|---
---------- ------------ ---------- ---------- |
-----
---
The code to insert a new node n becomes:
if (front == NULL)
front = n;
else
back -> _link := n;
back := n;
3. We will not develop this method in detail, but instead will look at
a more elegant solution that needs only one external pointer.
I. Another way of implementing queues uses a circularly-linked list:
1. In the linked lists we have considered thus far, a non-empty list has
always had a "last" item, whose successor is NULL. We can also
conceive of a circular list, in which the last item contains a link
back to the first.
2. If we do this with a queue, we could have a single external pointer to
the rear item which would give us ready access to both ends of the
queue. Example:
External Ptr -->
\
--> [ Front ]--> [ Second ] --> [ Third ] --> ... --> [ Rear ] -->
|_________________________________________________________________|
a. Insertions are made between the rear item and the front item,
and the external pointer is then reset to the newly inserted item -
e.g. - the above after inserting a new node:
External Ptr -->
Former \
--> [ Front ]--> [ Second ] --> [ Third ] --> ... --> [ Rear ] --> [ Rear ] -->
|_____________________________________________________________________________|
b. Removals are done by removing the item after the item pointed to
by the external pointer, without altering the external pointer -
e.g. the original example after a remove:
External Ptr -->
Now Now \
--> [ First ] --> [ Second ] --> ... --> [ Rear ] -->
|_____________________________________________________|
3. We have to recognize some special cases, though
a. An empty queue is represented by a NULL external pointer.
b. A one-item queue is represented by an external pointer to a node
pointing to itself.
c. The enqueue algorithm needs to test for an empty queue - in which
case it constructs a one-item queue instead of using the more
general algorithm.
d. The dequeue algorithm needs to test for a one item queue - in
which case, it resets the external pointer to nil.
PROJECT EXAMPLE QUEUE IMPLEMENTATION - LISTS
(Note: the approach I am developing here includes the list
implementation directly, rather than embedding a list in a wrapper
that handles only the actual stack operations as in the book)
III. Variants of the Queue
--- -------- -- --- -----
A. A deque - double ended queue - is a variant of the queue in which we
allow all operations at both ends - i.e. we have:
CREATE
EMPTY
INSERTF - add to front INSERTR - add to rear
REMOVEF - remove from front REMOVER - remove from rear
FRONT - examine the front item REAR - examine the rear item
1. Deques are of little direct interest, though one might be of
use, say, in a supermarket simulation where the possibility of the
rear customer giving up and leaving the line is considered.
2. However, if one has an implementation for a deque he can implement
both the stack operations and the queue operations as deque
operations, by letting the front of the deque be the front of the
queue and the rear of the dequeue be the rear of the queue and the
top of the stack - i.e.
stack operation dequeue operation
CREATE CREATED
EMPTY EMPTY
PUSH INSERTR
POP REMOVER
TOP REAR
queue operation
CREATE CREATE
EMPTY EMPTY
ENQUEUE INSERTR
DEQUEUE REMOVEF
FRONT FRONT
3. This is, in fact, what the STL does - it implements a deque template
(using a linked list of nodes, each of which can contain several
items!)
B. A priority queue is a queue structure in which each item has a priority
value. When an item is inserted in such a queue, it is not inserted
at the rear, but rather in front of all items having lower priority, and
after all items of greater or equal priority. (I.e. the queue is
maintained as an ordered list in priority order.) Such queues are very
important in operating systems.
1. This can be done by using a single queue with insertions possibly
being done in the middle - in which case a linked implementation is
much to be preferred over an array. (We will discuss this later.)
2. Alternately, if the priorities are discrete values, one can have
multiple queues - one for each possible priority value:
a. The ENQUEUE operation adds a new item to the rear of the queue for
its priority.
b. The FRONT and DEQUEUE operations choose the highest-priority non
empty queue.
3. Or, one can use a tree-like structure called a heap, which we will
come to later in the course