Foundation · Module 10 · Lesson 2

Doubly & circular lists

Bidirectional and wrap-around links.

15–25 min readLesson 54 of 123

Lesson 54 of 123

1d streak

Bidirectional Pointers & O(1) Self-Contained Deletion

A Doubly Linked List (DLL) augments each node with two pointer references: next addressing the successor and prev addressing the predecessor. This bidirectional symmetry eliminates the primary vulnerability of singly linked lists: the inability to inspect or update predecessor nodes without initiating a costly O(n) scan from the head.

Doubly Linked List Node Structure
        +-----------------------------------+
        |       DOUBLY LINKED LIST NODE     |
        |  [ prev ptr | data | next ptr ]   |
        +-----------------------------------+

     +<=================>+<=================>+
     |                   |                   |
+----+----+         +----+----+         +----+----+
|   10    | <=====> |   20    | <=====> |   30    |
+----+----+         +----+----+         +----+----+
     |                   |                   |
null <                   >                   > null

O(1) Self-Unlink of Node 20:
node20.prev.next = node20.next;   // 10 points directly to 30
node20.next.prev = node20.prev;   // 30 points directly back to 10

Architectural Boundary · Do Not Confuse

Concept A

Singly Linked Deletion: O(n) Predecessor Search

Concept B

Doubly Linked Deletion: O(1) Direct Self-Unlink

Given only a direct reference targetNode, a singly linked list must traverse from head to find prev such that prev.next == targetNode. In a doubly linked list, targetNode unlinks itself in O(1) time via targetNode.prev.next = targetNode.next and targetNode.next.prev = targetNode.prev.

The Double Sentinel Architecture

Production doubly linked lists avoid null checks entirely by establishing two permanent sentinel nodes: dummyHead and dummyTail. At initialization: dummyHead.next = dummyTail; dummyTail.prev = dummyHead;. Every active node resides between these sentinels. Inserting at head, inserting at tail, or unlinking any node requires zero conditional branches.
DoublyLinkedListCore.java
// Unconditional O(1) Node Unlinking with Sentinels
public class DoubleSentinelList {
    static class DNode {
        int key, val;
        DNode prev, next;
        DNode(int k, int v) { key = k; val = v; }
    }

    private final DNode head = new DNode(0, 0);
    private final DNode tail = new DNode(0, 0);

    public DoubleSentinelList() {
        head.next = tail;
        tail.prev = head;
    }

    // O(1) removal of any node given only its direct reference
    public void unlink(DNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
        node.prev = null;
        node.next = null;
    }

    // O(1) insertion right before the tail sentinel (Most Recently Used)
    public void addLast(DNode node) {
        node.prev = tail.prev;
        node.next = tail;
        tail.prev.next = node;
        tail.prev = node;
    }
}

Active Recall Checkpoint

What happens if you fail to nullify node.prev and node.next after unlinking a node in a garbage-collected language like Java?

Show answer & explanation

While mark-and-sweep GC can still reclaim isolated cyclic islands, lingering references inside live data structures can prevent memory reclamation (loitering) or lead to illegal state corruption if the unlinked node is accidentally re-used.

LRU Cache Under the Hood: HashMap + Doubly Linked List

The Least Recently Used (LRU) Cache requires both get(key) and put(key, value) to execute in strictly O(1) worst-case time complexity, while maintaining strict recency ordering so that when capacity is exceeded, the least recently accessed item is evicted in O(1).

LRU Cache Architecture: HashMap + Doubly Linked List
Most Recently Used (MRU)               Least Recently Used (LRU)
             |                                              |
             v                                              v
dummyHead <=====> [ Key: "A" | Val: 1 ] <=====> [ Key: "B" | Val: 2 ] <=====> dummyTail
   ^                       ^                               ^                       ^
   |                       |                               |                       |
   +-----------------------+-------------------------------+-----------------------+
                           |                               |
                 HashMap["A"] -> Node A          HashMap["B"] -> Node B

Get("B"): Unlink Node B -> Re-insert at dummyHead.next (Promote to MRU in O(1))!
Put("C"): If full, evict dummyTail.prev (Remove LRU in O(1))!
Data Structure Candidateget(key) LookupUpdate Recency OrderEvict Least RecentVerdict
HashMap aloneO(1)O(n) scan to sort timestampsO(n) scan for minimum timestampFails recency requirements
Singly Linked List aloneO(n) sequential scanO(n) to find predecessor for spliceO(1) pop headFails O(1) lookup
Array / ArrayListO(1) with index mapO(n) shift on middle removalO(n) shift on index 0 removalFails O(1) recency update
HashMap + Doubly Linked ListO(1) via map pointerO(1) unlink + addLastO(1) unlink head.nextOptimal: all operations O(1)
  1. 01Lookup: HashMap maps key -> DNode. In O(1), locate node without traversing the list.
  2. 02Recency Promotion: Whenever a node is read or updated, call unlink(node) followed by addLast(node) to shift it to the MRU position (just before tail sentinel).
  3. 03Capacity Eviction: If map.size() > capacity, target the LRU node at head.next. Unlink head.next and remove its key from the HashMap in O(1).
