Chapter 6
18 min read
Section 32 of 261

Doubly Linked Lists

Linked Lists

The Extra Pointer Back

A singly linked list moves in one direction. Given a pointer to some node xx, you can reach x.nextx.\text{next}, but you cannot, in O(1)O(1), reach xx's predecessor. To delete xx you first have to find its predecessor by walking from the head — that is O(n)O(n). 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 next\text{next}, and a backward pointer prev\text{prev}.

That single extra pointer buys two superpowers:

  1. Delete-by-pointer in O(1)O(1). 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.
  2. Reverse traversal in O(n)O(n). Just as natural as forward traversal — start at the tail and walk prev\text{prev} until you hit the front.

The unifying idea

A singly linked list is a one-way street. A doubly linked list is a two-way street. Every operation that needed the predecessor now becomes a single hop instead of a full traversal. That is the entire reason this data structure exists.

The Structural Invariant

The defining property of a well-formed doubly linked list is the bidirectional consistency invariant:

  • For every non-tail node nn: n.next.prev=nn.\text{next}.\text{prev} = n.
  • For every non-head node nn: n.prev.next=nn.\text{prev}.\text{next} = n.

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 AA to BB, then walking backward from BB must land you exactly back at AA.

The single rule that governs every operation

Every mutation — insert, delete, splice — must restore this invariant before returning. If even one of the four pointer rewrites in an insertion is missing, the list is silently corrupt: forward iteration may still work, but reverse iteration will skip a node, or worse, walk into a freed object.

Sentinel Head and Tail

The cleanest implementation uses two sentinel nodes: a dummy head\text{head} and a dummy tail\text{tail}, neither of which carries user data. The empty list is two sentinels wired together:

📝text
1HEAD <-> TAIL          # empty list (two sentinel nodes)
2HEAD <-> [7] <-> TAIL  # one real node
3HEAD <-> [7] <-> [3] <-> [9] <-> TAIL

Why pay for two extra node allocations? Because every real node now sits between two non-null neighbors. The first real node's prev\text{prev} is the head sentinel, not None\text{None}. The last real node's next\text{next} is the tail sentinel, not None\text{None}. 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

The non-sentinel version is occasionally taught as "simpler" because there is no setup. In production it is the opposite: the non-sentinel version has more code due to the boundary checks scattered through every mutator.

Splice — inserting a new node between A and B

Suppose two adjacent nodes AA and BB already satisfy A.next=BA.\text{next} = B and B.prev=AB.\text{prev} = A. We want to insert a new node XX between them. Four pointer assignments:

📝text
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 X

Order matters within pairs. Lines 1–2 set up XX 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 XX itself — the link to BB 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 AA, so backward traversal misses XX entirely.

Now XX sits between AA and BB, and we want to remove it. Two pointer assignments suffice:

📝text
1A.next = B         # 1. forward chain bypasses X
2B.prev = A         # 2. backward chain bypasses X

That is the entire delete. The neighbors close up around XX, and XX 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 O(n)O(n) traversal. Given the node, the delete is O(1)O(1). This is the crown jewel of the doubly linked list.

Don't forget the second update

The most common doubly-linked-list bug in production is writing 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.

HEADsentinelprev7nextnode #1prev3nextnode #2prev9nextnode #3TAILsentinel.next (forward).prev (backward)cyan border = cursorblue fill = selected
> HEAD <-> [7] <-> [3] <-> [9] <-> TAIL

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.

DoublyLinkedList — sentinel head/tail, O(1) splice and unlink
🐍doubly_linked_list.py
1Node class

A node carries a value plus TWO pointers — one forward (next), one backward (prev). The extra pointer is the entire reason a doubly linked list exists.

2Constructor

Takes the payload value. We accept None for the sentinels at lines 11–12 — sentinels carry no real data.

3

Stores the user's value on the node. Bytes: depends on Python's int/object overhead, but the structural cost we care about is the two pointers below.

4Backward pointer

Default to None. A real node placed inside the list will have prev rebound to point at its predecessor; only sentinels stay None on one side.

5Forward pointer

Same idea, but pointing toward the tail. Two pointers, one node — the structural definition of a doubly linked list.

8DoublyLinkedList class

Owns the sentinels, the size counter, and every operation that mutates the chain.

9__init__

Builds an empty list — but 'empty' in this design still has TWO node objects (the sentinels).

10Dummy head

A real node object that is never exposed to the user. Its only job: guarantee that every real node has a non-None prev. This eliminates the 'is this the first node?' edge case.

11Dummy tail

Symmetric to head. Guarantees every real node has a non-None next. With BOTH sentinels in place, every real node lives between two non-None neighbors — no boundary cases ever.

12Wire head -> tail

