jario_
Back to overview

Algorithms and Data Structures · Chapter 5

Lists, stacks and queues

So far, you have stored data in arrays or in ready-made collections. In this chapter, you build the most important data structures yourself: the stack, the queue and the linked list. They differ mainly in where elements come in and go out again, and that decides how fast each operation is.

LIFO and FIFO

A stack works by LIFO, Last In, First Out: what you put on top last, you take off first. The operations are called push (put on top), pop (take off) and peek (just look). Stacks are everywhere something is undone or nested: the back button in the browser, Ctrl+Z, checking brackets, method calls.

A queue works by FIFO, First In, First Out: whoever comes first is served first. The operations are called enqueue (join at the back) and dequeue (take from the front). Examples are print jobs or requests to a server.

ArrayDeque from java.util can do both. In the example, the same three letters come out once reversed and once in insertion order.

import java.util.ArrayDeque;import java.util.Deque;
public class Main {    public static void main(String[] args) {        Deque<String> stack = new ArrayDeque<>();        stack.push("A");        stack.push("B");        stack.push("C");        System.out.print("Stack: ");        while (!stack.isEmpty()) {            System.out.print(stack.pop() + " ");        }        System.out.println();
        Deque<String> queue = new ArrayDeque<>();        queue.offer("A");        queue.offer("B");        queue.offer("C");        System.out.print("Queue: ");        while (!queue.isEmpty()) {            System.out.print(queue.poll() + " ");        }        System.out.println();    }}

A stack with an array

For your own stack, an array and a counter size are enough. size is also the index of the next free slot, and the top element is at data[size - 1].

  • push: write to data[size], then size++.
  • pop: size--, then return data[size]. Nothing is deleted, the slot simply counts as free.

Neither operation needs a loop, no matter how many elements are on the stack. Their running time is O(1), that is, constant. You have to decide what happens on an empty or full stack: here, an IllegalStateException.

public class Main {    static int[] data = new int[10];    static int size = 0;
    static void push(int value) {        if (size == data.length) {            throw new IllegalStateException("Stack is full");        }        data[size] = value;        size++;    }
    static int pop() {        if (size == 0) {            throw new IllegalStateException("Stack is empty");        }        size--;        return data[size];    }
    public static void main(String[] args) {        push(1);        push(2);        push(3);        System.out.println(pop());        System.out.println(pop());        push(9);        System.out.println(pop());        System.out.println("Size: " + size);    }}

A queue as a ring

In a queue with an array, elements join at the back and leave at the front. If you moved all elements one slot forward after each dequeue, that would be O(n). Instead, the start moves: head points to the front element, count counts the elements.

When an index reaches the end of the array, it continues at 0. The modulo operator takes care of that:

int tail = (head + count) % data.length;   // next free slothead = (head + 1) % data.length;           // after dequeue

That way, the array becomes a ring buffer, and both enqueue and dequeue stay O(1). In the example, 40 and 50 land at index 0 and 1 again, because those slots have become free.

public class Main {    static int[] data = new int[3];    static int head = 0;    static int count = 0;
    static void enqueue(int value) {        if (count == data.length) {            throw new IllegalStateException("Queue is full");        }        int tail = (head + count) % data.length;        data[tail] = value;        count++;        System.out.println("enqueue " + value + " at index " + tail);    }
    static int dequeue() {        if (count == 0) {            throw new IllegalStateException("Queue is empty");        }        int value = data[head];        head = (head + 1) % data.length;        count--;        return value;    }
    public static void main(String[] args) {        enqueue(10);        enqueue(20);        enqueue(30);        System.out.println("dequeue " + dequeue());        System.out.println("dequeue " + dequeue());        enqueue(40);        enqueue(50);        while (count > 0) {            System.out.println("dequeue " + dequeue());        }    }}

Linked lists

A singly linked list consists of nodes. Each node knows its value and the next node (next), and the last one points to null. The list itself only remembers head, the first node.

You always walk through it the same way:

Node current = head;while (current != null) {    // use current.value    current = current.next;}

Array and linked list compared:

  • Read the element at index i: array O(1), list O(n), because you have to walk node by node.
  • Insert at the front: array O(n), because everything is moved, list O(1) with a new head.
  • Insert at the back: array O(1) as long as there is room, list O(n), O(1) with a tail reference.
  • Size: an array is fixed, a list grows as needed.

A stack can be built with either. For a queue with nodes, you also remember tail, so that enqueue doesn't have to walk to the end.

public class Main {    static class Node {        int value;        Node next;
        Node(int value) {            this.value = value;        }    }
    public static void main(String[] args) {        Node head = new Node(3);        head.next = new Node(5);        head.next.next = new Node(8);
        Node node = new Node(1);        node.next = head;        head = node;
        int sum = 0;        Node current = head;        while (current != null) {            System.out.print(current.value + " -> ");            sum += current.value;            current = current.next;        }        System.out.println("null");        System.out.println("Sum: " + sum);    }}

Common mistakes

  • Walking past null

    The last node has next == null. If you read current.next.value in the loop or touch head.next on an empty list, you get a NullPointerException. Check whether head == null first, and pick the right condition: current != null to walk through all nodes, current.next != null to stop at the last one.

  • Losing a reference

    A node that nothing points to any more is gone for good. That's why the order matters when relinking:

    current.next = previous;      // the rest of the list is lostNode next = current.next;     // too late

    Store current.next before you overwrite it. And when a queue with nodes becomes empty, also set tail to null, otherwise the next enqueue attaches to an old node.

  • Off-by-one when wrapping around

    In a ring buffer, the last valid index is data.length - 1. A hand-made wrap-around with if easily goes wrong:

    if (tail == data.length - 1) {    tail = 0;}tail++;   // after the wrap-around, tail becomes 1

    Index 0 is skipped, and the queue overwrites slots that are in use. The modulo form tail = (tail + 1) % data.length is safe.