21: Building queues
Announcements
Last quiz will be in a week and a half (on the first of May).
SRTI (“Student Response to Instruction”) assessments will be online this year. You’ll get an email about this sometime next week, and I’ll remind you, but it’s really important that you fill them out, good or bad. Instructors and departments all use this data to help improve teaching and the curriculum. I read all of them carefully, so please give me useful feedback – either about me, about the course, or both.
Implementing queues using linked lists
Recall to implement a stack with linked lists, we used just a head pointer. This made sense, because we always manipulated the same end of the list.
Queues have to manipulate both ends of the list, so we’ll need a head and tail pointer. Should we enqueue at the head or the tail? And where should we dequeue?
We know that both adding and removing at the head are pretty straightforward, since that’s what we did with push/pop for stacks. What about adding or removing at the tail?
(on board)
Adding at the tail is trivial, but removing the tail in a singly-linked list is not (linear to seek to the node before the tail). So let’s enqueue onto the tail, and dequeue from the head.
What does the code look like?
public class Queue<E> {
Node<E> head;
Node<E> tail;
public Queue() {
head = null;
tail = null;
}
...
When we enqueue, we make a new node and add it to the rear. Special case: if the queue is empty, our new node will be both the head and the tail. (on board)
public void enqueue(E value) { }
Node<E> node = new Node<E>(value);
if (head == null) {
head = node;
} else {
tail.setNext(node);
}
tail = node;
}
Back to the Queue
Dequeuing is similar. We remove the head node from the list and return its value. The corresponding special case is if the node is the last node; if so, we need to remember to also set tail to null. (on board)
public E dequeue() {
E value = head.getValue();
head = head.getNext();
if (head == null) {
tail = null;
}
return value;
}
Implementing queues using arrays
Suppose we want to use arrays to back our queue, like we did with stacks. With stacks, we fixed the element at index 0 as the “bottom” of the stack, and maintained a top pointer. What if we do this with queues?
(on board)
We can easily enqueue elements by keeping a pointer to the last element enqueued. But what about dequeuing? We’d have to remove the element at position zero, then move the element from position 1 into 0, 2 in to 1, and so on. That’s linear in the size of the queue (and thus terrible).
The right thing to do parallels the linked list implementation. Instead of a head and tail pointer, we’ll maintain two indices into the array, and allow both the front and rear of the queue to move through the array.
There is a trick, though: we need to now thing of the array as a circle, not as a linear array, that “wraps around”. This is kind of like a clock: (on board)
Now, instead of incrementing our front/rear indices, we use front = (front + 1) % capacity)
– this is modular arithmetic, and “wraps around” exactly as we want.
We’ll define rear
as the last occupied index (and initialize to what? virtual position “-1” == capacity - 1). Thus the next place to enqueue is rear + 1
. We’ll define front
as the next occupied index to dequeue
and initialize it to 0.
We have to keep track of the size
, since both an empty queue and a full queue have rear just before front.
public class Queue<T> {
public final int DEFAULT_CAP = 100;
T[] queue;
int size = 0;
int front = 0;
int rear;
public Queue(int capacity) {
queue = (T[])new Object[capacity];
rear = capacity - 1;
}
public Queue() {
this(DEFAULT_CAP);
}
public boolean isFull() {
return size == queue.length;
}
public boolean isEmpty() {
return size == 0;
}
public void enqueue(T value) throws QueueOverflowException {
if (isFull()) {
throw new QueueOverflowException();
}
rear = (rear + 1) % queue.length;
queue[rear] = element;
size++;
}
public T dequeue() throws QueueUnderflowException {
if (isEmpty()) {
throw new QueueUnderflowException();
}
T value = queue[front];
front = (front + 1) % queue.length;
size--;
return value;
}
}
Do we really have to track size
in an instance variable? No. It turns out we can compute it:
public int size() {
if (queue[head] == null) {
return 0;
}
else {
return (queue.length + rear - front + 1) % queue.length;
}
}
Usually we prefer to constant-cost compute properties like size()
rather than track them when modified, but in this case it’s a question of taste/style as to which is more straightforward.
Is the above clearer than the use of an explicit size
counter? Debatable. Is it less error-prone to implement? Debatable. Use your judgment, and develop it by reading others’ code.