Empty-list invariant: head.next must point at tail. There is nothing between them yet.

13Wire tail -> head

Symmetric backward link. Now head.next == tail AND tail.prev == head. The bidirectional invariant holds for the empty list.

14Size counter

Tracks number of REAL nodes (sentinels excluded). Maintained by every mutator so that __len__ is O(1).

16__len__

Returns the cached size. Walking the chain to count would be O(n) — we pay one increment per insert/delete instead.

19insert_after

The fundamental constructor of every other insertion. Splices a NEW node directly after a given node. O(1) — no traversal.

20Allocate

Build the new node with prev=None, next=None. We are about to overwrite both.

21Capture node.next

Save the old successor BEFORE we mutate node.next on line 24. If we wrote node.next first we would lose the address of the old successor and corrupt the chain.

22Splice step 1: new.prev <- node

Wire the new node's backward pointer to the anchor. After this line: new -> node, but node still ignores new.

23Splice step 2: new.next <- nxt

Wire the new node's forward pointer to the old successor. After this line, new is fully aware of its neighbors, but neighbors don't yet know about new.

24Splice step 3: node.next <- new

Now node sees new as its successor. Forward chain is repaired: ... node -> new -> nxt ...

25Splice step 4: nxt.prev <- new

Backward chain is repaired: ... node <- new <- nxt ... The bidirectional invariant is restored. Skipping ANY of the four lines breaks the structure.

26Bookkeeping

size grows by exactly one. We increment AFTER all pointer writes — if any of them threw, the list state would still be consistent.

27Return new node

Returning the node lets callers hold a stable handle for future O(1) deletes. This is what enables the LRU-cache pattern.

29delete_node

Removes a node given a direct pointer to it. The headline operation: O(1) WITHOUT traversal — impossible in a singly linked list.

30Sentinel guard

Deleting a sentinel would destroy the list's structural invariants. Raise loudly. In production code this is usually an assert — never silently no-op.

32Capture neighbors

a = predecessor, b = successor. Both are guaranteed non-None thanks to the sentinels (no special-case for first/last).

33

Same — capture the successor before mutation.

34Unlink step 1

a now skips over node and points directly at b. Forward chain bypasses node.

35Unlink step 2

b now skips over node and points directly at a. Backward chain bypasses node. Two pointer rewrites total — that is the entire delete.

36Help GC

Detach node from the chain entirely. This prevents dangling external iterators from accidentally walking back into the live list, and lets Python reclaim the object faster if it is unreferenced.

37Bookkeeping

size shrinks by one. Combined with the sentinel guard, size is always correct.

38Return value

Caller often wants the freed payload (e.g., LRU eviction returns the evicted key).

40append

Build on insert_after with anchor = tail.prev. Because tail is a sentinel, tail.prev always exists. No branching.

41

Calling insert_after(tail.prev, value) correctly handles the empty case too: when the list is empty, tail.prev IS head, so we splice between head and tail.

43prepend

Symmetric: insert_after(head, value). head is a sentinel that always exists.

46__iter__

Standard forward generator. Starts AFTER the head sentinel and stops BEFORE the tail sentinel — sentinels are invisible to user code.

47

cur is the first REAL node (or tail if list is empty).

48

Halt condition uses identity (is) — we test whether we hit the sentinel object itself, not value equality.

49

Yield the payload, never the node object.

50

Advance via next pointer.

52__reversed__

Built-in: enables `for x in reversed(dll):` syntax. THIS is the second headline win — backward iteration is O(n) and just as natural as forward.

53

Start at the last real node (tail.prev).

54

Halt at head sentinel.

55

Yield value.

56Walk backward

Advance via the prev pointer — the entire reason this data structure exists.

12 lines without explanation
1class Node:
2    def __init__(self, value):
3        self.value = value
4        self.prev = None
5        self.next = None
6
7
8class DoublyLinkedList:
9    def __init__(self):
10        self.head = Node(None)            # dummy head sentinel
11        self.tail = Node(None)            # dummy tail sentinel
12        self.head.next = self.tail
13        self.tail.prev = self.head
14        self.size = 0
15
16    def __len__(self):
17        return self.size
18
19    def insert_after(self, node, value):
20        new_node = Node(value)
21        nxt = node.next
22        new_node.prev = node              # 1. new -> node
23        new_node.next = nxt               # 2. new -> nxt
24        node.next = new_node              # 3. node -> new
25        nxt.prev = new_node               # 4. nxt -> new
26        self.size += 1
27        return new_node
28
29    def delete_node(self, node):
30        if node is self.head or node is self.tail:
31            raise ValueError("cannot delete sentinel")
32        a = node.prev
33        b = node.next
34        a.next = b                        # bypass node forward
35        b.prev = a                        # bypass node backward
36        node.prev = node.next = None      # help GC, prevent dangling refs
37        self.size -= 1
38        return node.value
39
40    def append(self, value):
41        return self.insert_after(self.tail.prev, value)
42
43    def prepend(self, value):
44        return self.insert_after(self.head, value)
45
46    def __iter__(self):
47        cur = self.head.next
48        while cur is not self.tail:
49            yield cur.value
50            cur = cur.next
51
52    def __reversed__(self):
53        cur = self.tail.prev
54        while cur is not self.head:
55            yield cur.value
56            cur = cur.prev

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).

