Doubly Linked List Explained — Structure, Operations & Real-World Use Cases
- The prev pointer is the entire value proposition of a DLL. It transforms deletion from O(n) to O(1) by eliminating the need to traverse for the predecessor.
- Every insert must set both prev and next for the new node. Every delete must update up to four pointers: prev.next, next.prev, head, and tail. Missing any single update creates a one-directional break.
- LRU cache = hash map + DLL. Hash map provides O(1) key lookup. DLL provides O(1) access-order maintenance and eviction. Neither alone is sufficient.
- Node = data + next pointer + prev pointer. Three fields instead of two.
- Delete known node: O(1). No traversal needed — node already knows its predecessor.
- Insert at head/tail: O(1) with both head and tail pointers.
- Space overhead: one extra pointer per node (8 bytes on 64-bit systems).
- O(1) deletion vs 8 bytes per node overhead. Worth it when deletion frequency is high.
- LRU cache = DLL + hash map. O(1) get and put via node lookup + pointer rewiring.
- Java's LinkedHashMap uses DLL internally for insertion-order iteration.
- Forgetting to update prev pointer on insert/delete. Creates one-directional breaks that corrupt traversal in one direction but not the other.
- DLL pointer updates are non-atomic. Two-thread insert at head creates broken chains. Always synchronize.
Backward traversal stops early (hits null before reaching head).
Traverse backward from tail: System.out.println("node=" + curr.val + " prev=" + (curr.prev != null ? curr.prev.val : "NULL"))If any prev is null in the middle of the list, the insert method is missing newNode.prev = prevNode.Memory grows despite eviction running.
Count forward: int fwd = 0; for (Node n = head; n != null; n = n.next) fwd++;Count backward: int bwd = 0; for (Node n = tail; n != null; n = n.prev) bwd++;. If fwd != bwd, the chain is broken.NullPointerException on delete.
Log node values: System.out.println("deleting=" + node.val + " prev=" + (node.prev != null ? node.prev.val : "HEAD") + " next=" + (node.next != null ? node.next.val : "TAIL"))If prev is null, update head = node.next. If next is null, update tail = node.prev.LRU cache eviction skips nodes — cache size exceeds capacity.
int count = 0; for (Node n = tail; n != null; n = n.prev) count++; System.out.println("backward count=" + count)Compare with forward count. If backward < forward, a prev pointer is null in the middle.Production Incident
Production Debug GuideSymptom-first investigation path for DLL pointer bugs.
get() returns stale data or misses valid entries.→Check if the get() method moves the accessed node to the head. If the move-to-head logic has a pointer bug, the node stays in its old position and gets evicted prematurely. Verify the node is correctly unlinked from its old position and linked at the head.A doubly linked list extends the singly linked list by adding a prev pointer to each node. This single addition transforms deletion from O(n) to O(1) for any known node — no traversal to find the predecessor. It also enables bidirectional traversal, which singly linked lists cannot do.
The structure powers real systems: LRU caches (DLL + hash map for O(1) get/put), browser back/forward history, Java's LinkedHashMap, and deque implementations. Every system that needs O(1) insertion and deletion at both ends, or O(1) deletion of a known node, uses a DLL under the hood.
The common misconception is that DLLs are just singly linked lists with an extra pointer. The prev pointer changes the complexity class of deletion — from O(n) to O(1). This is not a minor improvement. In an LRU cache with 1 million entries, O(n) deletion is 1 million operations. O(1) deletion is one pointer rewiring.
What is Doubly Linked List? — Plain English
A Doubly Linked List (DLL) is a linked list where each node has three fields: a value, a pointer to the next node, and a pointer to the previous node. Unlike a singly linked list, you can traverse in both directions.
The real power of a DLL: deletion of a known node is O(1) without traversal. In a singly linked list, to delete node X you need its predecessor — which requires O(n) traversal unless you have a pointer to it. With a DLL, every node already knows its predecessor via its 'prev' pointer, so deletion is always O(1).
DLLs are the backbone of LRU caches, browser history (back/forward), and deque (double-ended queue) implementations.
package io.thecodeforge.collections; /** * Node for a doubly linked list. * Each node holds a value, a reference to the next node, * and a reference to the previous node. * * The prev pointer is what distinguishes a DLL from a singly linked list. * It enables O(1) deletion of any known node and bidirectional traversal. */ public class DoublyLinkedListNode<T> { T value; DoublyLinkedListNode<T> next; DoublyLinkedListNode<T> prev; public DoublyLinkedListNode(T value) { this.value = value; this.next = null; this.prev = null; } @Override public String toString() { String prevVal = (prev != null) ? String.valueOf(prev.value) : "HEAD"; String nextVal = (next != null) ? String.valueOf(next.value) : "TAIL"; return "Node{value=" + value + ", prev=" + prevVal + ", next=" + nextVal + "}"; } }
- Singly linked list delete: O(n) to find predecessor, O(1) to rewire. Total: O(n).
- Doubly linked list delete: O(0) to find predecessor (node.prev), O(1) to rewire. Total: O(1).
- The prev pointer is 8 bytes per node. The O(n) to O(1) improvement is worth it for frequent deletion.
- LRU cache: DLL enables O(1) eviction. Without prev pointer, eviction would be O(n).
- This is not a minor optimization. It is the reason DLLs exist.
How a Doubly Linked List Works — Java Implementation with Invariants
A Doubly Linked List (DLL) is a linked list where each node has three fields: a value, a pointer to the next node, and a pointer to the previous node. Unlike a singly linked list, you can traverse in both directions.
The real power of a DLL: deletion of a known node is O(1) without traversal. In a singly linked list, to delete node X you need its predecessor — which requires O(n) traversal unless you have a pointer to it. With a DLL, every node already knows its predecessor via its 'prev' pointer, so deletion is always O(1).
DLLs are the backbone of LRU caches, browser history (back/forward), and deque (double-ended queue) implementations.
package io.thecodeforge.collections; /** * Doubly linked list implementation with invariant checks. * Tracks both head and tail for O(1) operations at both ends. * * Invariants maintained after every operation: * - head.prev == null * - tail.next == null * - Forward traversal count == size * - Backward traversal count == size */ public class DoublyLinkedList<T> { private DoublyLinkedListNode<T> head; private DoublyLinkedListNode<T> tail; private int size; public DoublyLinkedList() { this.head = null; this.tail = null; this.size = 0; } /** * Insert at head. O(1). * Critical: newNode.prev must be null for the new head. * Forgetting this breaks backward traversal from tail. */ public void insertAtHead(T value) { DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(value); newNode.next = head; newNode.prev = null; // CRITICAL: new head has no predecessor if (head != null) { head.prev = newNode; // old head's prev now points to new node } head = newNode; if (tail == null) { tail = newNode; // list was empty, new node is both head and tail } size++; assertInvariants(); } /** * Insert at tail. O(1). * Critical: newNode.next must be null for the new tail. */ public void insertAtTail(T value) { DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(value); newNode.prev = tail; newNode.next = null; // CRITICAL: new tail has no successor if (tail != null) { tail.next = newNode; // old tail's next now points to new node } tail = newNode; if (head == null) { head = newNode; // list was empty } size++; assertInvariants(); } /** * Delete a known node. O(1). * Must update up to four pointers. * Missing any one creates a dangling reference. */ public void delete(DoublyLinkedListNode<T> node) { if (node == null) { throw new IllegalArgumentException("Cannot delete null node"); } // Pointer 1: bypass from left if (node.prev != null) { node.prev.next = node.next; } else { head = node.next; // deleting head } // Pointer 2: bypass from right if (node.next != null) { node.next.prev = node.prev; } else { tail = node.prev; // deleting tail } // Sever dangling references to allow GC node.prev = null; node.next = null; size--; assertInvariants(); } /** * Move an existing node to head. O(1). * Used by LRU cache on access. */ public void moveToHead(DoublyLinkedListNode<T> node) { if (node == head) { return; // already at head } // Unlink from current position if (node.prev != null) { node.prev.next = node.next; } if (node.next != null) { node.next.prev = node.prev; } else { tail = node.prev; // node was tail } // Link at head node.next = head; node.prev = null; if (head != null) { head.prev = node; } head = node; assertInvariants(); } /** * Remove and return the tail node's value. O(1). * Used by LRU cache for eviction. */ public T removeTail() { if (tail == null) { return null; } T value = tail.value; DoublyLinkedListNode<T> oldTail = tail; tail = tail.prev; if (tail != null) { tail.next = null; } else { head = null; // list is now empty } oldTail.prev = null; oldTail.next = null; size--; assertInvariants(); return value; } /** * Post-operation invariant checks. * Catches pointer bugs immediately instead of at next traversal. */ private void assertInvariants() { if (head == null && tail == null && size == 0) { return; // empty list is valid } if (head == null || tail == null) { throw new IllegalStateException( "Invariant violation: head and tail must both be null or both non-null. " + "head=" + head + " tail=" + tail + " size=" + size ); } if (head.prev != null) { throw new IllegalStateException( "Invariant violation: head.prev must be null. Found: " + head.prev.value ); } if (tail.next != null) { throw new IllegalStateException( "Invariant violation: tail.next must be null. Found: " + tail.next.value ); } // Verify forward chain length matches size int forwardCount = 0; DoublyLinkedListNode<T> curr = head; while (curr != null) { forwardCount++; if (curr.prev != null && curr.prev.next != curr) { throw new IllegalStateException( "Invariant violation: prev.next broken at node " + curr.value ); } curr = curr.next; } if (forwardCount != size) { throw new IllegalStateException( "Invariant violation: forward chain length " + forwardCount + " != size " + size ); } } public DoublyLinkedListNode<T> getHead() { return head; } public DoublyLinkedListNode<T> getTail() { return tail; } public int getSize() { return size; } public boolean isEmpty() { return size == 0; } }
- prevNode.next = nextNode (bypass the deleted node from the left).
- nextNode.prev = prevNode (bypass the deleted node from the right).
- If deleting head: head = nextNode (or null if list becomes empty).
- If deleting tail: tail = prevNode (or null if list becomes empty).
- All four checks are mandatory. Missing any one creates a bug.
get() path in LRU caches.LRU Cache: The Killer App for Doubly Linked Lists
An LRU (Least Recently Used) cache is a hash map + doubly linked list working together. The hash map provides O(1) key-to-node lookup. The DLL provides O(1) access-order maintenance and O(1) eviction.
On get(key): 1. Look up node in hash map — O(1). 2. Move node to head of DLL — O(1) pointer rewiring. 3. Return value.
On put(key, value): 1. If key exists: update value, move to head — O(1). 2. If key does not exist: create node, insert at head, add to hash map — O(1). 3. If capacity exceeded: remove tail from DLL and hash map — O(1).
Why DLL and not singly linked list: eviction removes the tail node. In a singly linked list, removing the tail requires O(n) traversal to find the second-to-last node. In a DLL, tail.prev is the second-to-last node — O(1).
Java's LinkedHashMap with accessOrder=true is a built-in LRU cache implementation using this exact pattern.
package io.thecodeforge.cache; import java.util.HashMap; import java.util.Map; /** * LRU Cache backed by a hash map + doubly linked list. * O(1) get and put. O(1) eviction. * * The DLL maintains access order: most recently used at head, * least recently used at tail. The hash map provides O(1) key-to-node * lookup so we can move a node to head without traversing the DLL. */ public class LRUCache<K, V> { private final int capacity; private final Map<K, Node<K, V>> cache; private Node<K, V> head; private Node<K, V> tail; private static class Node<K, V> { K key; V value; Node<K, V> prev; Node<K, V> next; Node(K key, V value) { this.key = key; this.value = value; } } public LRUCache(int capacity) { this.capacity = capacity; this.cache = new HashMap<>(capacity); } /** * Get value by key. O(1). * Moves accessed node to head (most recently used). */ public V get(K key) { Node<K, V> node = cache.get(key); if (node == null) { return null; } moveToHead(node); return node.value; } /** * Put key-value pair. O(1). * If key exists: update and move to head. * If new: insert at head. If over capacity: evict tail. */ public void put(K key, V value) { Node<K, V> node = cache.get(key); if (node != null) { node.value = value; moveToHead(node); } else { Node<K, V> newNode = new Node<>(key, value); cache.put(key, newNode); addToHead(newNode); if (cache.size() > capacity) { Node<K, V> evicted = removeTail(); cache.remove(evicted.key); } } } /** * Move node to head. O(1). * Step 1: unlink from current position. * Step 2: link at head. */ private void moveToHead(Node<K, V> node) { removeNode(node); addToHead(node); } /** * Add node right after head sentinel (or as head if no sentinel). * CRITICAL: newNode.prev must be set to null (or head sentinel). */ private void addToHead(Node<K, V> node) { node.prev = null; // CRITICAL: see incident #7293 node.next = head; if (head != null) { head.prev = node; } head = node; if (tail == null) { tail = node; } } /** * Remove node from its current position. O(1). * Updates all four pointers. */ private void removeNode(Node<K, V> node) { if (node.prev != null) { node.prev.next = node.next; } else { head = node.next; // node was head } if (node.next != null) { node.next.prev = node.prev; } else { tail = node.prev; // node was tail } node.prev = null; node.next = null; } /** * Remove and return tail node. O(1). */ private Node<K, V> removeTail() { if (tail == null) { return null; } Node<K, V> oldTail = tail; removeNode(tail); return oldTail; } public int size() { return cache.size(); } }
- Hash map: key -> DLL node. O(1) lookup to find the node for a given key.
- DLL: maintains access order. Head = most recent, tail = least recent.
- get(key): hash map finds node in O(1), DLL moves it to head in O(1). Total: O(1).
- put(key): hash map inserts in O(1), DLL inserts at head in O(1). If over capacity, DLL removes tail in O(1). Total: O(1).
- This is exactly how Java's LinkedHashMap(accessOrder=true) works internally.
put() when capacity was exceeded. CPU usage dropped 7%, and session access latency improved from 12ms to 0.3ms because the hash map lookup replaced a full scan.Thread Safety and Concurrent Doubly Linked Lists
DLL pointer updates are not atomic. Each pointer assignment (node.next = X, node.prev = Y) is a separate memory operation. Two threads operating on the same DLL simultaneously can interleave pointer updates, creating broken chains.
Common concurrency bugs: 1. Two threads insert at head simultaneously: both set newNode.next = head, but only one head.prev = newNode survives. The other's prev pointer is lost. 2. One thread deletes a node while another traverses through it: the traversing thread reads a node that is being unlinked, following a next pointer to a node whose prev no longer points back. 3. One thread moveToHead while another removeTail: both modify the same node's pointers, creating an inconsistent state.
- Coarse-grained: synchronized block or ReentrantLock around all operations. Simple but serializes all access.
- Fine-grained: lock only the nodes being modified and their neighbors. Complex but allows concurrent reads and non-conflicting writes.
- Lock-free: not practical for DLLs. The multi-pointer update requirement makes CAS-based approaches extremely complex.
- Copy-on-write: snapshot the list, modify the snapshot, swap reference. Works for read-heavy workloads.
Java's ConcurrentLinkedDeque uses a lock-free approach for deque operations but it is significantly more complex than a basic DLL.
package io.thecodeforge.collections; import java.util.concurrent.locks.ReentrantLock; /** * Thread-safe doubly linked list using coarse-grained locking. * All operations acquire a single lock. * Simple, correct, but serializes all access. * * For high-concurrency scenarios, consider fine-grained locking * or Java's ConcurrentLinkedDeque. */ public class ConcurrentDoublyLinkedList<T> { private DoublyLinkedListNode<T> head; private DoublyLinkedListNode<T> tail; private int size; private final ReentrantLock lock = new ReentrantLock(); public void insertAtHead(T value) { lock.lock(); try { DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(value); newNode.next = head; newNode.prev = null; if (head != null) { head.prev = newNode; } head = newNode; if (tail == null) { tail = newNode; } size++; } finally { lock.unlock(); } } public void insertAtTail(T value) { lock.lock(); try { DoublyLinkedListNode<T> newNode = new DoublyLinkedListNode<>(value); newNode.prev = tail; newNode.next = null; if (tail != null) { tail.next = newNode; } tail = newNode; if (head == null) { head = newNode; } size++; } finally { lock.unlock(); } } public void delete(DoublyLinkedListNode<T> node) { lock.lock(); try { if (node.prev != null) { node.prev.next = node.next; } else { head = node.next; } if (node.next != null) { node.next.prev = node.prev; } else { tail = node.prev; } node.prev = null; node.next = null; size--; } finally { lock.unlock(); } } /** * Traverse the list with a lock held. * For read-heavy workloads, consider a ReadWriteLock. */ public void traverse(java.util.function.Consumer<T> action) { lock.lock(); try { DoublyLinkedListNode<T> curr = head; while (curr != null) { action.accept(curr.value); curr = curr.next; } } finally { lock.unlock(); } } public int getSize() { lock.lock(); try { return size; } finally { lock.unlock(); } } }
- Singly linked list delete: one pointer change. Can use CAS on prev.next.
- DLL delete: four pointer changes. CAS on each individually does not guarantee atomicity.
- If thread A CAS's prev.next but thread B CAS's next.prev before A completes, the chain is inconsistent.
- Solution: use a lock, or use a lock-free deque implementation (ConcurrentLinkedDeque) which is 500+ lines of complex CAS logic.
- For most applications, coarse-grained locking is correct and fast enough. Do not over-engineer.
Edge Cases and Boundary Conditions in Doubly Linked Lists
DLL bugs almost always occur at the boundaries: empty list, single element, head, tail, and interleaved operations. The happy path (insert into middle of large list, delete from middle) rarely fails. The boundaries fail constantly.
- Insert into empty list: head and tail must both point to the new node.
- Delete the only element: head and tail must both become null.
- Insert at head: new head's prev must be null. Old head's prev must point to new head.
- Delete head: new head's prev must be null. Old head must be severed.
- Delete tail: new tail's next must be null. Old tail must be severed.
- Move to head when node is already head: no-op.
- Move to head when node is tail: tail must update to the new tail (node.prev).
Test every operation against all five boundary conditions. Run invariant checks after every operation to catch boundary bugs immediately.
package io.thecodeforge.collections; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; /** * Boundary condition tests for doubly linked list. * Each test verifies invariants after every operation. */ public class DoublyLinkedListTest { @Test public void testInsertIntoEmptyList() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("first"); assertNotNull(list.getHead()); assertNotNull(list.getTail()); assertSame(list.getHead(), list.getTail()); assertEquals(1, list.getSize()); assertNull(list.getHead().prev); assertNull(list.getTail().next); } @Test public void testDeleteOnlyElement() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("only"); DoublyLinkedListNode<String> node = list.getHead(); list.delete(node); assertNull(list.getHead()); assertNull(list.getTail()); assertEquals(0, list.getSize()); assertNull(node.prev); assertNull(node.next); } @Test public void testInsertAtHeadBoundary() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("second"); list.insertAtHead("first"); assertEquals("first", list.getHead().value); assertNull(list.getHead().prev); assertEquals("second", list.getHead().next.value); } @Test public void testDeleteHead() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("third"); list.insertAtHead("second"); list.insertAtHead("first"); list.delete(list.getHead()); assertEquals("second", list.getHead().value); assertNull(list.getHead().prev); assertEquals(2, list.getSize()); } @Test public void testDeleteTail() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("third"); list.insertAtHead("second"); list.insertAtHead("first"); list.delete(list.getTail()); assertEquals("second", list.getTail().value); assertNull(list.getTail().next); assertEquals(2, list.getSize()); } @Test public void testMoveToHeadWhenAlreadyHead() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("first"); list.insertAtHead("second"); DoublyLinkedListNode<String> head = list.getHead(); list.moveToHead(head); assertSame(head, list.getHead()); assertEquals(2, list.getSize()); } @Test public void testMoveTailToHead() { DoublyLinkedList<String> list = new DoublyLinkedList<>(); list.insertAtHead("first"); list.insertAtHead("second"); list.insertAtHead("third"); DoublyLinkedListNode<String> tail = list.getTail(); list.moveToHead(tail); assertSame(tail, list.getHead()); assertEquals(3, list.getSize()); assertNull(tail.prev); assertEquals("third", tail.next.value); assertNotSame(tail, list.getTail()); } @Test public void testForwardAndBackwardTraversalMatch() { DoublyLinkedList<Integer> list = new DoublyLinkedList<>(); for (int i = 1; i <= 10; i++) { list.insertAtTail(i); } // Forward traversal int forwardCount = 0; DoublyLinkedListNode<Integer> curr = list.getHead(); while (curr != null) { forwardCount++; curr = curr.next; } // Backward traversal int backwardCount = 0; curr = list.getTail(); while (curr != null) { backwardCount++; curr = curr.prev; } assertEquals(forwardCount, backwardCount); assertEquals(list.getSize(), forwardCount); } @Test public void testInterleavedInsertDelete() { DoublyLinkedList<Integer> list = new DoublyLinkedList<>(); list.insertAtHead(1); list.insertAtTail(2); list.insertAtHead(0); list.insertAtTail(3); assertEquals(4, list.getSize()); list.delete(list.getHead()); list.delete(list.getTail()); assertEquals(2, list.getSize()); assertEquals(Integer.valueOf(1), list.getHead().value); assertEquals(Integer.valueOf(2), list.getTail().value); assertNull(list.getHead().prev); assertNull(list.getTail().next); } }
- Empty list insert: new node is both head and tail. Both pointers must be set.
- Single element delete: both head and tail become null. Size becomes 0.
- Head delete: new head.prev must be null. Old head must be severed.
- Tail delete: new tail.next must be null. Old tail must be severed.
- Interleaved operations: each operation must handle the case where it modifies head or tail.
| Feature / Operation | Doubly Linked List | Singly Linked List | Array List (dynamic array) | LinkedBlockingDeque |
|---|---|---|---|---|
| Node structure | data + next + prev (3 fields) | data + next (2 fields) | contiguous array of references | data + next + prev (3 fields) + lock |
| Insert at head | O(1) | O(1) | O(n) — shift all elements | O(1) — with lock |
| Insert at tail | O(1) with tail pointer | O(n) without tail pointer, O(1) with | O(1) amortized — append | O(1) — with lock |
| Delete known node | O(1) — node.prev gives predecessor | O(n) — must traverse to find predecessor | O(n) — shift elements after index | O(1) — with lock |
| Delete head | O(1) | O(1) | O(n) — shift all elements | O(1) — with lock |
| Delete tail | O(1) with tail pointer | O(n) — must find second-to-last | O(1) — just decrement size | O(1) — with lock |
| Access by index | O(n) — must traverse | O(n) — must traverse | O(1) — direct array index | O(n) — must traverse |
| Search by value | O(n) — linear scan | O(n) — linear scan | O(n) — linear scan (unsorted) | O(n) — linear scan |
| Bidirectional traversal | Yes — both directions | No — forward only | Yes — by index | Yes — both directions |
| Memory per element | 24 bytes (data + 2 pointers on 64-bit) | 16 bytes (data + 1 pointer) | 8 bytes (reference) + array overhead | 24 bytes + lock overhead |
| Cache locality | Poor — nodes scattered in heap | Poor — nodes scattered in heap | Excellent — contiguous memory | Poor — nodes scattered in heap |
| Thread safety | Not thread-safe | Not thread-safe | Not thread-safe | Thread-safe — blocking operations |
| Primary use case | LRU cache, browser history, deque | Stack, simple queue | Random access, iteration-heavy | Producer-consumer queue, work stealing |
🎯 Key Takeaways
- The prev pointer is the entire value proposition of a DLL. It transforms deletion from O(n) to O(1) by eliminating the need to traverse for the predecessor.
- Every insert must set both prev and next for the new node. Every delete must update up to four pointers: prev.next, next.prev, head, and tail. Missing any single update creates a one-directional break.
- LRU cache = hash map + DLL. Hash map provides O(1) key lookup. DLL provides O(1) access-order maintenance and eviction. Neither alone is sufficient.
- DLL pointer updates are non-atomic. Multi-threaded access without synchronization corrupts the list structure. Use ReentrantLock for correctness, or replace with ConcurrentSkipListMap for high contention.
- Test every DLL operation against five boundary conditions: empty list, single element, head operations, tail operations, and interleaved operations. Add invariant checks after every operation.
- Choose DLL when you need O(1) deletion of known nodes or bidirectional traversal. Choose array list when you need random access or read-heavy iteration. The 8-byte-per-node overhead and poor cache locality of DLL are significant costs.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain why deletion of a known node is O(1) in a doubly linked list but O(n) in a singly linked list. What role does the prev pointer play?
- QYou are implementing delete(node) for a DLL. How many pointers must you update? Walk through each one and explain what happens if you skip it.
- QAn LRU cache uses a hash map + doubly linked list. Why can't you implement an LRU cache with just a hash map? Or just a DLL?
- QA DLL's backward traversal stops early — hits null before reaching the head. What is the most likely cause, and how would you fix it?
- QTwo threads insert at head simultaneously on an unsynchronized DLL. What specific corruption can occur? Draw the pointer state after both inserts complete.
- QYou need to implement a thread-safe DLL. What are your synchronization options, and what are the trade-offs of each?
- QWalk through the edge case of deleting the only element in a DLL. What must happen to head, tail, and the deleted node's pointers?
- QCompare the memory overhead of a DLL vs an array list for 1 million integer elements. Which uses more memory, and by how much?
- QJava's LinkedHashMap uses a DLL internally. Explain how accessOrder=true makes it an LRU cache, and what happens on
get()andput(). - QYou find a production bug: forward traversal count does not match backward traversal count in a DLL. What does this tell you, and how would you find the break point?
Frequently Asked Questions
What is the main advantage of a doubly linked list over a singly linked list?
The main advantage is O(1) deletion of any known node. In a singly linked list, deleting node X requires finding X's predecessor, which is O(n) traversal. In a DLL, every node has a prev pointer that directly references its predecessor, making deletion O(1). DLL also supports bidirectional traversal, which singly linked lists cannot do.
How many pointers must be updated when deleting a node from a DLL?
Up to four pointers: (1) prevNode.next = nextNode, (2) nextNode.prev = prevNode, (3) head = nextNode if deleting the head, (4) tail = prevNode if deleting the tail. Additionally, the deleted node's prev and next should be set to null to prevent dangling references.
Why does an LRU cache need both a hash map and a doubly linked list?
The hash map provides O(1) key-to-node lookup so you can find any cached item without traversing the list. The DLL provides O(1) access-order maintenance (move to head on access) and O(1) eviction (remove tail). Without the hash map, lookup is O(n). Without the DLL, eviction is O(n). Together they give O(1) for all operations.
Is a doubly linked list thread-safe?
No. DLL pointer updates are not atomic — each pointer assignment (node.next = X, node.prev = Y) is a separate memory operation. Two threads operating on the same DLL can interleave pointer updates and create broken chains. For thread safety, use synchronized blocks, ReentrantLock, or replace with ConcurrentLinkedDeque/ConcurrentSkipListMap.
When should I use a DLL instead of an array list?
Use a DLL when you need O(1) insertion/deletion at both ends or O(1) deletion of known nodes. Use an array list when you need O(1) random access by index or read-heavy iteration. Array list has better cache locality (contiguous memory) and is 10-100x faster for iteration. DLL has poor cache locality (nodes scattered in heap) but excels at pointer-based operations.
What is the space overhead of a DLL compared to a singly linked list?
A DLL adds one extra pointer (prev) per node. On a 64-bit JVM, this is 8 bytes per node (plus object header overhead). For 1 million nodes, that is approximately 8MB extra. The trade-off: 8 bytes per node buys O(1) deletion vs O(n) deletion.
How does Java's LinkedHashMap use a doubly linked list?
LinkedHashMap maintains a DLL through all entries in insertion order (default) or access order (accessOrder=true). When accessOrder=true, every get() or put() moves the accessed entry to the tail of the DLL. Combined with a capacity limit and removeEldestEntry() override, this makes LinkedHashMap an LRU cache — the eldest entry (head) is removed when capacity is exceeded.
What is the most common bug in DLL implementations?
Forgetting to update the prev pointer on insert or the head/tail pointer on delete. These create one-directional breaks: forward traversal works correctly but backward traversal hits null prematurely, or vice versa. The fix: add invariant checks (head.prev == null, tail.next == null, forward count == backward count) after every operation.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.