LRUCache.java
import java.util.HashMap;
import java.util.Map;

public class LRUCache {
    private static class Node {
        int key, value;
        Node prev, next;
        Node(int k, int v) { key = k; value = v; }
    }

    private final int capacity;
    private final Map<Integer, Node> map;
    private final Node head, tail;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<>();
        this.head = new Node(-1, -1);
        this.tail = new Node(-1, -1);
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        moveToTail(node); // Promoted to MRU
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToTail(node);
            return;
        }
        if (map.size() >= capacity) {
            // Evict least recently used (first after head sentinel)
            Node lru = head.next;
            removeNode(lru);
            map.remove(lru.key);
        }
        Node fresh = new Node(key, value);
        map.put(key, fresh);
        addToTail(fresh);
    }

    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void addToTail(Node node) {
        node.prev = tail.prev;
        node.next = tail;
        tail.prev.next = node;
        tail.prev = node;
    }

    private void moveToTail(Node node) {
        removeNode(node);
        addToTail(node);
    }
}

Use it when

Implement HashMap + Doubly Linked List when building in-memory cache layers (LRU/LFU), kernel page replacement buffers, or sliding window recency trackers.

Reconsider when

Use standard LinkedHashMap in production Java when custom concurrency or low-level memory layout controls are not mandated: new LinkedHashMap<>(cap, 0.75f, true) with removeEldestEntry override.

Circular Linked Lists: Ring Topologies & Boundary Traversal

In a circular linked list, the terminal node’s next pointer does not terminate at null; instead, it wraps around to reference the head node. In a circular doubly linked list, head.prev simultaneously points back to tail, forming a seamless, continuous ring topology.

Infinite Loop Boundary Hazard

Standard linear linked list traversal guards (while (curr != null)) will spin forever in an infinite loop on a circular list. Termination conditions must compare node identity against the origin reference (curr != head using a do-while loop) or decrement an explicit size counter.

Architectural Boundary · Do Not Confuse

Concept A

Linear List Termination

Concept B

Circular List Termination

Linear lists terminate naturally when curr == null. Circular lists never encounter null; traversal loops must check do { ... curr = curr.next; } while (curr != head); or track visited references.

JosephusSimulation.java
// Josephus Circle Problem via Circular Singly Linked List
public class JosephusCircle {
    static class Node {
        int id;
        Node next;
        Node(int id) { this.id = id; }
    }

    public static int findSurvivor(int n, int k) {
        if (n == 1) return 1;

        // Build circular linked list: 1 -> 2 -> ... -> n -> 1
        Node head = new Node(1);
        Node prev = head;
        for (int i = 2; i <= n; i++) {
            prev.next = new Node(i);
            prev = prev.next;
        }
        prev.next = head; // Close the circular ring

        Node curr = head;
        // Eliminate until 1 node remains (points to itself)
        while (curr.next != curr) {
            // Count k - 1 steps to locate predecessor of target node
            for (int count = 1; count < k - 1; count++) {
                curr = curr.next;
            }
            // Eliminate k-th node
            curr.next = curr.next.next;
            curr = curr.next; // Advance to next round
        }

        return curr.id;
    }
}

Active Recall Checkpoint

How can you split a circular linked list with N nodes into two equal circular lists in O(n) time and O(1) space?

Show answer & explanation

Use fast and slow pointers. Advance slow by 1 and fast by 2 until fast.next == head or fast.next.next == head. slow marks the end of the first half, and slow.next is the head of the second half. Rewire fast (or fast.next) to point back to slow.next, and rewire slow.next to point back to head.

Cost Model

Complexity & Trade-offs

Traversal / known-node update
O(n) / O(1)
O(1)
The bound follows from the number of states visited and the work performed per transition.

When to use it

  • Frequent local insertions, deletions, chain rewiring, and cycle analysis.
  • Problems where contiguous storage or random access is unnecessary.
  • In-place sequence transformations.

Common mistakes

  • Losing the unread suffix by overwriting next too early.
  • Failing to update head or tail after a boundary mutation.
  • Dereferencing null or looping forever on a cycle.

Practice prompts

  • Reproduce the worked Doubly & circular lists example without looking at the trace.
  • Change one input condition and explain whether the Doubly & circular lists invariant still holds.
  • Implement the approach, derive its cost, and compare it with a straightforward baseline.

Mastery check

Can you derive it instead of memorizing it?

State the invariant before looking at code.
Explain why every transition is safe.
Construct a worst-case input.
Name one alternative and its trade-off.