SEN2211 · Week 3 · Doubly Linked Lists, Ordered Linked Lists

Doubly linked, sorted and circular lists

The doubly linked list with a prev reference, the ordered list that keeps its elements sorted, and the circular list whose end is linked back to the start. The exam asks for reference update traces and the insertSorted method.

doubly linked listprev referencetailordered linked listsorted insertcircular linked listdo-while traversalJosephus problemsentinel

Last week we saw two weak points of the singly linked list: deleting from the end is O(n), and deleting a node requires searching for the one before it. This week there are three variations. The doubly linked list adds a prev reference to each node and can go backward. The ordered list always keeps its elements from smallest to largest. The circular list links the last node back to the start. Exam questions are again tracing and method writing; this time there are more references to update, so there is more room for mistakes.

Golden rules:

  1. In a doubly linked list every link is two-sided. If you did a.next = b, you must also do b.prev = a.
  2. head and tail are candidates in every operation. Inserting into an empty list and deleting the last element change both.
  3. Sorted insertion = find the right place + a normal insertion.
  4. There is no null in a circular list. End the loop with “have I come back to the start”.

The doubly linked list

class DNode {
    int data;
    DNode prev, next;
    DNode(int d) { data = d; }
}

class DoublyLinkedList {
    DNode head, tail;
    int size;
}
head                               tail
 |                                  |
 v                                  v
[null|5|*] <-> [*|10|*] <-> [*|20|null]

Each node knows both directions. The cost: one extra reference of memory per node and twice as many reference updates in every operation.

Insertion

void addFirst(int x) {
    DNode n = new DNode(x);
    if (head == null) { head = tail = n; }
    else { n.next = head; head.prev = n; head = n; }
    size++;
}

void addLast(int x) {
    DNode n = new DNode(x);
    if (tail == null) { head = tail = n; }
    else { n.prev = tail; tail.next = n; tail = n; }
    size++;
}

void insertAfter(DNode p, int x) {
    if (p == tail) { addLast(x); return; }
    DNode n = new DNode(x);
    n.prev = p;          // 1
    n.next = p.next;     // 2
    p.next.prev = n;     // 3  (the right neighbour points back to n)
    p.next = n;          // 4  (last: we were still using p.next)
    size++;
}

Four references change when inserting in the middle. The order of 3 and 4 matters: if you do p.next = n first, p.next.prev now becomes the prev of n itself, and the right neighbour is not updated.

Deletion

If you already hold the node to be deleted, you don’t need to search for the one before it. This is the biggest advantage of a doubly linked list.

void removeNode(DNode p) {
    if (p.prev == null) head = p.next; else p.prev.next = p.next;
    if (p.next == null) tail = p.prev; else p.next.prev = p.prev;
    size--;
}

int removeLast() {
    if (tail == null) throw new RuntimeException("empty list");
    int x = tail.data;
    tail = tail.prev;
    if (tail == null) head = null;
    else tail.next = null;
    size--;
    return x;
}

removeLast is now O(1). It was O(n) in a singly linked list.

Worked example

d.addLast(10); d.addLast(20); d.addLast(40); d.addFirst(5);
d.insertAfter(d.find(20), 30);
d.printForward(); d.printBackward();
d.removeNode(d.find(20));
System.out.println("removeLast=" + d.removeLast());
d.printForward(); d.printBackward();
5 10 20 30 40 
40 30 20 10 5 
removeLast=40
5 10 30 
30 10 5 

TIP · Print in both directions. The best way to test a doubly linked list method is to print both forward and backward. If it looks right forward but comes out different backward, you forgot a prev. On the exam, check the trace in both directions too.

The ordered linked list

The elements are always kept in ascending order. The user doesn’t say where to insert; the list finds the right place itself.

void insertSorted(int x) {
    Node n = new Node(x);
    if (head == null || x < head.data) {   // empty or the smallest → front
        n.next = head;
        head = n;
        return;
    }
    Node p = head;
    while (p.next != null && p.next.data <= x)
        p = p.next;                         // stop just before the first node greater than x
    n.next = p.next;
    p.next = n;
}

Let’s insert 5, 2, 8, 2, 6 in this order and print after each step:

5 
2 5 
2 5 8 
2 2 5 8 
2 2 5 6 8 

With <= an equal value goes after the existing ones; if you had written < it would go before them. Both give a sorted list, but it makes a difference if the question says “equal values go after”.

Searching in a sorted list

Since the list is sorted, you can stop the moment you pass the value you are looking for:

boolean contains(int x) {
    for (Node p = head; p != null; p = p.next) {
        if (p.data == x) return true;
        if (p.data > x) return false;   // early exit
    }
    return false;
}

When searching for 7 in the list 2 2 5 6 8, 5 nodes are looked at (it stops at 8), and when searching for 1, a single node is looked at (2 > 1). The average case improves but the worst case is still O(n). You can’t do binary search on a linked list, because you can’t jump to the middle element in O(1).

