The Extra Pointer Back
A singly linked list moves in one direction. Given a pointer to some node , you can reach , but you cannot, in , reach 's predecessor. To delete you first have to find its predecessor by walking from the head — that is . To iterate backward, you must reverse the list or stack up pointers along the way. This asymmetry is the central pain of the singly linked list.
A doubly linked list fixes it with one structural change: every node carries an extra pointer back. Each node now holds three fields: a value, a forward pointer , and a backward pointer .
That single extra pointer buys two superpowers:
- Delete-by-pointer in . If you already hold a reference to the node, you can unlink it without ever touching the head — because the predecessor is one hop backward.
- Reverse traversal in . Just as natural as forward traversal — start at the tail and walk until you hit the front.
The unifying idea
The Structural Invariant
The defining property of a well-formed doubly linked list is the bidirectional consistency invariant:
- For every non-tail node : .
- For every non-head node : .
Read this carefully. It says the two arrows between any pair of adjacent nodes are mirror images of each other. If you walk forward from to , then walking backward from must land you exactly back at .
The single rule that governs every operation
Sentinel Head and Tail
The cleanest implementation uses two sentinel nodes: a dummy and a dummy , neither of which carries user data. The empty list is two sentinels wired together:
1HEAD <-> TAIL # empty list (two sentinel nodes)
2HEAD <-> [7] <-> TAIL # one real node
3HEAD <-> [7] <-> [3] <-> [9] <-> TAILWhy pay for two extra node allocations? Because every real node now sits between two non-null neighbors. The first real node's is the head sentinel, not . The last real node's is the tail sentinel, not . This is the entire reason sentinels exist: they erase boundary cases.
Without sentinels, every method has to special-case "is this the first/last node?". With sentinels, the same four pointer rewrites work for the front, the middle, and the back. The code is shorter, the bug surface is smaller, and the invariant is uniform. Both std::list in C++ and java.util.LinkedList in Java use this exact idiom.
Pedagogical note
Splice and Unlink: The Core Pointer Dance
Splice — inserting a new node between A and B
Suppose two adjacent nodes and already satisfy and . We want to insert a new node between them. Four pointer assignments:
1X.prev = A # 1. X knows its predecessor
2X.next = B # 2. X knows its successor
3A.next = X # 3. A's forward link skips through X
4B.prev = X # 4. B's backward link skips through XOrder matters within pairs. Lines 1–2 set up completely; lines 3–4 patch the neighbors. Crucially, you must capture the old neighbor before overwriting the pointer that points to it. If you wrote A.next = X first and then tried to read A.next.prev = X, you would be writing into itself — the link to would be lost.
Skipping any of the four lines is a silent corruption. Forward traversal might still appear to work even with line 4 missing — but B.prev still points at , so backward traversal misses entirely.
Unlink — deleting node X between A and B
Now sits between and , and we want to remove it. Two pointer assignments suffice:
1A.next = B # 1. forward chain bypasses X
2B.prev = A # 2. backward chain bypasses XThat is the entire delete. The neighbors close up around , and becomes garbage (or in manual-memory languages, the caller frees it). Notice that nothing about this loop required the head pointer — and therefore nothing required traversal. Given the node, the delete is . This is the crown jewel of the doubly linked list.
Don't forget the second update
A.next = B and forgetting B.prev = A. The forward iterator looks fine. The reverse iterator walks into the deleted node and segfaults (in C/C++) or sees garbage (in any language).Interactive: Doubly Linked List Lab
Build a list, splice nodes into it, delete them by reference, and walk the cursor in either direction. The forward (cyan) and backward (amber) arrows are drawn separately so you can see both halves of the invariant at once. Click any node to select it; sentinels are dashed.
Click a node to select it. Then insert_after(selected) splices a new node in with four pointer rewrites; delete(selected) unlinks it with two. The .next and .prev step buttons walk the cursor — notice you can run it in either direction without ever returning to the head.
Implementation in Python
Below is a complete, sentinel-based implementation. Every meaningful line is annotated. Read the splice block (lines 22–25) and the unlink block (lines 34–35) carefully — those eight lines contain everything the data structure does.
Implementation in C++
The C++ version makes two things explicit that Python hides: memory ownership (RAII, the destructor walks and frees the chain) and copy semantics (we delete the copy constructor, because shallow copying two list objects that share node pointers is a double-free disaster).
Why O(1) Delete-by-Pointer Matters
Imagine a cache that maps keys to a position in a recency list — the most-recently used at the front, the least-recently used at the back. On every cache hit, we want to move that entry to the front. The naive plan with a singly linked list:
- Hashmap gives us a pointer to the node holding the key. Good.
- To move it, we first have to delete it from its current position.
- To delete from a singly linked list, we need the predecessor.
- Finding the predecessor is . Catastrophe — the cache lookup degrades from expected to worst case.
With a doubly linked list, step 3 becomes : we already have . The whole move becomes two unlinks plus four splice rewrites — eight pointer assignments, constant time. This single property is why the canonical LRU cache implementation is a hashmap plus a doubly linked list and not anything else.
The slogan: the doubly linked list is what you reach for when you already know which node you want to delete, and you can't afford to find its predecessor.
Singly linked lists have a famous workaround — delete a node given only a pointer to it by copying the next node's value into the current node and unlinking the next instead. This works only if the deleted node is not the tail, and it has the side effect of changing addresses, which breaks any external iterator pointing at the next node. In production, the doubly linked list is the clean solution.
Memory Cost
Each node carries an extra pointer compared to a singly linked list. On a 64-bit machine that is bytes per node. For a node holding a 64-bit integer, the structural overhead per node is roughly:
| Implementation | Per-node layout | Bytes (64-bit) |
|---|---|---|
| Singly linked | value (8) + next (8) | 16 |
| Doubly linked | value (8) + next (8) + prev (8) | 24 |
| Doubly linked + sentinels | above + 2 dummy nodes total | 24n + 48 |
| Contiguous array (int32) | value (4) | 4 |
The doubly linked list is 6× more memory per element than a contiguous array of int32, and 1.5× the singly linked variant. In addition, every node lives at a heap-scattered address, so cache locality is poor — a forward walk of nodes can incur cache misses.
The two sentinels are a fixed overhead — irrelevant for any list with more than a handful of elements, and well worth the simplification.
Comparison vs Singly Linked
| Operation | Singly linked | Doubly linked |
|---|---|---|
| Prepend (insert at head) | O(1) | O(1) |
| Append (insert at tail, with tail pointer) | O(1) | O(1) |
| Insert after a known node | O(1) | O(1) |
| Insert before a known node | O(n) | O(1) |
| Delete head | O(1) | O(1) |
| Delete tail | O(n) | O(1) |
| Delete given a node pointer | O(n) (must find prev) | O(1) |
| Forward iteration | O(n) | O(n) |
| Reverse iteration | O(n^2) or build stack | O(n) natural |
| Memory per node (64-bit) | 16 B | 24 B |
| Edge-case complexity (with sentinels) | Some | None — uniform |
The big-O column for forward operations is identical, but the constant factors differ — and several rows that were for the singly version collapse to . When the workload involves deletions by reference, bidirectional iteration, or insertion before a known node, the doubly linked list is the right tool.
Where Doubly Linked Lists Live
- LRU caches. Hashmap of key doubly-linked-list-node, with the list ordered by recency. Every hit is . We will build this in Chapter 9.
- Browser tab and history navigation. Forward and back buttons walk a doubly linked list of visited pages. Inserting a new page in the middle (after navigating back, then visiting somewhere new) is a one-step splice.
- Undo/redo stacks in editors. Each command is a node; the cursor moves backward (undo) or forward (redo) through the list of edits. Modern editors like VS Code build their command history this way.
- Music and video players. Playlists with prev/next buttons — the queue is a doubly linked list (often circular).
- The Linux kernel.
struct list_headininclude/linux/list.his an embedded doubly linked list head with sentinel — used for almost every dynamic list inside the kernel (process lists, file descriptor tables, etc.). - Standard library containers. C++
std::listand Javajava.util.LinkedList(which also implementsDeque) are doubly linked with sentinels. - Window managers. Alt-Tab walks a doubly linked list of focusable windows so you can step both directions through the cycle.
- Text editor line buffers. Some editors store the document as a doubly linked list of lines so that splitting and merging lines (Enter, Backspace at column 0) is constant time.
Pitfalls and How to Avoid Them
- Forgetting one of the four splice writes. Forward traversal still works; reverse traversal silently skips the new node. Defense: always write splices as four lines in the same order, and run a symmetry assertion in tests:
assert n.next.prev is nfor every node. - Forgetting one of the two unlink writes. Same failure mode. Defense: same assertion, run it on the neighbors after every delete in test mode.
- Memory leak when destroying the list. In manual-memory languages, you must walk the chain and free every node. The destructor in the C++ implementation above does exactly this.
- Accidental cycles. Setting
node.next = nodeby mistake creates an infinite loop on iteration. Hard to catch; use a visited-set in debug mode. - Iterator invalidation. If a user holds an iterator pointing at a node and you delete that node, the iterator now references freed memory. C++
std::listhandles this by documenting that erase invalidates only the erased iterator — but you must clearnode.prevandnode.nexton delete (as Python'sdelete_nodedoes on line 36) so a stale iterator at least fails fast instead of walking back into the live list. - Touching a sentinel. Iterating to
node is Noneinstead ofnode is self.tailwalks past the sentinel into on the first wraparound. Always test with sentinel identity.
Quick Checks
Quick Check
A new node X is being spliced between adjacent nodes A and B (where A.next == B and B.prev == A). Which sequence of FOUR assignments correctly preserves the bidirectional invariant?
Quick Check
Why does a doubly linked list WITHOUT sentinels need special-case code in delete_node, while one WITH sentinels does not?
The doubly linked list adds one pointer per node and earns two superpowers: delete-by-reference and natural reverse iteration. Combined with sentinels, it becomes the rare data structure where the implementation has zero edge cases — every real node is uniformly "in the middle." In the next section we look at circular linked lists, where the tail loops back to the head and a third class of problems (round-robin schedulers, ring buffers, Josephus-style rotations) becomes natural.