Lecture 21: Building queues
Announcements
DTA assessment today. Couldn’t have asked for nicer weather.
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. More on this later.
Quiz 4 grades will be posted once the makeups are done. Really good overall, though!
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 in stacks. What about at the tail?
(on board)
Adding at the tail is trivial, but removing the tail in a singly-linked list is not (linear). 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;
}
Changing enqueue
public void enqueue(E element) {
Node<E> node = new Node<E>(element);
if (head == null) {
head = node);
} else {
tail.setNext(node);
}
tail = tail.getNext();
}
We changed the last line. What’s broken (if anything)? There’s a NPE if the queue was empty.
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.