OperationUnsorted listSorted list
InsertionO(1) at the frontO(n) finding the place
Search (not found)O(n), always to the endO(n), but can stop early
Smallest elementO(n)O(1), head

The circular linked list

The next of the last node points to the first node instead of null. A practical trick: keep only the tail. The first node is always tail.next. That way you have O(1) access to both the front and the end.

          +-----------------------------+
          v                             |
 tail.next=[0] -> [1] -> [2] -> [3]=tail-+
void addFirst(int x) {
    Node n = new Node(x);
    if (tail == null) { tail = n; n.next = n; }   // a single node points to itself
    else { n.next = tail.next; tail.next = n; }
}

void addLast(int x) { addFirst(x); tail = tail.next; }   // add at the front, move the tail

int removeFirst() {
    Node first = tail.next;
    if (first == tail) tail = null;    // there was a single element
    else tail.next = first.next;
    return first.data;
}

void rotate() { if (tail != null) tail = tail.next; }   // the first element moves to the end

void print() {
    Node p = tail.next;
    do {
        System.out.print(p.data + " ");
        p = p.next;
    } while (p != tail.next);
    System.out.println();
}

Note the addLast trick: adding at the front and moving the tail one step forward makes the new node the last. A worked example:

c.addLast(1); c.addLast(2); c.addLast(3); c.addFirst(0);
c.print();
c.rotate(); c.print();
System.out.println("removeFirst=" + c.removeFirst());
c.print();
0 1 2 3 
1 2 3 0 
removeFirst=1
2 3 0 

PITFALL · while (p != null) is an infinite loop in a circular list. No node’s next is null. Use do-while and stop when you come back to the start. If you start with while (p != tail.next) the loop never runs, because p already starts at tail.next.

Typical uses of a circular list: jobs that take turns (a round-robin scheduler), turns in a multiplayer game, and the Josephus problem (eliminating every k-th person in a circle).

Common mistakes

WrongCorrect
Updating only next in a doubly linked listSet up every link in both directions: next and prev
Setting only head when inserting into an empty listhead = tail = n
Leaving the tail alone when deleting the last elementIf the list became empty, head and tail are both null
p.prev.next = p.next works on the head tooIf p is the head, p.prev is null: check first
Forgetting the insert-at-front case in insertSortedIf x < head.data, it is the new head
Binary search on a sorted linked listNo O(1) access to the middle, the search is O(n)
while (p != null) in a circular listdo { } while (p != tail.next)
n.next = null in a single-element circular listA single node points to itself: n.next = n

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

Starting from an empty doubly linked list, what do printForward() and printBackward() show?

list.addFirst(2);
list.addLast(4);
list.addFirst(1);
list.addLast(6);
list.removeFirst();
list.addFirst(8);
Show answer
8 2 4 6 / 6 4 2 8

[2] → [2,4] → [1,2,4] → [1,2,4,6] → removeFirst: [2,4,6] → addFirst(8): [8,2,4,6]. Backward, from the tail via prev: 6 4 2 8.

Question 2easy

When a new node is inserted between two existing nodes of a doubly linked list, how many references must be set or changed?

Show answer
4

The new node's prev and next (2 of them), the next of the left node and the prev of the right node (2 of them). Total 4. In a singly linked list this number is 2.

Question 3medium

The doubly linked list is 1 2 3 4. What is printed?

DNode p = head.next;
System.out.println(p.prev.data + p.next.next.data);
System.out.println(tail.prev.prev.data);
p.next.data = p.prev.data * 10;
printForward();
Show answer
5 / 2 / 1 2 10 4

p = 2. p.prev = 1, p.next.next = 4; int addition: 5. tail.prev.prev: 4 → 3 → 2. Finally the data of p.next (3) becomes 1 · 10 = 10: 1 2 10 4.

Question 4medium

Write int removeLast() for a doubly linked list with head and tail. It must run in O(1) and handle a one-element list.

Show answer
tail = tail.prev; then tail.next = null (or head = null if the list became empty).

In a doubly linked list, tail.prev gives the node before the last, so there is no need to walk.

int removeLast() {
    if (tail == null) throw new RuntimeException("empty list");
    int x = tail.data;
    tail = tail.prev;
    if (tail == null)      // there was a single element
        head = null;
    else
        tail.next = null;  // the new last node
    size--;
    return x;
}
For the list 7 8 9: two calls return 9 and 8, and the list stays 7. The third call returns 7, and both head and tail become null.

Question 5hard

Write void insertSorted(int x) for a singly linked list kept in ascending order. Equal values go after existing ones.

Show answer
Insert at head if empty or x < head.data; otherwise advance while p.next.data <= x, then link.

Inserting at the front is a separate case: if the list is empty or x is the smallest. Otherwise stop just before the first node greater than x.

