Foundation · Module 10 · Lesson 2
Doubly & circular lists
Bidirectional and wrap-around links.
Lesson 54 of 123
1d streakBidirectional 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 |
| [ 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 10Architectural Boundary · Do Not Confuse
Singly Linked Deletion: O(n) Predecessor Search
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
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.// 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).
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 Candidate | get(key) Lookup | Update Recency Order | Evict Least Recent | Verdict |
|---|---|---|---|---|
| HashMap alone | O(1) | O(n) scan to sort timestamps | O(n) scan for minimum timestamp | Fails recency requirements |
| Singly Linked List alone | O(n) sequential scan | O(n) to find predecessor for splice | O(1) pop head | Fails O(1) lookup |
| Array / ArrayList | O(1) with index map | O(n) shift on middle removal | O(n) shift on index 0 removal | Fails O(1) recency update |
| HashMap + Doubly Linked List | O(1) via map pointer | O(1) unlink + addLast | O(1) unlink head.next | Optimal: all operations O(1) |
- 01Lookup: HashMap maps key -> DNode. In O(1), locate node without traversing the list.
- 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).
- 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).
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
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
Linear List Termination
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.
// 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
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