DoublyLinkedList<T> — RAII, sentinels, identical splice/unlink
📄doubly_linked_list.hpp
1

<stdexcept> brings in std::invalid_argument for the sentinel guard at line 47.

3Templated

Generic over the payload type T — works for int, std::string, or a user struct, just like std::list<T>.

4Class declaration

Owns three things: head pointer, tail pointer, size. The class is the resource manager (RAII).

5Inner Node struct

Private nested type. value holds payload, prev/next are raw pointers. Raw pointers (not unique_ptr) because of the cycle prev <-> next — smart pointers cannot express bidirectional ownership cleanly.

6

The payload. Type T means this list is generic.

7Backward pointer

Raw pointer; ownership belongs to the enclosing list, NOT to the node itself.

8Forward pointer

Same — this raw pointer is owned by the list. The destructor at line 22 walks them all and calls delete.

9Node constructor

Initializer list: copies value, sets both pointers to nullptr. They are wired up later by insert_after.

12Sentinel head

A heap-allocated dummy. Never returned to the user. Its prev stays nullptr forever; its next is the live chain.

13Sentinel tail

Symmetric dummy. Together with head, it guarantees every real node sits between two valid nodes — zero edge cases.

14Size counter

std::size_t — unsigned. Counts only real nodes. Sentinels excluded.

17Default constructor

Allocates both sentinels and wires them empty: head <-> tail. T{} value-initializes the dummy payloads (0 for ints, empty string for std::string, etc.).

18Init list

Member init list runs BEFORE the constructor body — using it is the correct way to initialize members.

19Wire forward

head->next = tail. The empty-list invariant on the forward side.

20Wire backward

tail->prev = head. The empty-list invariant on the backward side. The list is now structurally valid even with zero real nodes.

23Destructor — RAII

When the list goes out of scope, this runs automatically and frees every node, including both sentinels. The class manages memory; users never see a delete.

24Walk and delete

Start at head sentinel and walk via next, deleting as we go.

25

Tail's next is nullptr (set at line 9, never overwritten), so the loop stops cleanly after the tail sentinel is deleted.

26Capture before delete

MUST save cur->next BEFORE delete cur — otherwise we read freed memory on the next iteration.

27

Free the current node. Calls Node's destructor (trivial here) then releases heap memory.

28

Advance to the captured successor.

32Disable copy

Copying a DoublyLinkedList by default would copy the head/tail pointers, leading to two lists thinking they own the same nodes — double-free disaster on destruction. Delete the copy operations to make this a compile error.

33

Same for copy-assignment. (You would implement deep copy or move-only semantics in a real production class.)

35insert_after

Same four-pointer splice as Python — but in C++ the failure mode is a memory leak if 'new Node' throws, so production code wraps the allocation in a try/catch or uses placement new.

36Allocate on heap

new returns a Node* pointing into heap memory. The list takes ownership immediately.

37Capture old successor

Same reason as Python — must save before mutating node->next on line 40.

38Splice step 1

n->prev = node. Wire new -> node.

39Splice step 2

n->next = nx. Wire new -> nx.

40Splice step 3

node->next = n. Wire node -> new.

41Splice step 4

nx->prev = n. Wire nx -> new. Bidirectional invariant restored.

42

Increment size only after all pointer writes succeed.

43

Return the raw Node* — caller can use it later for O(1) erase.

46erase

Equivalent of Python's delete_node. Returns the payload by value so the caller has a copy of the freed data.

47Sentinel guard

Compare pointers, not values. Sentinels carry T{} which could collide with real values.

48

Throw — same idea as Python's ValueError.

49Capture neighbors

Same identifiers as Python — a = prev, b = next.

51Unlink forward

a->next = b. Forward chain bypasses node.

52Unlink backward

b->prev = a. Backward chain bypasses node.

53Save value

Copy the payload BEFORE delete — after delete, node->value is reading freed memory (undefined behavior).

54Free

Release heap memory. Node's destructor runs (trivial). Pointer becomes dangling — never dereference it again.

55

Decrement size.

56

Return the captured value.

