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