Doubly Linked List — Full Implementation & 6 Scenario Problems
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
An LRU Cache requires two core operations: (1) look up a key and promote it to the most-recently-used position in O(1), and (2) evict the least-recently-used node from the tail in O(1). In a singly linked list, deleting an arbitrary node requires finding its predecessor, which takes O(N) traversal unless extra pointers are maintained. In a doubly linked list, each node has a direct pointer to its predecessor (node->prev), allowing unlinking and splicing in strictly O(1) time with zero traversal.
For std::vector<int>, 1,000,000 4-byte integers take 4 MB of contiguous memory. For std::list<int>, every node contains two 8-byte pointers (prev and next), a 4-byte integer, and 4 bytes of alignment padding, totaling 24 bytes per node, plus 8–16 bytes of heap allocator metadata per node. In total, std::list consumes 32–40 MB—roughly an 8x to 10x memory penalty—and causes massive cache thrashing.
Because head_ and tail_ are initialized first to point to each other, the list is in a completely valid, coherent empty state before any data nodes are inserted. If an exception is thrown while allocating or copying a data element in the constructor, the destructor ~DoublyLinkedList() can safely call clear() to delete all nodes allocated so far without checking whether head_ or tail_ are null.
Interviewer's lens
Interviewers at top tech firms frequently ask candidates to implement a doubly linked list or solve a mutation scenario because it exposes whether you write fragile, branch-heavy pointer spaghetti or elegant, invariant-driven systems code. Weak candidates juggle four separate if (head == nullptr), if (curr == head), if (curr == tail) conditions, inevitably missing an edge case. Senior candidates immediately introduce sentinel dummy head_ and tail_ nodes, shrinking insertion and unlinking down to 4 lines and 2 lines of pointer manipulation with zero special-case branches, and demonstrate deep awareness of safe iteration deletion, O(1) zero-allocation LRU splicing, and bidirectional traversal deadlocks.
Under the hood & memory layout
In memory, each Node<T> in a doubly linked list contains two 8-byte pointer addresses (prev and next) alongside the payload T data. On 64-bit architectures, this incurs a minimum 16 bytes of pointer overhead per element, plus potential struct padding up to an 8-byte alignment boundary. By allocating two permanent sentinel dummy nodes (head_ and tail_), the list maintains the strict invariant that every real data node always has a non-null predecessor and non-null successor. Unlinking a node is unconditionally p->next = n; n->prev = p;, and inserting before pos is unconditionally node->prev = p; node->next = pos; p->next = node; pos->prev = node;. Because nodes are dynamically allocated on the heap at disparate addresses, traversing a linked list yields frequent CPU L1/L2 cache misses compared to contiguous buffers.
Contains Node* prev (8 bytes), Node* next (8 bytes), and T data (sizeof(T) bytes). For int (4 bytes), struct padding adds 4 bytes to achieve 8-byte alignment, yielding 24 bytes total per node on x86_64.
Constructed once in the list constructor. In an empty list, head_->next = tail_ and tail_->prev = head_. Real data nodes only exist in the half-open range (head_, tail_). head_->prev and tail_->next remain nullptr.
For every valid node x strictly between head_ and tail_, x->next->prev == x and x->prev->next == x. Corrupting either back-pointer breaks backward iteration or causes infinite loops.
Unlike std::vector, adjacent nodes in a linked list are scattered across arbitrary heap pages. Each pointer dereference (curr = curr->next) can incur an L3 cache miss (~40-100 cycles).
Core concepts & syntax traps
Why Sentinel Dummy head_ and tail_ Nodes Are the Ultimate Interview Secret
Why Sentinel Dummy head_ and tail_ Nodes Are the Ultimate Interview Secret
In a naive doubly linked list where head_ and tail_ are raw pointers directly pointing to data nodes (or nullptr), any insertion or deletion requires multiple branches:
// NAIVE FRAGILE CODE:
if (node == head) { head = node->next; if (head) head->prev = nullptr; }
else { node->prev->next = node->next; }
if (node == tail) { tail = node->prev; if (tail) tail->next = nullptr; }
else { node->next->prev = node->prev; }
This is notoriously prone to off-by-one errors and segmentation faults when lists become empty. By allocating two sentinel dummy nodes (head_ and tail_), every real node is guaranteed to have both a valid prev and a valid next. Unlinking a node collapses into two unconditional pointer writes:
node->prev->next = node->next;
node->next->prev = node->prev;
Not a single if statement is needed, eliminating 100% of boundary condition edge-case bugs.
// Sentinel initialization invariant:
head_ = new Node();
tail_ = new Node();
head_->next = tail_;
tail_->prev = head_;
head_->prev = nullptr;
tail_->next = nullptr;
// List is empty when head_->next == tail_Always allocate dummy sentinels in live coding interviews; it eliminates edge cases for head, tail, and empty lists.
The 4-Pointer Insertion Order & 2-Pointer Unlink Invariant
The 4-Pointer Insertion Order & 2-Pointer Unlink Invariant
When inserting a newNode before an existing position node pos, four pointers must be rewired. The order of assignment is critical to avoid losing access to the neighbor nodes:
newNode->prev = pos->prev;(wire backward to predecessor)newNode->next = pos;(wire forward to successor)newNode->prev->next = newNode;(rewire predecessor's forward link)pos->prev = newNode;(rewire successor's back link)
If you execute step 4 before step 1, pos->prev is overwritten with newNode, meaning you can no longer reach the original predecessor without an extra temporary variable!
void insertBefore(Node* pos, Node* newNode) noexcept {
Node* p = pos->prev; // save predecessor
newNode->prev = p; // wire 1
newNode->next = pos; // wire 2
p->next = newNode; // wire 3
pos->prev = newNode; // wire 4
++size_;
}Wire the new node's outgoing pointers first before modifying existing list pointers.
Scenario 1: Safe Deletion While Iterating
Scenario 1: Safe Deletion While Iterating
A classic interview trap is asking you to remove elements matching a predicate while iterating through a linked list. If you write:
// BUG: Use-After-Free!
for (Node* curr = list.first(); curr != list.end(); curr = curr->next) {
if (curr->data % 2 == 0) deleteNode(curr); // curr is destroyed; curr->next is garbage!
}
The moment deleteNode(curr) deallocates memory, curr->next dereferences freed memory (heap corruption / segfault). The production solution is designing erase(Node* curr) to return curr->next:
Node* curr = list.firstNode();
while (curr != list.endNode()) {
if (curr->data % 2 == 0) {
curr = list.erase(curr); // Returns curr->next directly!
} else {
curr = curr->next;
}
}
Node* erase(Node* node) noexcept {
if (!node || node == head_ || node == tail_) return tail_;
Node* nextNode = node->next;
node->prev->next = nextNode;
nextNode->prev = node->prev;
delete node;
--size_;
return nextNode; // Caller seamlessly continues iteration!
}Always return the successor pointer from erase() to allow single-pass conditional deletions without use-after-free.
Scenario 2: O(1) moveToFront / Splicing (The Heart of LRU Cache)
Scenario 2: O(1) moveToFront / Splicing (The Heart of LRU Cache)
In an LRU Cache (Least Recently Used), accessing a key requires promoting its corresponding node to the front of the list. A naive implementation destroys the old node and creates a new one (erase + push_front), calling operator new and operator delete on every single cache hit! In high-frequency trading or database caches, this causes catastrophic heap fragmentation and latency spikes.
A senior candidate implements moveToFront(Node*) in O(1) with zero allocations by unlinking the existing node from its current position and rewiring it immediately after head_. The node's memory address never changes, ensuring external pointers or hash map iterator values remain 100% valid.
void moveToFront(Node* node) noexcept {
if (!node || node == head_ || node == tail_ || node == head_->next) return;
// 1. Unlink node from current position (2 pointer rewires)
node->prev->next = node->next;
node->next->prev = node->prev;
// 2. Insert node right after head_ (4 pointer rewires)
Node* first = head_->next;
node->prev = head_;
node->next = first;
head_->next = node;
first->prev = node;
// Zero memory allocation or deallocation!
}LRU promotion must rewire existing pointers in O(1) rather than delete and reallocate nodes.
Scenario 3 & 4: In-Place Reversal & Repairing Corrupted prev Pointers
Scenario 3 & 4: In-Place Reversal & Repairing Corrupted prev Pointers
To reverse a doubly linked list in-place, traverse every node from head_ to tail_ and swap curr->prev with curr->next. Finally, swap head_ and tail_. Because both directional links are swapped in a single pass, the list is completely inverted in O(N) time and O(1) auxiliary space.
In Scenario 4 (Corrupted Pointer Repair), interviewers present a DLL where forward next pointers are intact, but one or more back-pointers prev were corrupted by buggy concurrent code or serialization. We can traverse forward via next and verify curr->next->prev == curr. If a discrepancy is found, restoring curr->next->prev = curr heals the list in O(N) time with zero extra space.
void reverse() noexcept {
if (size_ <= 1) return;
Node* curr = head_;
while (curr != nullptr) {
std::swap(curr->prev, curr->next);
curr = curr->prev; // prev now holds the original next pointer!
}
std::swap(head_, tail_);
}In-place DLL reversal is simply swapping prev and next on every node; verifying curr->next->prev == curr detects and repairs corrupted back-links.
Scenario 5 & 6: O(1) List Splicing & Thread-Safety Concurrency Traps
Scenario 5 & 6: O(1) List Splicing & Thread-Safety Concurrency Traps
In Scenario 5, two doubly linked lists can be concatenated in O(1) time by taking other's entire range [other.head_->next, other.tail_->prev] and splicing it directly before this->tail_, then setting other to empty.
In Scenario 6 (Concurrency Trap), interviewers ask: "Can we use fine-grained per-node mutexes (std::mutex mtx_ inside each Node) to allow concurrent reads and writes?" The answer is a resounding NO without global lock ordering. If Thread A traverses forward (locking Node1 then Node2) while Thread B traverses backward (locking Node2 then Node1), the threads will lock-order invert and deadlock! Concurrent linked lists require coarse-grained list locks, reader-writer locks (std::shared_mutex), or complex lock-free hazard-pointer schemes.
// Deadlock scenario with fine-grained per-node locks:
// Thread 1: lock(nodeA) -> tries to lock(nodeB) [Forward traversal]
// Thread 2: lock(nodeB) -> tries to lock(nodeA) [Backward traversal]
// Result: ABBA Deadlock! Always enforce uniform lock direction or use std::scoped_lock.Never use bidirectional per-node locking without strict address-based ordering to prevent ABBA deadlocks.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.