Topic 02

Linked Lists

Data Structures

A linked list stores each element in its own node, with a pointer to the next node. Inserting or removing at a node you already hold is two pointer writes, and nothing else moves. Textbooks sell the structure on that property.

On real hardware the story has a second half. Getting to the node costs one pointer hop per element, and each hop is a likely cache miss (Chapter 4). The array usually wins even at the job the linked list was designed for, except in the few places where the program already holds the node. Those places turn out to be some of the most important code in any system.

Nodes and Pointers

Each element sits in a separately allocated node (Chapter 4) with a pointer to the next one. A doubly linked node adds a pointer to the previous one, which lets the list be walked backwards and lets a node be removed without finding its predecessor first. Inserting or deleting at a node you hold is O(1). Reaching position i from the head is O(i), one hop at a time.

Every node also carries overhead. On top of the element itself there are one or two 8-byte pointers and the allocator's own header, and in Python the node is a full object: a minimal node class with two fields takes 48 bytes on CPython 3.15, before counting the value it holds.

Nodes scattered in memory, and an insertion that moves nothing
three nodes scattered across memory, each pointing to the nextACBinserting N after A: two pointer writes, nothing movesANBCold link from A to B, replaced

The Price of a Hop

The address of the next node is stored inside the current node. The processor cannot start fetching node k plus one until node k has arrived, and the prefetcher has no steady stride to follow (Chapter 4). So the misses happen one after another, never overlapped. A million-node traversal in which every hop misses the cache waits about a million times 100 nanoseconds, about 100 milliseconds. A million 8-byte values in an array stream through in about a millisecond.

Python shows the same shape. On CPython 3.15, walking a million nodes linked in the order they were allocated took 32 milliseconds. Linking the very same nodes in random order, so that each hop lands somewhere unpredictable, made the walk take 156 milliseconds, about five times longer. Summing a list of a million integers took 3.2 milliseconds.

Insert in the Middle, Search Included

To insert at a position, you first have to find it. The linked list walks there hop by hop, paying the ladder at every step. The array finds the position by index or by binary search (Chapter 7), then shifts its tail with one block move that the hardware performs at full bandwidth.

For "find the place, then insert", the array wins at the sizes people usually test, often by a wide margin. Bjarne Stroustrup, the designer of C++, made the result famous in a 2012 keynote by timing vectors, C++'s dynamic arrays, against linked lists on this task: the vector won across the sizes he showed, because the search dominates and the search is where the list is slowest.

Where Linked Lists Win

A linked list wins when the program already holds the node. The classic case is a least-recently-used cache. It keeps a hash table (see Hash Tables) from each key to that key's node, and a doubly linked list of the nodes in order of last use. A hit finds the node through the table, unlinks it and relinks it at the head, all in O(1). Eviction removes the node at the tail, also O(1).

An LRU cache: a hash table pointing into a recency list
hash table: key to noderecency list: most recent firstk2k4k1k3k2k4k1k3headtail: evicted nexta hit on k1: unlink it, relink it at the head

CPython's own least-recently-used cache decorator and its ordered dictionary are both built this way in C, and allocators thread their free blocks into lists (Chapter 4). The other property that matters is stability: a node never moves, so a pointer to it stays valid for the node's whole life. An array cannot promise that, because growing it moves every element.

Intrusive Lists in Kernels

The Linux kernel embeds the link fields inside the objects themselves instead of wrapping each object in a separate node. One object can then sit on several lists at once, a process on the list of all processes and on its parent's list of children for example, with no extra allocation, and it can be removed from any of them in O(1).

The objects exist anyway, so the usual price of a separate node disappears. That is why kernels use linked lists heavily, in places where application code should not: the kernel always holds the node, and it never has to search the list to find it.

Linked Lists in a Python Service

In Python, a hand-written linked list pays for an object per node, dozens of bytes, and an interpreted step per hop (Chapter 13). It loses to the built-in list and to the deque almost every time. The linked lists worth having in a Python service are the ones already inside the ordered dictionary, the least-recently-used cache and the deque.

The memory bill is as lopsided. On CPython 3.15, a minimal node object takes 48 bytes, so a million nodes cost 48 megabytes before counting the values they hold, where a list of the same million values needs 8 megabytes of pointers. The nodes are also scattered wherever the allocator put them, which is the price of a hop all over again.

Writing a new one is justified by one thing only: an O(1) removal or move of a node you already hold, which nothing in the standard library provides for your case. Anywhere else, the cost is memory and cache misses for a benefit the program never uses.

Misconceptions
  • "A linked list is faster for inserts." Only at a node already in hand. Finding the position costs a hop per element, and each hop can be a trip to RAM.
  • "Linked lists save memory because they never over-allocate." A doubly linked node for an 8-byte element carries two 8-byte pointers and an allocator header, several times the payload, while an array's slack is bounded by its growth factor.
  • "A Python list is a linked list." It is a dynamic array (see Arrays and Dynamic Arrays). The name misleads engineers who arrive from Lisp, or from Java's LinkedList.
  • "Removing an element from a linked list is O(1)." Removing a node you hold is. Removing by value is an O(n) search first, and a singly linked list also needs the node before it.
  • "Linked lists are obsolete." Every LRU cache, every kernel wait queue, every allocator's free list and every chained hash bucket is one.
Why It Matters
  • Default to an array-backed structure, and choose a linked list only for O(1) removal or reordering of nodes you already hold. Everywhere else the hops cost more than the shifts they avoid.
  • Use the library's LRU cache and ordered dictionary instead of writing the list. The linked list you would write is already inside them, in C.
  • Pair a hash table with a linked list when you need both lookup by key and O(1) reordering. Each structure covers what the other cannot.
  • Count hops, not operations, when estimating a linked structure's cost. Each hop is a potential trip down the ladder (Chapter 4).
RelatedSkip lists linked lists with express lanes for O(log n) search; Redis sorted sets use oneUnrolled linked lists each node holds a block of elements, the design of CPython's deque (see Stacks, Queues and Ring Buffers)Linked trees pay the same hop cost at every level (Chapter 6)

Knowledge Check

A program must find the right position in a sorted sequence of 100,000 items and insert a new item there. Why does a dynamic array often beat a linked list?

  • The array never has to move any elements when an item is inserted
  • Finding the spot costs the list a hop per element; the array searches fast
  • Linked lists cannot hold sorted data, so they must re-sort after each insert
  • The array's insert is O(1), and the linked list's insert is O(n log n)

An LRU cache is built from a hash table and a doubly linked list. What does each structure contribute?

  • The table keeps keys in order of use; the list finds a key's value
  • The table stores the values; the list stores a copy of every key for safety
  • The table finds a key's node; the list keeps nodes in order of last use
  • Both store the same data twice, so that a lookup can read whichever is faster

Why can the hardware prefetcher not help a walk along a linked list?

  • Linked lists live in a region of memory that is never cached
  • The prefetcher only works on data written by the operating system
  • Linked lists are read backwards, and prefetchers only read forwards
  • The next address is inside a node that has not been fetched yet

Roughly how much memory does a doubly linked list use per 8-byte element, compared with a dynamic array?

  • Several times as much: two pointers and a header per node
  • The same, because both store each element exactly once
  • Less, because a linked list never keeps any spare capacity
  • Half as much, because nodes can be packed into the same line

Which removal from a doubly linked list is O(1)?

  • Removing the first node whose value equals x
  • Removing the element at position n / 2
  • Removing a node the program already holds
  • Removing every node whose value is even

You got correct