The Concept
Applications
Array-based
Implementation/Data Representation
Reference-based
Implementation/Data Representation
The Concept
In Great Britain (and some other parts of the English-speaking world), what we in the USA refer to as a waiting line (as in a bank or grocery store) is usually called a queue. The person at the front of the queue receives some kind of service and then departs. A person entering the queue does so at the rear. Thus, a queue exhibits a FIFO ("first-in-first-out") arrival/departure pattern. Viewing it slightly differently, we can say that a queue is a sequence of items such that insertions occur at one end (called the rear) and deletions occur at the other end (called the front). The insert and delete operations are usually called enqueue and dequeue, respectively (even though a good argument could be made for referring to them as insert and delete in order to maintain uniformity with other kinds of container structures).
Traditionally, the operations applicable to a queue are analogous to those that can be applied to a stack, there being
Here is a Java interface for the queue ADT:
/** An instance of a class that implements this interface represents a ** queue capable of holding items of the type specified by the client in ** instantiating the generic type parameter T. A queue is a container ** having a FIFO ("first-in-first-out") arrival/departure pattern (which ** is achieved by having a queue be a list in which all insertions occur ** at one end (the "rear") and all removals occur at the other (the "front"). ** ** Author: R. McCloskey ** Date: September 2020 */ public interface Queue<T> { // observers // --------- /** Returns the number of items on this queue. */ int sizeOf(); /** Returns true iff there are no items on this queue. */ boolean isEmpty(); /** Returns true if this queue fails to have the capacity to store ** any more items (i.e., enqueue() would fail), false otherwise. */ boolean isFull(); /** Returns (a reference to) the item at the front of this queue. ** pre: !this.isEmpty() */ T frontOf(); // mutators // -------- /* Removes all the items from this queue, leaving it empty. ** post: isEmpty() */ void clear(); /** Places the specified item onto the rear of this queue. ** pre: !isFull() ** post: Letting elem(k) refer to the k-th item on a queue, counting ** from the front starting at zero, and letting q refer to this ** queue before enqueue() is applied: ** this.sizeOf() = q.sizeOf() + 1 && ** this.elem(q.sizeOf()) = item && ** for all k satisfying 0 <= k < q.sizeOf(), this.elem(k) = q.elem(k) */ void enqueue(T item); /** Removes the item at the front of this queue and returns (a reference to) it. ** pre: !isEmpty() ** post: Letting elem(k) refer to the k-th item on a queue, counting ** from the front starting at zero, and letting q refer to this ** queue before dequeue() is applied: ** this.sizeOf() = q.sizeOf() - 1 && ** for all k satisfying 0 <= k < this.sizeOf(), this.elem(k) = s.elem(k+1) */ T dequeue(); } |
Notice that dequeue() acts not only as a mutator but also an observer. This is strictly for the convenience of the client, as it is frequently the case that, when the item at the front of the queue is to be retrieved, it is also to be removed.
Use of queues by computer operating systems:
A computer operating system manages the resources available to the
processes running on the machine. Among these resources are the
processor(s), space (memory), peripheral devices (e.g., printers, disk units).
Often, a process will request a resource that is not currently available
because it is already being used by some other process.
When this happens, the request is recorded and, later, when the
resource becomes available, the request is granted.
Often (but not always), the order in which requests for a particular
resource are granted corresponds to the order in which they were submitted.
One way to achieve this is by associating with each resource a queue in
which the as-yet-ungranted requests for it are stored.
For convenience, we assume that the vertices of G are identified by the integers 0 through N-1, where N is the number of vertices in G. The output is an array dist[0..N-1], such that, for each z, 0≤z<N, dist[z] equals d(z), i.e., the distance from vertex v to vertex z, which we define to be the length of any shortest path from v to z. (If there is no path from v to z, dist[z] should end up containing, say, -1.)
One way to solve this problem is to perform a breadth-first search/traversal of the graph, starting with the source vertex. Sometimes this is called a "level-by-level" traversal, because the search implicitly builds a rooted tree whose root is v (the source, whose distance from itself is zero), whose children are the vertices at distance one from v, whose grandchildren are the vertices at distance two from v, etc., etc. This happens because the vertices are "visited/discovered" in ascending order by their distances from the source vertex v.
Such a traversal of the graph can be described as a sequence of explorations and discoveries of the vertices reachable from the source vertex, in which a queue is used for the purpose of properly scheduling those explorations.
In more detail, to explore a vertex simply means to examine each of its outgoing edges to see which vertices lie at the other ends. If a vertex at the other end of an outgoing edge is one that has not been encountered before, then it has just been discovered!
Immediately upon discovering a vertex, we place it at the rear of the queue so that, at some later time, it will be explored for the purpose of discovering more vertices reachable from the source. Indeed, the "next" vertex to be explored is the one at the front of the queue. By using a queue to hold the vertices waiting to be explored, we ensure that they are explored in the same order that they are discovered! This is crucial to the correct workings of the algorithm. In order to get things started, we initially place the source vertex into the queue.
The following is a Java method that implements the algorithm sketched above. Given a digraph and a "source" vertex v within that graph, it returns an array of int's indicating each vertex's distance from v. The method assumes the existence of a class DiGraph each instance of which represents a directed graph whose vertices are identified by the natural numbers in the range [0..N), where N is the number of vertices in the graph. It also assume that that class has the instance methods numVertices() and hasEdge(), the intended meanings of which should be obvious. The class QueueX can be any class that implements the Queue interface shown above.
|
As an exercise, imagine that we call the method distancesFrom() and pass to it a Digraph object representing the graph described by the adjacency matrix shown below and the number 5 (indicating that vertex 5 is to play the role of the "source" vertex). The matrix is to be interpreted as follows: a 1 in location (i,j) (row i, column j) indicates that there is an edge in the graph directed from vertex i to vertex j. A zero indicates the lack of such an edge.
To the right of the adjacency matrix we show the tree that is implicitly described by the sequence of vertex explorations and discoveries during execution of the method. The root node of the tree is the source vertex and, with respect to every parent-child pair of nodes in the tree, the parent is the vertex that was being explored when the child was discovered. Thus, every path in the tree starting at the root node corresponds to a shortest path in the original graph.
Adjacency Matrix | Breadth-first Search Tree |
---|---|
13 | 1 0 0 0 0 0 1 0 0 0 0 0 0 0 12 | 0 0 0 0 0 0 0 0 0 0 0 1 0 0 11 | 0 0 0 0 0 0 0 0 0 0 0 0 1 0 10 | 0 0 0 0 0 0 0 0 0 0 0 0 0 0 9 | 0 0 0 1 0 0 1 1 0 0 0 0 0 0 8 | 1 1 1 0 1 1 0 0 0 0 0 0 0 0 7 | 1 0 0 1 0 0 1 0 0 1 0 0 0 0 6 | 0 0 0 0 0 0 0 1 0 1 0 0 0 1 5 | 0 0 1 0 0 0 0 0 1 0 0 0 0 0 4 | 0 0 0 1 0 0 0 0 1 0 0 0 0 0 3 | 0 1 0 0 1 0 0 1 1 1 0 0 0 0 2 | 0 1 0 0 0 1 0 0 1 0 0 0 0 0 1 | 0 0 1 1 0 0 0 0 1 0 0 0 0 0 0 | 0 0 0 0 0 0 0 1 1 0 0 0 0 1 +-------------------------------- 0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
5 / \ / \ / \ / \ 2 8 | / \ | / \ | / \ 1 0 4 | / \ | / \ 3 7 13 | | | | 9 6 |
History of Queue and Contents of dist[] |
---|
+---+---+---+---+---+---+---+---+---+---+---+ queue | 5 | 2 | 8 | 1 | 0 | 4 | 3 | 7 |13 | 9 | 6 | +---+---+---+---+---+---+---+---+---+---+---+ enq. 1 3 4 6 8 9 11 13 14 17 19 deq. 2 5 7 10 12 15 16 18 20 21 22 +---+---+---+---+---+---+---+---+---+---+---+---+---+---+ dist | 2 | 2 | 1 | 3 | 2 | 0 | 4 | 3 | 1 | 4 |-1 |-1 |-1 | 3 | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
In the figure above, we show the history of the queue during execution of the method, as well as the array that would be returned by the method. What the figure shows is that the vertices were placed into the queue in this order: 5, 2, 8, 1, 0, etc., etc. But what it also shows is the order in which enqueue and dequeue operations were carried out. In the given graph, eleven vertices are reachable from the source vertex; thus, a total of 22 enqueues and dequeues were performed (because each reachable vertex was both placed onto the queue and later removed from it). The numbers in the rows marked enq. and deq. indicate the order in which the operations occurred. For example, the 6th operation to occur was to enqueue vertex 1, while the 10th was to dequeue that same vertex.
Somewhat redundantly, here is a high-level explanation of why the algorithm works as intended.
The key to achieving these properties is the use of the queue to "schedule" the explorations of vertices. A more detailed proof of the algorithm's correctness can be found in the appendix, which is optional reading.
Naive Approach
Following an approach similar to that used in developing an array-based
representation of a stack, let us propose the following array-based
representation scheme for queues: for a queue containing k items,
store (representations of) those items, in order from front to rear,
in locations 0, 1, ..., k-1 of an array, which we will call
items. (The values of array elements at locations k
and beyond are irrelevant.) Rather than referring to the number of items
as k, we shall use the variable numItems (just as in
the Stack class).
For example, a queue containing the characters '$', 'd', 'z', '#', 'A', and '9' (written from front to rear) would be represented as follows:
0 1 2 3 4 5 6 7 N-1 +---+---+---+---+---+---+---+---+------------+---+ items |'$'|'d'|'z'|'#'|'A'|'9'| | | ... | | +---+---+---+---+---+---+---+---+------------+---+ +---+ numItems | 6 | +---+ |
Having proposed a representation scheme, let's determine whether it admits easy and efficient implementations of the standard queue operations. To implement frontOf() is easy: we simply return the value items[0]. As for enqueue(), it, too, is simple: we simply store the value to be inserted into items[numItems] and then increment numItems. (This assumes that numItems < N. Otherwise, before inserting the new value into the queue, we would have to create a new, longer array, copy the contents of items[] into it, and then, via assignment statement, make items refer to the new array.)
The dequeue() operation, however, does not work out so nicely. In order to delete the item at the front of the queue, while at the same time remaining faithful to our proposed representation scheme, we must shift all the elements in items[1..numItems-1] one location to the "left" and then decrement numItems. Shifting the elements could be accomplished as follows:
for (int i = 0; i != numItems-1; i = i+1) { items[i] = items[i+1]; }Notice that execution of this requires time proportional to the number of items on the queue. In other words, it has linear running time. Intuition tells us that we ought to be able to do better.
What would happen if we stored the queue items in the array in order from rear to front, rather than front to rear? That is, suppose that we kept the rear item at location zero, the one preceding it (on the queue) at location one, etc., etc., and the front item at location numItems-1. Then to perform a dequeue() would be easy: simply decrement numItems. But now enqueue() would require that we shift items[0..numItems-1] to the "right" one position so as to make room (at location zero) for the value being inserted. This requires linear time, of course. So this variation doesn't help. For similar reasons, storing the items at the "end" of the array, rather than at the beginning, is no better.
WrapAround Approach
It appears that the decision to keep all the items in the queue stored
in the "leftmost" (or "rightmost") segment of items[] may
need to be relaxed in order to allow us to achieve a sub-linear
running time for both enqueue() and dequeue().
So we propose the following:
Let the segment of items[] holding the items on
the queue "float" through the array, and use int variables
frontLoc and rearLoc to indicate the boundaries of
that segment. Suppose that frontLoc points directly to the
element holding the item at the front of the queue and that rearLoc
points to the element following the one holding the item at the rear.
Taking the example from above, one possible representation would be
0 .... 44 45 46 47 48 49 50 51 .... N-1 +---+-------+---+---+---+---+---+---+---+---+-------+---+ items | | .... | |'$'|'d'|'z'|'#'|'A'|'9'| | .... | | +---+-------+---+---+---+---+---+---+---+---+-------+---+ +---+ frontLoc | 45| +---+ +---+ rearLoc | 51| +---+ |
With this representation, an enqueue() requires only that the new item be placed into items[rearLoc] and that then rearLoc be incremented. To perform dequeue() requires only that frontLoc be incremented. Thus, we have achieved simple and constant-time solutions for both of them. How about the other operations? Well, frontOf() is implemented simply by returning the value in items[frontLoc]. As for isEmpty(), it should be clear that the condition of a queue being empty corresponds, in our new representation scheme, to frontLoc == rearLoc.
We are not quite finished, however, because an interesting question arises: what happens when rearLoc is N and an enqueue() occurs? (This will occur the (N+1)-st time that enqueue() is invoked.) One possible answer would be to extend the length of items[]. But, except in the unlikely event that frontLoc == 0, this is rather wasteful, because the array segment items[0..frontLoc) is, logically speaking, empty. In order to make use of it, we may imagine that location 0 comes immediately after location N-1. In other words, we may view the array as being circular in layout, rather than linear. Mathematically, it means that our index calculations should be carried out "modulo N" —meaning that, when incrementing frontLoc or rearLoc (in performing a dequeue or enqueue, respectively), we should increment and then take the remainder of division by N. In other words, if incrementing frontLoc (or rearLoc) results in its having value N, its value should be set to zero!
To illustrate this "wrap-around" scheme, the following is another possible representation of the queue from above, under the assumption that N = 75.
0 1 2 .... 70 71 72 73 74 +---+---+---+-----------+---+---+---+---+---+ items |'A'|'9'| | .... | |'$'|'d'|'z'|'#'| +---+---+---+-----------+---+---+---+---+---+ +---+ frontLoc | 71| +---+ +---+ rearLoc | 2 | +---+ |
Under this scheme, what do we do upon an attempt to insert (i.e., enqueue) if the array items is "full"? For that matter, how do we determine whether or not it is full? It would seem that the condition frontLoc == rearLoc corresponds to the array being full, because that would indicate that the array segment items[frontLoc..N) contains the items on the initial part of the queue and that items[0..frontLoc) contains the remaining items. But earlier —when we considered how to tell whether or not the queue was empty— we claimed that the same condition indicated an empty queue! So, which is it?! Well, it could be either one! That is, the condition frontLoc == rearLoc indicates that either the queue is empty or that the array holding its elements is full. In order to determine which it is, more information is needed!
A good way to provide the extra information is to introduce another instance variable, say numItems, whose value indicates the number of items currently occupying the queue. To maintain its value, we simply increment it each time an item is enqueued and decrement it each time an item is dequeued. In order to determine whether the queue is empty, it suffices to compare numItems against zero. Similarly, to determine whether items[] is full, it suffices to compare numItems against items.length. Employing this approach, one never need test for the condition frontLoc == rearLoc, as it will hold if and only if either numItems == 0 or numItems == items.length.
Note: An alternative approach for storing the extra information is to have an instance variable whose purpose is "to remember" which of the two mutation operations was applied most recently. If frontLoc == rearLoc and enqueue (respectively, dequeue) was most recently applied, the array is full (respectively, the queue is empty). End of note.
Having introduced numItems, we consider the relationship that exists between it, frontLoc, and rearLoc. Clearly, the value of rearLoc should always be precisely numItems positions "to the right" (using wraparound when necessary) of frontLoc. That is, an invariant of our representation scheme is
In other words, the value of rearLoc is calculable from the values of the other two. It follows that we don't need the variable rearLoc! In its place, we may use the right-hand side of the above equation.
Here is the complete implementation:
/** QueueViaArray.java ** An instance of this class represents a queue capable of holding items ** of the type specified by the client in instantiating the generic type ** parameter T. The implementation is based upon storing the queue items ** in an array. ** ** Author: R. McCloskey ** Date: September 2020 */ public class QueueViaArray |
Hence, although the abstract queue structure is growing and shrinking in small increments, the underlying structure used to represent it is growing and shrinking (occasionally) in large increments.
A concrete representation that makes use of references, rather than an array, can conveniently grow and shrink incrementally, just like the abstract structure that it represents.
The idea is to make use of a (generic) class that provides one-directional linking capabilities. We shall call this class Link1<T>. An object of this class can be depicted as
+---+---+ | * | *-+----> points to a Link1<T> object +-+-+---+ | | v points to an object of type T |
That is, a Link1<T> object contains a reference to an object (of type T) and a reference to another object of type Link1<T>. An implementation of this class is as follows:
/** An instance of this class contains a reference to an object of the ** specified type T (the generic type parameter) and a reference to ** an object of the same kind (i.e., Link1). The idea is that objects ** of this class can be used as building blocks to form one-directional ** linked structures (i.e., one-way lists). */ public class Link1<T> { // instance variables // ------------------ public T item; public Link1<T> next; // constructors // ------------ public Link1(T item, Link1<T> next) { this.item = item; this.next = next; } public Link1(T item) { this(item, null); } public Link1() { this(null, null); } } |
Using Link1 as a basis, we can represent the queue containing COW, CAT, DOG, BUG, and ANT objects as follows:
+-----+---+ +-----+---+ +-----+---+ +-----+---+ +-----+---+ | COW | x-+---->| CAT | x-+---->| DOG | x-+---->| BUG | x-+---->| ANT | x-+--! +-----+---+ +-----+---+ +-----+---+ +-----+---+ +-----+---+ ^ ^ | | | | +-+-+ +-+-+ | x | | x | +---+ +---+ front rear |
For simplicity, we have simply written each Link1 object's animal name inside (the box representing) its first field. In reality, each such field is a reference (i.e., pointer) to the corresponding animal object.
Following this approach, here is the queue class that we derive:
/** An instance of this class represents a queue capable of holding items ** of the specified type T (the generic type parameter). ** The underlying implementation makes use of a linked structure of ** objects arising from the Link1 class. ** ** Author: R. McCloskey ** Date: January 2020 */ public class QueueViaLink1<T> implements Queue<T> { // instance variables // ------------------ protected Link1<T> front; // reference to object holding front item on queue protected Link1<T> rear; // reference to object holding rear item on queue private int numItems; // # of items on the queue // constructors // ------------ /* Establishes this queue as being empty. */ public QueueViaLink1() { clear(); } // observers // --------- public int sizeOf() { return numItems; } public boolean isEmpty() { return sizeOf() == 0; } public boolean isFull() { return false; } public T frontOf() { return front.item; } /* Returns a string, beginning and ending with square brackets, containing ** the (images of the) items on this queue, going from front to rear, ** separated by spaces. */ @Override public String toString() { Link1 pntr = front; StringBuilder result = new StringBuilder("[ "); for (int i=0; i != numItems; i++) { result.append(pntr.item + " "); pntr = pntr.next; } return result.append(']').toString(); } // mutators // -------- public void clear() { front = null; rear = null; numItems = 0; } public void enqueue( T item ) { Link1<T> newRear = new Link1(item, null); if (isEmpty()) { front = newRear; } else { rear.next = newRear; } rear = newRear; numItems = numItems + 1; } public T dequeue() { T result = front.item; front = front.next; numItems = numItems - 1; return result; } } |
A slightly different approach, which is more clever but not really any better, is to use a circular chain of Link1 objects. Here, the class implementing queues needs only a single variable, which is a reference to the Link1 object corresponding to the rear of the queue. As the chain is circular, this object includes a reference to the front of the queue, so there is no need for the queue class to include an instance variable pointing to the front of the queue. (Assuming that the instance variable that refers to the rear is called rear, a reference to the front of the queue is obtained using the expression rear.next.) Here is a picture depicting the situation:
+---------<--------------------------<---------------------<-------+ | | v | +-----+---+ +-----+---+ +-----+---+ +-----+---+ +-----+-+-+ | COW | *-+---->| CAT | *-+---->| DOG | *-+---->| BUG | *-+---->| ANT | * | +-----+---+ +-----+---+ +-----+---+ +-----+---+ +-----+---+ ^ | | +-+-+ | * | +---+ rear |
We leave it as an exercise for the reader to modify the QueueViaLink1 class above to make use of this idea.
First we prove a few graph-theoretic results that will come in handy in the proof of the algorithm's correctness. We assume that G is a directed graph whose set of edges is E. Recall that d(x) is the distance from the source vertex, v, to vertex x, meaning the length of any shortest path starting at v and ending at x.
Lemma 1: Let (x,y) be in E and suppose that there is a path in G from v to x. Then d(y) ≤ d(x) + 1.
Proof: Take any shortest path from v to x (which, by definition of d, must have length d(x)) and extend it by the edge (x,y). The resulting path from v to y has length d(x) + 1; hence, the shortest path from v to y is of that length or less, which is to say that d(y) ≤ d(x) + 1.
Lemma 2: Let d(y) = k, where k > 0.
(a) Then there exists a vertex x for which
d(x) = k-1 and (x,y) is in E.
(b) There is no vertex x' for which
d(x') < k-1 and (x',y) is in E.
Proof: Part (b) follows from Lemma 1. As for Part (a), let P = w0, w1, ..., wk-1, wk (with w0 = v and wk = y) be a shortest path in G from v to y. Letting x = wk-1, we have that (x,y) is in E. It remains to show that d(x) = k-1. The prefix of P of length k-1 is a path from v to x; hence d(x) ≤ k-1. It remains only to show that d(x) > k-2. Suppose, to the contrary, that d(x) = j, where j ≤ k-2. But then, by Lemma 1, we would have d(y) ≤ j+1 ≤ k-1, contradicting the assumption d(y) = k.
Here, once again, is the algorithm:
|
Lemma 3: Following initialization of every element of dist[] to −1, every assignment to an element of that array changes its value from −1 to some natural number.
Proof: The proof is by induction on the number of assignments made to elements in dist[] following initialization. The first one clearly changes dist[v] from −1 to 0. Every subsequent assignment to an element of dist[] occurs inside the loop. By inspection of the code, it is clear that assignment to an element cannot occur unless its value is −1. Also, as the value it is given is one more than that of another array element (whose value, the induction hypothesis tells us, must be either −1 or a natural number, depending upon whether it was ever changed), that value must be a natural number.
Corollary 3.1: Following initialization, no element of dist[] is the target of more than one assignment.
Corollary 3.2: No vertex in G is discovered (i.e., placed on the queue) more than once.
Proof: Inspection of the algorithm indicates that each discovery of a vertex is immediately followed by an assignment to the corresponding element of dist[]. If a vertex were discovered more than once, the corresponding array element would be the target of more than one assignment, contradicting Corollary 3.1.
Theorem: Let y be a vertex reachable from v, and let d(y) = k. Then
Proof: The proof is by mathematical induction on k.
Basis: k = 0. As the only vertex at distance zero from v is v itself, y must be v. As v is the first vertex placed on the queue, (1) clearly holds. Also, zero is placed into dist[v] prior to the loop, which, applying Corollary 3.1, gives us (2).
Induction Step: Let k > 0 and assume, as an induction hypothesis, that the theorem holds for all k' < k. According to Lemma 2, among the vertices possessing an edge directed to y, none is at distance less than k-1 from v, but there is at least one at distance k-1 from v. Let x be the first such vertex to be discovered. By (2), k-1 will be placed in dist[x] when x is discovered and will remain there until termination of the program. As vertices are explored in the same order as they are discovered (because the algorithm employs a queue for "scheduling" these events), every vertex explored before x (according to (1) of the induction hypothesis) is either at distance less than k-1 from v or at distance k-1 but having no edge to y. By Lemma 2, none of the vertices at distance less than k-1 have edges to y. Hence, y will be discovered during exploration of x. Because (by (2) of the induction hypothesis) the value of dist[x] is k-1 at the time that x is explored, from inspection of the program it follows that k-1 + 1 (i.e., k) is placed into dist[y], satisfying the first part of (2). The second part of (2) follows from Corollary 3.1.
As for (1), suppose, to the contrary, that some vertex z satisfying d(z) > k were discovered before y. By Lemma 2, such a discovery could only happen during exploration of a vertex u satisfying d(u) ≥ k. For z to be discovered before y would require that u be explored before y, which, by the use of the queue for scheduling explorations, means that z would have been discovered before y. But this contradicts (1) of the induction hypothesis.