Skip to content
Chapter 6 · Linear Structures

Linked Lists

O(1) insertion — bought by giving up the contiguity that made arrays fast, a trade real hardware rarely rewards. Measured live.

The last chapter ended on the array’s one genuine weakness: because its elements are packed contiguously, inserting or deleting anywhere but the end means shifting everything after it — an O(n) memmove. If your workload is insert-in-the-middle heavy, that’s the wall you hit.

The linked list exists to knock that wall down. Its entire reason for being is to make insertion and deletion O(1): instead of one contiguous block, it scatters each element into its own independently-allocated node and threads the nodes together with pointers. To splice a new element in, you rewrite a couple of pointers — no shifting, no memmove, no matter how big the list is.

That’s the promise. The catch is that it pays for it with the one thing that made arrays fast, and the whole point of this chapter is to measure exactly what that costs. Spoiler: on real hardware the bill is so steep that the linked list is almost never the right answer — which is itself one of the most useful things a systems engineer can internalize.

How it works

A node is the whole idea: a value plus a pointer to the next node.

template <typename T>
struct Node {
    T value;
    Node<T>* next;   // nullptr marks the end
};

You hold a head pointer to the first node; follow next until it’s nullptr and you’ve walked the list. That’s a singly linked list. A doubly linked list adds a prev pointer to each node so you can walk backward too and delete a node given only a handle to it — the price is a second pointer’s worth of memory per element.

graph LR
    H[head] --> A["7 | •"]
    A --> B["3 | •"]
    B --> C["9 | •"]
    C --> N["nullptr"]

The superpower is the splice. Given a pointer to a node, inserting or deleting next to it is O(1) — you rewrite next (and prev), touch nothing else, and every other node keeps its address:

// insert `n` after node `p` — O(1), no shifting
n->next = p->next;
p->next = n;

The catch hides in three words: given a pointer to a node. A linked list has no random access. There is no base + i × size arithmetic to jump to element i; the only way to reach it is to start at head and follow next i times. So finding anything is O(n), and the O(1) splice only helps once you’re already holding the node. That single asymmetry — cheap to edit, expensive to find — governs everything about when a linked list is and isn’t useful.

The hardware cost: the pointer chase

Here is where the theory meets the machine, and where the linked list’s promise quietly falls apart.

Walk an array and you touch memory in a straight, predictable line. The CPU’s prefetcher sees the pattern and streams the next cache line in before you ask for it; almost every access is a cache hit. The array is the memory system’s best case.

Walk a linked list and every step is a leap into the dark. Each node was allocated separately, whenever it happened to be inserted, so it lives at some unrelated address on the heap. Following next means dereferencing a pointer to an address the CPU could not have predicted — so the prefetcher can’t help, and each hop is very likely a cache miss: a ~100ns stall, roughly 300 cycles, waiting for main memory. This is the pointer chase, and you pay it once per node, for the entire length of the list.

Both walks are O(n) and do one dereference per element. Big-O says they’re identical. Watch what the hardware actually does — the same elements, visited once each, contiguous in a std::vector versus scattered across the heap in a std::list:

Live measurement

Walking an array vs. walking a linked list

The same elements, visited once each: contiguous in memory (a std::vector) versus scattered across the heap and linked by pointers (a std::list). Identical element count, identical O(n), identical one-dereference-per-element.

takes about 4s · nothing leaves your browser

Same asymptotic complexity, and often 30–50× apart. Nothing in that gap is algorithmic — it is entirely cache misses. The array’s traversal keeps the prefetcher fed; the list’s traversal stalls on main memory at nearly every node. This is the concrete, measurable reason std::list is almost never the right choice, and why “linked lists are O(1) to insert” is one of the most misleading true statements in the field: the O(1) is real, and it’s swamped by the constant factor the moment you have to reach the insertion point.

It gets worse in aggregate. Every node also carries the memory overhead of its pointer(s) — 8 bytes for singly, 16 for doubly — plus per-node allocator bookkeeping, so a list of small values can use 2–3× the memory of the equivalent array, which means fewer elements per cache line and more misses. The array wasn’t just faster to walk; it was denser to begin with.

Engineering judgment: when a linked list actually wins

The list is not useless — it’s specialized. It wins in exactly the situations where its one real strength applies and its cache penalty doesn’t dominate:

  • You already hold node handles and need O(1) splice without disturbing anything else. The canonical case is an LRU cache: a doubly linked list of entries in recency order, plus a hash map from key to node. On a hit you unlink the node and move it to the front in O(1); on eviction you drop the tail in O(1). You never search the list — the hash map hands you the node — so you pay the splice cost and skip the traversal cost entirely.
  • You need stable addresses. A std::vector invalidates every pointer and iterator into it when it reallocates on growth. Linked-list nodes never move, so pointers to them stay valid for the node’s whole life. If other data structures hold references to your elements, that stability can be worth more than cache behavior.
  • You want an intrusive list. Embed the next/prev pointers inside the element’s own struct rather than in a separate node. Now the same object can live on several lists at once, splicing requires no allocation, and there’s no separate node to cache-miss on. This is how systems code actually uses linked lists — see the kernel below.

Outside of those, the rule is blunt and it’s the lesson to carry out of this chapter: default to a std::vector. The array’s cache win almost always beats the list’s O(1)-insert win, even for workloads that look insert-heavy on paper — because “insert in the middle” first requires “find the middle,” and finding is where the list bleeds. Vector versus list: it’s almost always vector.

Where this actually lives

When linked lists do show up in production, it’s overwhelmingly the intrusive, handle-in-hand form above — not the “list of ints” from a textbook.

Notice the pattern: every one of these holds the node directly and never traverses to find it. That is the only shape in which the linked list’s O(1) splice pays off.

Try it: reverse a linked list

The definitive linked-list exercise, and it’s pure pointer rewiring: walk the list and flip each node’s next to point at the node before it. The one habit that makes or breaks it — save next into a temporary before you overwrite it, or you lose the rest of the list. No cache tricks here, just careful pointer bookkeeping. Fill in the function and run the tests.

Interview checklist

Takeaways

  • The linked list exists to make insertion and deletion O(1) by scattering elements into pointer-linked nodes instead of one contiguous block — solving the array’s one weakness.
  • It pays for that with the array’s superpower: no random access (finding anything is O(n)) and a cache miss per node on traversal, because each node lives at an unpredictable heap address the prefetcher can’t anticipate.
  • You measured the result: same O(n), often 30–50× slower than an array walk, entirely from cache misses. This is why std::list is almost never the right choice.
  • A linked list wins only when you already hold the node and need O(1) splice without moving or invalidating anything else — intrusive lists (the Linux kernel), LRU caches, allocator free lists, adjacency lists. Otherwise: default to std::vector.

Next, stacks and queues — the two most useful restrictions you can put on a sequence.