16 lines without explanation
1#include <stdexcept>
2
3template <class T>
4class DoublyLinkedList {
5    struct Node {
6        T value;
7        Node* prev;
8        Node* next;
9        Node(T v) : value(v), prev(nullptr), next(nullptr) {}
10    };
11
12    Node* head;            // dummy head sentinel
13    Node* tail;            // dummy tail sentinel
14    std::size_t sz;
15
16public:
17    DoublyLinkedList()
18        : head(new Node(T{})), tail(new Node(T{})), sz(0) {
19        head->next = tail;
20        tail->prev = head;
21    }
22
23    ~DoublyLinkedList() {
24        Node* cur = head;
25        while (cur) {
26            Node* nxt = cur->next;
27            delete cur;
28            cur = nxt;
29        }
30    }
31
32    DoublyLinkedList(const DoublyLinkedList&) = delete;
33    DoublyLinkedList& operator=(const DoublyLinkedList&) = delete;
34
35    Node* insert_after(Node* node, T value) {
36        Node* n  = new Node(value);
37        Node* nx = node->next;
38        n->prev = node;
39        n->next = nx;
40        node->next = n;
41        nx->prev = n;
42        ++sz;
43        return n;
44    }
45
46    T erase(Node* node) {
47        if (node == head || node == tail)
48            throw std::invalid_argument("sentinel");
49        Node* a = node->prev;
50        Node* b = node->next;
51        a->next = b;
52        b->prev = a;
53        T value = node->value;
54        delete node;
55        --sz;
56        return value;
57    }
58};

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:

  1. Hashmap gives us a pointer to the node holding the key. Good.
  2. To move it, we first have to delete it from its current position.
  3. To delete from a singly linked list, we need the predecessor.
  4. Finding the predecessor is O(n)O(n). Catastrophe — the cache lookup degrades from O(1)O(1) expected to O(n)O(n) worst case.

With a doubly linked list, step 3 becomes O(1)O(1): we already have x.prevx.\text{prev}. 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 88 bytes per node. For a node holding a 64-bit integer, the structural overhead per node is roughly:

ImplementationPer-node layoutBytes (64-bit)
Singly linkedvalue (8) + next (8)16
Doubly linkedvalue (8) + next (8) + prev (8)24
Doubly linked + sentinelsabove + 2 dummy nodes total24n + 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 nn nodes can incur nn cache misses.

The two sentinels are a fixed O(1)O(1) overhead — irrelevant for any list with more than a handful of elements, and well worth the simplification.


Comparison vs Singly Linked

OperationSingly linkedDoubly linked
Prepend (insert at head)O(1)O(1)
Append (insert at tail, with tail pointer)O(1)O(1)
Insert after a known nodeO(1)O(1)
Insert before a known nodeO(n)O(1)
Delete headO(1)O(1)
Delete tailO(n)O(1)
Delete given a node pointerO(n) (must find prev)O(1)
Forward iterationO(n)O(n)
Reverse iterationO(n^2) or build stackO(n) natural
Memory per node (64-bit)16 B24 B
Edge-case complexity (with sentinels)SomeNone — uniform

The big-O column for forward operations is identical, but the constant factors differ — and several rows that were O(n)O(n) for the singly version collapse to O(1)O(1). 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

  1. LRU caches. Hashmap of key \to doubly-linked-list-node, with the list ordered by recency. Every hit is O(1)O(1). We will build this in Chapter 9.
  2. 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.
  3. 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.
  4. Music and video players. Playlists with prev/next buttons — the queue is a doubly linked list (often circular).
  5. The Linux kernel. struct list_head in include/linux/list.h is an embedded doubly linked list head with sentinel — used for almost every dynamic list inside the kernel (process lists, file descriptor tables, etc.).
  6. Standard library containers. C++ std::list and Java java.util.LinkedList (which also implements Deque) are doubly linked with sentinels.
  7. Window managers. Alt-Tab walks a doubly linked list of focusable windows so you can step both directions through the cycle.
  8. 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

  1. 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 n for every node.
  2. Forgetting one of the two unlink writes. Same failure mode. Defense: same assertion, run it on the neighbors after every delete in test mode.
  3. 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.
  4. Accidental cycles. Setting node.next = node by mistake creates an infinite loop on iteration. Hard to catch; use a visited-set in debug mode.
  5. 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::list handles this by documenting that erase invalidates only the erased iterator — but you must clear node.prev and node.next on delete (as Python's delete_node does on line 36) so a stale iterator at least fails fast instead of walking back into the live list.
  6. Touching a sentinel. Iterating to node is None instead of node is self.tail walks past the sentinel into None\text{None} 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: O(1)O(1) 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.

Loading comments...