20: Building queues

DRAFT

Announcements

Reminder: Tomorrow is a UMass Monday! You have a quiz! Don’t miss it!

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.

To review, to add to the head of the list, we did the following (review on board):

  • create a new node
  • set its next reference to the current head
  • set head to the new node

To remove the head node, we did the following:

  • remember the head node’s data
  • set head to it’s next reference (now there are no refs into the former head node, so it’s deleted)

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). Suppose we keep a “tail” pointer which always points at the last node in the list. To add, we:

  • create a new node
  • set tail node’s next point to this new node
  • update tail to point at the new node

How do remove? We need a pointer to the node before the tail…we’d have to either forward and backward pointers in the list (which then is a doublely-linked list, which makes all other methods more difficult), or traverse to the node-before:

  • traverse to node-before, keep reference to it
  • remember the tail node’s data
  • set tail to the node-before

What have we learned? 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 LinkedQueue<E> {
    Node<E> head;
    Node<E> tail;

    public LinkedQueue() {
        head = null;
        tail = null; // d'oh
    }
...

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 add(E e) {
        Node<E> node = new Node<>();
        node.data = e;

        if (head == null) { // or size == 0: the queue is empty
            head = node;
        } else {
            tail.next = node;
        }
        tail = node;        
    }

In-class exercise

    public void add(E e) {
        Node<E> node = new Node<>();
        node.data = e;

        if (head == null) { // or size == 0: the queue is empty
            head = node;
        } else {
            tail.next = node;
        }
        tail = tail.next;       
    }

I changed the last line. What’s broken (if anything)?

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 remove() {
        E value = head.data;
        head = head.next;

        if (head == null)  {// or size == 0, or whatev
            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 CircularQueue<E> {
    E[] queue;
    int front;
    int rear;
    int size;

    public CircularQueue(int initialCapacity) {
        queue = (E[]) new Object[initialCapacity];
        size = 0;
        front = 0;
        rear = initialCapacity - 1; // or queue.length - 1 <- probably better
    }

    public boolean isFull() {
        return size == queue.length;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    public void add(E e) {
        // if full, throw exception...

        rear = (rear + 1) % queue.length;
        queue[rear] = e;
        size++;
    }

    public E remove() {
        // if empty...


        E value = queue[front];
        queue[front] = null;
        front = (front + 1) % queue.length;
        size--;
        return value;
    }

In-class exercise

Can we compute the size of the queue? If so, how?

Do we really have to track size in an instance variable? No. It turns out we can compute it, so long as we flag empty spaces with null (and thus don’t allow null to be stored in the queue.)

public int size() {
  if (queue[head] == null) {
    return 0;
  }
  else if ((queue.length + rear - front + 1) % queue.length == 0) {
      return queue.length;
  }
  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. Will you ever do this in real life? Only on whiteboard coding interviews; but these are a very specific niche that not everyone should stress out about.