void insertSorted(int x) {
    Node n = new Node(x);
    if (head == null || x < head.data) {
        n.next = head;
        head = n;
        return;
    }
    Node p = head;
    while (p.next != null && p.next.data <= x)
        p = p.next;
    n.next = p.next;
    p.next = n;
}
Because <= is used, equal values come after the existing ones. If 5, 2, 8, 2, 6 are inserted in order, the list becomes 2 2 5 6 8.

Question 6easy

Values are inserted with insertSorted in this order: 9, 1, 8, 3, 5. What does the list print?

Show answer
1 3 5 8 9

[9] → 1 goes to the front: [1,9] → 8 goes in the middle: [1,8,9] → 3: [1,3,8,9] → 5: [1,3,5,8,9]. Whatever the insertion order, the list stays sorted.

Question 7medium

The sorted list is 1 3 5 8 9. The contains method stops as soon as it sees a value greater than the target. How many nodes are examined when searching for 7?

Show answer
4

1, 3, 5 are smaller, continue. 8 > 7, so 7 can't be further ahead, return false. 1, 3, 5, 8: four nodes. In an unsorted list all 5 nodes would have to be looked at. The worst case is still O(n).

Question 8medium

A circular singly linked list keeps only tail (tail.next is the first node). Starting empty, what is printed?

c.addLast(4);
c.addFirst(7);
c.addLast(9);
c.rotate();      // tail = tail.next
c.addFirst(1);
c.removeFirst();
c.rotate();
c.print();       // from tail.next around to tail
Show answer
9 7 4

[4] → [7,4] → [7,4,9] (tail 9). rotate: tail becomes 7, reading from the start gives 4 9 7. addFirst(1): [1,4,9,7]. removeFirst removes 1: [4,9,7]. rotate: tail 4, reading gives 9 7 4.

Question 9easy

Why does this loop never end on a circular linked list, and how should it be written?

Node p = tail.next;
while (p != null) {
    System.out.print(p.data + " ");
    p = p.next;
}
Show answer
No node's next is null; use do-while until p returns to tail.next.

In a circular list the last node points to the first node, and no next is null. The stopping condition is coming back to the start: Node p = tail.next; do { print; p = p.next; } while (p != tail.next); The do-while prints once even in a single-element list.

Question 10hard

People 1..6 sit in a circle (circular list, tail = 6). Repeat 5 times: rotate() then removeFirst() (remove every second person). In what order are people removed, and who survives?

Show answer
2 4 6 3 1, survivor 5

rotate moves the tail to 1, and the first node, 2, is removed. Then the tail goes to 3, and 4 is removed. The tail goes to 5, and 6 is removed. 1 3 5 remain: the tail goes to 1, and 3 is removed. 5 1 remain: the tail goes to 5, and 1 is removed. The survivor is 5. This is the Josephus problem, the classic application of a circular list.

Question 11medium

A student removes node p from a doubly linked list with the two lines below. On which nodes does this crash, and how is it fixed?

p.prev.next = p.next;
p.next.prev = p.prev;
Show answer
Crashes when p is the head or the tail (NullPointerException); check p.prev == null / p.next == null and update head / tail.

If p is the head, p.prev is null; if it is the tail, p.next is null; in both cases a NullPointerException. The fix:

if (p.prev == null) head = p.next;
else p.prev.next = p.next;
if (p.next == null) tail = p.prev;
else p.next.prev = p.prev;

Question 12easy

Name one operation that is O(n) in a singly linked list with head and tail, but O(1) in a doubly linked list with head and tail.

Show answer
removeLast

In a singly linked list, finding the node before the last requires walking from the start: O(n). In a doubly linked list tail.prev is ready: O(1). Likewise, deleting a node you already hold a reference to is O(1) in a doubly linked list.

Verification: All traces and methods were run on the JDK in verify/SEN2211/W03.java with the DoublyLinkedList, SortedList and CircularList classes (java W03.java). Comparison counts were taken from the counter in the program.

Review card

DNode: data, prev, next list: head + tail addFirst: n.next = head; head.prev = n; head = n; (if empty, head = tail = n) addLast: n.prev = tail; tail.next = n; tail = n; Inserting in the middle (after p): 4 references: n.prev, n.next, p.next.prev, p.next Deleting a node: p.prev.next = p.next; p.next.prev = p.prev; special case if head/tail removeLast is O(1) in a doubly linked list (tail.prev), O(n) in a singly linked list Print backward: for (p = tail; p != null; p = p.prev) Sorted list insertSorted: empty or x < head.data → front; otherwise while (p.next != null && p.next.data <= x) Early exit in sorted search: stop if p.data > x (worst case still O(n)) Circular: last.next = first; NO null; keep only tail, first = tail.next Circular traversal: do { ... p = p.next; } while (p != tail.next); rotate: tail = tail.next Josephus: repeat rotate + removeFirst