◈ python mapVol 4 · Ch 50/62
Volume 4 Python, from the metal up · chapter 50

50The linked list — data joined by pointers

In Chapter 49 we built the array: one contiguous block, where order is nothing but adjacency. Element 5 sits right after element 4, and "next" means a few bytes further along. The linked list is its exact opposite, and I want to take the trade slowly, because it's the cleanest example in the whole volume of buying one power by selling another. Here's the plan. We scatter each element into its own little object on the heap, a node, and we hand every node a pointer to where the next one lives. Order stops being about addresses and starts being about arrows. The whole way through we keep asking the one question that decides everything: if the sequence lives in the pointers instead of the layout, what does that make almost free, and what does it make ruinously expensive? By the end you'll know why splicing into a linked list is O(1) when you already hold the spot, and why finding that spot is O(n). You'll see why a scattered list crawls even though it's "the same" O(n) as an array — and when that trade is exactly the one you want.

iolinked · chapter 50 — the checkpoints6 steps
$ sections covered in The linked list — data joined by pointers
01Order without neighbours
02The pointer tax
03Insertion is two pointers — given the node
04…but finding it is a walk
05Same O(n), wildly different speed
06Two-way streets, and where this really lives

01Order without neighbours

Let's start with how an array keeps order, because the linked list is defined entirely by rejecting it. An array keeps order by adjacency. Element 5 sits right after element 4 in one block, so "next" just means "8 bytes further along" (Volume 1, chapter 8). A linked list throws that rule out. It scatters its elements anywhere the heap has room. Each element carries a note saying where the next one is. That note is a reference, the same 8-byte machine pointer that names use in Volume 1. The container remembers just one thing: a head reference to the first node. Follow the notes and the sequence unspools, even though no two nodes need sit anywhere near each other in memory.

Read that slowly, because it's the whole idea and everything else in the chapter falls straight out of it: logical order lives in the pointers, not in the addresses. Two nodes that are "adjacent" in your playlist can sit on opposite ends of RAM. That one decision buys the linked list its superpower. You can rearrange the sequence without moving a single byte of data. It also saddles the list with its curse. You can't jump to the middle, because there's no address to compute. Let's watch it happen in memory first.

★ YOU ALREADY RUN THIS · the-treasure-huntevery clue knows one thing only: where the next clue is
You have run a treasure hunt for a seven-year-old. The first clue is taped to the fridge and it says exactly one thing: look under the blue chair. Under the chair is the next clue, which names the next place, and on it goes to the prize in the shoe cupboard. Nobody in that game can skip to clue seven. There is no map and no list of hiding places — the order lives entirely in the clues, handed over one at a time. But that is also why you could change the hunt thirty seconds before the kids arrived: to add a stop, you rewrote one clue and taped it to a new card. Nothing else in the house had to move.
reach clue 7 — walk clues 1 through 6node_at(head, 6) · k hops · O(n)
the clues are hidden all over the housenodes scattered on the heap — no base + i×stride to compute
adding a stop mid-hunt — rewrite ONE cluex.nxt = a.nxt; a.nxt = x · O(1)
every clue costs a card and a strip of tapea 48 B node against an 8 B slot — the pointer tax
you are already standing at the blue chairhold the handle, and the splice is free
pin it: the array computes where THU is; the chain can only ever be told what comes next — so reaching clue 7 costs seven steps, and changing the hunt costs one card.

Before we count bytes, hold one node in your hand and picture a playlist of three songs: Levels → Wake Me Up → Titanium. Each node is a tiny two-cell box. The first cell holds a reference to its value, the song itself, and the second holds a reference to the next node, the arrow. The Levels node doesn't contain Wake Me Up inside it — it just knows the address where that node lives. The figure below lays the three boxes out at unrelated addresses, and the only thing stitching them into an order is those arrows. Trace them once with your finger before we start pricing them.

head a reference node @ 0x…c0 value next node @ 0x…14 value next node @ 0x…9c value next None ⏚ "Levels" "Wake Me Up" "Titanium" Blue = a reference to the next node · amber = a reference to the value · the arrows, not the addresses, hold the order.
Fig — A singly linked list of three songs. Each node is a two-cell box: a reference to its value and a reference to the next node. The nodes sit at unrelated addresses (…c0, …14, …9c); the sequence exists only because the blue arrows chain them, ending at None.

If order is just arrows, rearranging the playlist should be almost free — no block to shift. Let's cash that in. But first: what does one node actually cost? →

02The pointer tax

Nothing is free, and the linked list's bill comes due in bytes. In an array (a Python list), each element is stored as one bare 8-byte reference, packed shoulder-to-shoulder in a shared block. The object header is paid once for the whole list. A linked list can't do that. Every element is its own heap object, so every element pays the full Python object tax again. I measured a minimal node on CPython 3.12. It's a class with __slots__, so it carries no per-instance dictionary:

cost.pypython
import sys

class Node:
    __slots__ = ('value', 'next')      # no per-instance __dict__
    def __init__(self, value):
        self.value = value             # a reference to the song
        self.next  = None              # a reference to the next node (or None)

a = Node("Levels"); b = Node("Wake Me Up"); c = Node("Titanium")
a.next = b; b.next = c                  # a -> b -> c -> None
print(sys.getsizeof(a))                 # 48   bytes per node

48 bytes. The node holds only two useful references, which is 16 bytes of actual pointers. Those sit wrapped in 32 bytes of Python machinery: the reference count, the type pointer, and the garbage-collector bookkeeping every tracked object carries (Volume 1, chapter 4). So to store one element's worth of sequence, an array spends 8 bytes and a linked list spends 48. That's a 6× memory tax, 40 extra bytes on every single node. That surcharge is what buys the flexible arrows, and it is never free.

Break that 32-byte header open and the tax is easy to audit. Eight bytes hold the reference count, eight more hold the type pointer that says "I'm a Node", and the last sixteen are garbage-collector bookkeeping: 8 + 8 + 16 = 32, and the two useful pointers then push that to 48. And here's the quiet twist. Those 16 GC bytes are themselves two 8-byte pointers, threading this node onto a hidden doubly linked list that the collector keeps — a linked list riding inside every node of your linked list. Hold that thought; we cash it in at the end of the chapter.

Let's make that surcharge concrete at scale, because 40 bytes sounds tiny until you multiply. Say you store a million songs. The array packs them as a million bare references: 1,000,000 × 8 bytes = 8 MB, one clean block. The linked list pays the full node price on each one: 1,000,000 × 48 bytes = 48 MB. Same million songs, and the linked version has eaten an extra 40 MB of RAM to hold nothing but reference counts, type pointers, and arrows. The tax didn't stay small. It grew in lockstep with your data, exactly as "per element" promised.

The tax is per element, so it never stays small. It scales with your data. Drive the count up and watch the same numbers cost three wildly different amounts of memory, decided by nothing but how you wrap them. Watch, too, where each layout spills out of cache.

InteractiveStore a million numbers — pay the pointer tax
the same N numbers, stored three ways — what does the sequence itself cost? N = 1,000,000 numbers values are shared by all three — this is the container's own plumbing array 8 B / elem 8.0 MB · fits L3 ✓ slotted node 48 B / elem 48 MB · spills ✗ un-slotted node 344 B / elem · no __slots__ 344 MB · spills ✗ UN-SLOTTED LIST · PLUMBING FOR THE SAME NUMBERS 344 MB = 43× the array's 8.0 MB — for identical data At 1,000,000 numbers the array's links still fit a 32 MB L3 cache; the un-slotted nodes miss it by 11×.
1,000,000
Slide from a thousand to ten million. The array keeps its sequence in 8 bytes/element; a slotted node pays 48, an un-slotted node 344 — a 43× blow-up for the identical numbers, almost all of it Python bookkeeping the CPU never reads. Notice who still fits a 32 MB cache and who doesn't: layout alone decides.
one linked-list node — 48 bytes object header · 32 B value ref8 B next ref8 B 40 B tax one array slot — 8 bytes value ref8 B …header paid once for the whole block
Fig — The pointer tax. A node spends 48 bytes to carry the same 8-byte reference an array slot holds bare — 40 bytes of per-element overhead (a 32-byte header plus the extra next pointer). Multiply by a million nodes and it's real memory.
⚠ MOST BEGINNERS THINK…the chain is a museum piece now
This is the fair conclusion to draw from the bill we have just added up, and almost everyone draws it. Python's list is C underneath: it indexes in one multiply-add, it appends for free, it hands the CPU eight elements per cache line, and it will insert anywhere you like with a single method call — while the chain wants 48 bytes to hold what the block holds in 8. Whatever the chain does, the block seems to do faster and lighter. So the linked list becomes a thing you learn about, the way you learn about punched cards: a structure kept in the syllabus for historical reasons, because list already does everything it does.
TYPE THIS — 10 SECONDS
import time
n, reps = 200_000, 20_000

class Node:
    __slots__ = ('val', 'nxt')
    def __init__(self, val, nxt=None): self.val, self.nxt = val, nxt

L = list(range(n))                            # one contiguous block
head = cur = Node(0)                          # one chain of the same length
for v in range(1, n):
    cur.nxt = Node(v); cur = cur.nxt
held = head
for _ in range(n // 2): held = held.nxt       # walk ONCE, then keep the handle

t = time.perf_counter()
for _ in range(reps): L.insert(n // 2, -1)          # every call slides the tail
print("list.insert(mid)  :", round((time.perf_counter() - t) * 1000, 1), "ms")

t = time.perf_counter()
for _ in range(reps): held.nxt = Node(-1, held.nxt) # two writes, nothing slides
print("splice at held    :", round((time.perf_counter() - t) * 1000, 1), "ms")
list.insert(mid)  : 373.3 ms
splice at held    : 4.1 ms
Both halves of the belief are wrong, and one run separates them. The chain wins one contest outright, and it wins it by a landslide — an edit at a node you are already holding: twenty thousand inserts into the middle cost the list 373.3 ms of sliding and cost the chain 4.1 ms of pointer writes, about 91×. (Section 03 takes that two-line splice apart properly, including the write order that makes or ruins it; here we only wanted its price tag.) Now two labelled refinements, so this does not become a fresh half-truth pointing the other way. First, the honest loss. Take the handle away — make each insert find its node by value first — and the chain flips from 91× faster to roughly 8× slower: 4,850 µs per find-and-splice against 600 µs for L.index() plus L.insert() on the same data, and 7× to 9× across repeat runs. Both of those are O(n); the array's O(n) is a C loop down one block, while the chain's is interpreted pointer-chasing across scattered addresses — the same effect section 05 puts on a clock. Second, 91× is not the number to keep — the shape is. A ratio measured at one length means nothing, so we moved the length: at 50,000 / 200,000 / 800,000 nodes list.insert climbed 107 → 378 → 1,669 ms, in step with n, while the splice column refused to climb at all — 3.9 → 10.2 → 4.1 ms, wobbling with allocator weather rather than with length. That flat line is the whole claim. The digits belong to this machine; the shape does not. The chain is neither obsolete nor a default: it is the thing you reach for when something else is already holding the spot you mean to edit.
THE STDLIB TOOLBELT · the dataclass nodetwo fields and one arrow — the whole linked list, in five lines you would actually type
from dataclasses import dataclass @dataclass(slots=True) # slots=True -> 48 B/node, no per-instance __dict__ class Node: val: int nxt: "Node | None" = None # quoted: the name Node isn't bound yet head = Node(value, Node(value)) # build it inside-out, tail first while cur is not None: # the only route through a chain cur = cur.nxt
@dataclassWrites __init__, __repr__ and __eq__ for you — exactly the boilerplate a node needs, and none of the typing. This is the reason hand-rolling a node in modern Python is five lines, not fifteen.
slots=TruePython 3.10+. Keeps the node at the 48 bytes section 02 metered. Leave it off and the same node costs 344 — measured on this machine — because it grows a __dict__. Not optional.
"Node | None"A quoted forward annotation. Inside the class body the name Node does not exist yet, so the string is the only legal way to say “another one of me, or the end”.
= NoneThe default that makes Node(3) a legal tail. None is the chain's terminator — the thing the walk tests for, and the thing the last arrow points at.
headThe container's one handle. Everything else is reached through it, and the moment you lose it every node behind it becomes unreachable and is collected.
field(default_factory=list)The escape hatch if a field ever needs a mutable default. A bare = [] would be one list shared by every node you ever make — @dataclass refuses it outright, and rightly.
INPUTimport sys
from dataclasses import dataclass

@dataclass(slots=True)
class Node:
    val: int
    nxt: "Node | None" = None

head = Node(1, Node(2, Node(3)))     # build it inside-out

cur, total, hops = head, 0, 0
while cur is not None:               # the ONLY way through a chain
    total += cur.val
    hops += 1
    cur = cur.nxt
print("summed", total, "in", hops, "hops")
print("one node  :", sys.getsizeof(head), "bytes  (slots=True)")
print("repr(head):", repr(head))
OUTPUTsummed 6 in 3 hops
one node  : 48 bytes  (slots=True)
repr(head): Node(val=1, nxt=Node(val=2, nxt=Node(val=3, nxt=None)))
TRIPWIRES
  • That free __repr__ is recursive. It is charming on three nodes, as above, and on a 2,000-node chain repr(head) raises RecursionError: maximum recursion depth exceeded — run here to be sure. Print [n.val for n in walk(head)], never the head itself.
  • The generated __eq__ recurses too. Two structurally equal 2,000-node chains and a == b raises the same RecursionError. When you mean “the same node”, write a is b.
  • End the walk with cur is None, never cur == None. == calls that generated __eq__ on every single hop: the identical 2,000-node walk measured 21.0 µs with is not None against 224.7 µs with != None — 11×, for one wrong operator.
Why 32 bytes of header, when Volume 1 said ~28?
Volume 1's "~28-byte header" was measured on a small int (sys.getsizeof(5) is 28) — that figure folds in the int's own digit storage. Our node has no payload of its own beyond two references, so it shows the header alone: 16 bytes of core object (the reference count and the type pointer) plus a 16-byte garbage-collector head that every container-like object drags along so the cycle collector can find it. That's 32 bytes of pure Python bookkeeping before a single useful pointer. Both numbers are honest; they're measuring different objects.
The 344-byte node — a tax you can accidentally 7×
Drop the __slots__ line and every node grows a __dict__ to hold its attributes. I measured it: the same node balloons from 48 bytes to 344sys.getsizeof(fn) + sys.getsizeof(fn.__dict__). Seven times heavier, for identical data. If you ever hand-roll a node-based structure in Python, __slots__ is not optional; it's the difference between a lean list and one that evicts your working set from cache. Most of what's "slow about linked lists in Python" is really the un-slotted node.

So a node is heavy but its arrows are flexible. Now spend that flexibility — insert a song mid-playlist and count what actually moves. →

03Insertion is two pointers — given the node

Here's the linked list's marquee trick. To insert a new node X between two existing nodes A and B, you do exactly two things: point X.next at B, then point A.next at X. Two reference writes. Nothing else in the entire structure is read or moved, not the million nodes before A, not the million after B. Compare an array, where inserting at position i must shift every element from i onward one slot down to open a gap (Volume 1, chapter 8). That's O(n) copies. The linked list splice is O(1), and here's the beautiful part. It stays O(1) no matter where in the sequence you splice, because the cost doesn't depend on position at all. Order is arrows, and you're just re-drawing two of them.

One quiet detail decides whether that splice works or corrupts the list: the order of the two writes. You must set X.next to B first, while A.next still points at B and can tell you where B is. Then, and only then, set A.next to X. Do it the other way round and you overwrite A.next before you've read it. Now B's address is gone, and the whole tail of the list after A has fallen off into the void. Same two writes, opposite outcome. When you re-wire pointers, the sequence you read and overwrite them in is part of the algorithm, not a detail.

Let's put addresses on it so the ordering isn't abstract. Say node A lives at address 200, B at 350, and the new node X at 500. Right now A.next holds 350, pointing straight at B. First we set X.next = 350, so X now knows where B is. Only then do we set A.next = 500, so A points at X instead. Read the chain from A and you get 200 → 500 → 350, exactly A → X → B. Reverse the two writes and A.next becomes 500 before anyone saved 350 — so B's address is lost, and everything past it leaks away.

A B C old A.next (discarded) B.next — untouched X (new) ① A.next = X ② X.next = B Two writes. Zero elements moved. And it's the same two writes whether the list holds 3 nodes or 3 million.
Fig — A splice rewires exactly two pointers: A.next = X and X.next = B. The old A→B arrow is simply dropped. Constant work, independent of list length or position.

The widget below makes it literal. Slide to pick where the new node goes, and watch what each side has to do. The linked list always writes exactly two pointers, no matter where you drop the node. The array beside it must shift a different number of elements depending on where you cut. And the further left you insert, the more it copies.

InteractiveSplice a node in — watch exactly two pointers rewire
2
The linked list writes 2 pointers at every gap — O(1). The array's shift count changes with position — O(n). Same edit, different cost.
↺ The thing people get backwards
"Insertion in a linked list is O(1)" is the most over-quoted half-truth in data structures. It's only O(1) once you already hold the node to splice at — handed to you by an iterator, a hash-map entry, or an LRU handle. If you have to find that spot first — "insert after the node whose value is Titanium", or "insert at index 500" — you pay O(n) to walk there, and the walk dominates. So mid-list insertion, start to finish, is O(n) for a linked list too. The linked list doesn't make insertion cheap; it makes it cheap given a reference to the location. The whole game is arranging for something else to already own that reference.

Which raises the obvious question: if I only have an index — "give me the 500th song" — how does a linked list find it? →

04…but finding it is a walk

Ask an array for element 500 and it does one multiplication: base + 500 × 8. Jump straight there, done. That's O(1), the reward for living in one block (Volume 1, chapter 8). A linked list can offer nothing like it. There is no base address and no arithmetic, because the nodes are scattered and only the arrows know the order. To reach index k you must start at head and follow next k times, node by node. Reaching the 500th node means visiting the other 500 first. That's O(n), baked into the layout, the price of trading adjacency for arrows.

I measured the gap on a million-node list. Walking to the middle took about 9 milliseconds; the array's lst[k] took about 0.1 microseconds — the O(n) walk versus the O(1) address computation. Most of those 9 ms is simply the half-million pointer hops the walk is forced to make; the array never hops at all. (Wall-clock numbers vary by machine; the O(n)-vs-O(1) shape does not.)

Put a clock on a single hop and the O(n) gets vivid. Nine milliseconds is 9,000,000 nanoseconds, spread across the half-million hops the walk makes: 9,000,000 / 500,000 ≈ 18 nanoseconds per hop. That's the cost of one link followed — read the node, grab its next, move on. The array pays this zero times: it computes an address and lands in a single shot. So the linked list's bill for reaching the middle isn't one slow step, it's half a million ordinary steps with nothing to skip them. There is no arithmetic shortcut when order lives in the arrows.

Here's the arithmetic behind that walk. Reaching index k costs exactly k hops, so index 0 is free and the last index costs the whole list. Average that over every position and a random lookup runs about n/2 hops. On a million-node list that's 500,000 pointer hops for one index, every single time. An array answers any of them with one multiplication. That distance between 500,000 and 1 is what O(n) versus O(1) means in hardware, not on paper.

linked list: walk from head, hop by hop 0 1 2 k hop · hop · hop … k times → O(n) array: one multiply-and-add base + k×8 → O(1)
Fig — The mirror image of insertion. The array indexes in one arithmetic step; the linked list must walk k hops from the head because only the arrows encode position. Great at splicing, slow at seeking.

Drive the walk yourself. Slide the target index and watch the current pointer crawl out from the head, one hop per node. Count them as they go, because the hop count is the index — that equality is the whole cost of a linked-list seek.

InteractiveWalk to index k — count the hops
0
Reaching index k costs k hops from the head — an array would reach it in zero. This is why random access on a linked list is O(n).

Myth

"Linked lists beat arrays because you never shift elements — so use them when you insert a lot."

Reality

Only if you already hold the node. If you insert "after the item I'm looking at while iterating", the linked list wins. If you insert "at index i" or "after the item with value v", you pay O(n) to find the spot. And then the array's fast index and cache locality usually make it the better choice anyway.

There's a sharper version of that trap, and it catches people constantly: even appending to the end of a plain singly linked list is O(n). Think it through — the container holds only head. To reach the last node so you can hang a new one off it, you have to walk every link from the front. The splice itself is still two writes, but getting to the tail is a full traversal. The fix is to cache a second reference, a tail pointer, updated on every append. Then the end is one hop away and the append is genuinely O(1). It's the same lesson underneath: a linked list is only fast where you already hold the handle, so real implementations keep handles to the spots they touch.

So when do you genuinely already hold the node? More often than the myth suggests. Think of a music player showing your queue, where you drag Titanium up two spots. The interface is already pointing at that exact node, so the move is a splice, not a search. The same is true inside an LRU cache, where every entry is a node you reached through a hash lookup, and you re-order it on each hit. In both cases the find is free, because something else already handed you the handle. That is the shape of the problem where a linked list quietly wins.

One more place the handle comes for free: a running iterator. When you loop through a linked list and decide to insert or drop as you go, you're already standing on the node. The loop variable is the handle. That's why merging two sorted linked lists splices in O(1) per step: the walk that visits each node also hands you the exact spot to re-wire. The find isn't a separate cost. It's the traversal you were doing anyway.

NOW TURN THE ARROWS AROUNDreverse the chain with three names — then prove that not one value moved
The drill. Write reverse(head) for the dataclass Node using exactly three names: prev, cur and nxt. Walk the chain once, turn each arrow to point at the node behind it, and return the new head.

The rule that makes it work is section 03's write-order lesson, running backwards: save cur.nxt into nxt before you overwrite cur.nxt, or the entire tail falls off into the void on the very first turn of the loop.

Then prove the claim. This chapter says reversal moves no data, only arrows. Don't take it on trust — collect id(n) for every node walking the chain before, and again after. If it is true, the after-list is the before-list backwards, and the count of nodes created during the reversal is exactly zero.
show the solution
from dataclasses import dataclass

@dataclass(slots=True)
class Node:
    val: str
    nxt: "Node | None" = None

def walk(head):                       # yields the nodes themselves, in order
    cur = head
    while cur is not None:
        yield cur
        cur = cur.nxt

def reverse(head):
    prev, cur = None, head
    while cur is not None:
        nxt = cur.nxt        # 1. remember the way forward, or you lose the tail
        cur.nxt = prev       # 2. turn this arrow around
        prev, cur = cur, nxt # 3. shuffle both walkers one node along
    return prev              # cur fell off the end; prev is the new head

head = Node("Levels", Node("Wake Me Up", Node("Titanium")))
before_ids = [id(n) for n in walk(head)]
print("before:", [n.val for n in walk(head)])

head = reverse(head)
after_ids = [id(n) for n in walk(head)]
print("after :", [n.val for n in walk(head)])

print("same node objects, reversed order:", after_ids == before_ids[::-1])
print("nodes created during reverse     :", len(set(after_ids) - set(before_ids)))

# before: ['Levels', 'Wake Me Up', 'Titanium']
# after : ['Titanium', 'Wake Me Up', 'Levels']
# same node objects, reversed order: True
# nodes created during reverse     : 0

# READ THE LAST TWO LINES. Every id() came back, in the opposite order, and
# nothing new was allocated. The three song strings never moved, the three
# nodes never moved -- three assignments to .nxt reversed the whole sequence.
# An array reversal has to write every slot. This wrote the arrows and left
# the data exactly where the allocator first put it.
# The one line that carries the whole algorithm is nxt = cur.nxt. Delete it
# and cur.nxt = prev overwrites the only pointer to the rest of the list,
# so the loop ends after one node and the tail is unreachable forever.

O(n) to walk, O(n) to scan — the same big-O as an array. So a full pass over a linked list and a full pass over an array should take the same time, right? Time them and prepare for a shock. →

05Same O(n), wildly different speed

This is the section that separates people who know Big-O from people who know the machine. Summing a linked list and summing an array are both O(n): n additions, one per element. Big-O says they should scale identically, and they do. But wall-clock time is a different story, and the reason is the memory hierarchy from Volume 3. The CPU never fetches one value from RAM. It fetches a whole cache line, 64 bytes, eight references' worth, and keeps it in fast on-chip memory. An array is one contiguous block, so a single fetch pulls in the next eight elements for free. The scan streams: fetch a line, use eight, fetch the next. This is called spatial locality, and hardware is built to reward it.

A linked list has none of it. Its nodes are scattered wherever the allocator found room, so following next lands you at an unrelated address every time. Each hop is a fresh cache miss: the CPU stalls, waits a hundred-odd cycles for RAM, and pulls a 64-byte line only to use 8 bytes of it. Then it hops somewhere else and pays the whole price over again. This is pointer-chasing, and it's the linked list's hidden tax. I measured a two-million-element sum three ways:

cache.pypython
array sum (contiguous)                     49 ms   (24 ns/elem)
linked list sum (nodes in alloc order)     55 ms   (28 ns/elem)
linked list sum (nodes shuffled in RAM)   407 ms  (204 ns/elem)

Same n, same additions, same O(n). Yet the scattered linked list ran roughly 8× slower than the array on that run, and about 6× on a second run. It's a cache effect, so the exact ratio drifts with the machine and memory state. But it's always a large constant, never 1×. Look closely at the middle row. A linked list whose nodes happen to be allocated in order is nearly as fast as the array, because consecutive nodes land near each other and the scan stays cache-friendly. It's the scattering that kills it, not the linking. Two structures, one complexity class, an order-of-magnitude difference in the only number the user feels.

Here's the same story counted in fetches, so the 8× stops feeling like magic. A cache line is 64 bytes, which is 64 / 8 = 8 references. To sum two million array elements, the CPU streams the block in 2,000,000 / 8 = 250,000 line fetches, and every fetch delivers eight useful values. The scattered linked list can't share a line between nodes, so it pays up to 2,000,000 fetches, one per hop, and throws away 56 of every 64 bytes it drags in. Roughly eight times the memory traffic for the identical arithmetic. That ratio is the 8× you measured, and it was hiding in the geometry all along.

array: one 64-byte line = 8 useful refs r r r r r r r r 1 miss → 8 used ✓ scattered list: one line = one node, 7/8 wasted nodewasted nodewasted nodewasted 1 miss per hop → the CPU stalls, over and over
Fig — Why the same O(n) diverges. The array's contiguous block turns one memory fetch into eight useful elements; the scattered list burns a whole cache-line fetch per node and stalls on every hop. Cache locality is the difference between 24 ns and 200 ns per element.
Big-O hides the constant that ate your afternoon
Two algorithms with the same Big-O can differ by 10× in practice, and cache locality is the usual culprit. This is exactly why Python's own list and deque — both built on contiguous blocks of references — beat a hand-rolled node-per-item linked list for almost every real workload, even the ones textbooks say "favour" linked lists. When you profile and two "equally fast" options aren't, suspect the memory layout before you suspect the algorithm.

If it's heavier and slower to scan, why does the linked idea sit at the heart of some of the most-used software on your machine? Because there's a job only it can do. →

06Two-way streets, and where this really lives

Add one more pointer to each node, a prev reference back to the previous node, and you get a doubly linked list. Now you can walk either direction. More importantly, you can delete a node in place when you're standing on it. Reach both neighbours through prev and next, wire them to each other, and the node is gone in O(1). There's no need to have walked from the head to find the one before it. My measurement puts a doubly linked node at 56 bytes versus the singly node's 48: one extra 8-byte pointer, the fee for two-way travel. Link the tail's next back to the head and it becomes circular, no beginning, no end, useful for round-robins.

Picture three nodes A ⇄ B ⇄ C and say you want B gone. Standing on B, you read its two neighbours: B.prev is A, B.next is C. Two writes finish the job — set A.next = C, then C.prev = A. Now A and C point straight at each other, B is unreachable, and the reference count that kept it alive falls to zero. No walk from the head, no shifting of neighbours: O(1), decided entirely by the two pointers you were already holding.

Watch the reference count do the cleanup for you — this is where Volume 1's memory model pays off. While B sat in the chain, two live pointers from inside the list named it: A.next and C.prev. So its structural refcount was 2. The moment you rewire both — A.next = C, then C.prev = A — those two references vanish, and the count drops 2 → 1 → 0. Once your own local handle on B is gone too, CPython reclaims its 56 bytes on the spot (Volume 1, chapter 4); you never call anything like free. Unlinking a node and freeing its memory are the same event, and both are O(1).

Where does a two-way or circular list actually earn its extra pointer? Wherever you need to step backward or loop forever. A media player's previous-track button wants prev, so pressing it is one hop, not a walk from the start. An operating system's run queue of ready processes is often circular. The scheduler hands each one a time slice, then follows next to the following process, and the tail loops back to the head so the rotation never ends. No beginning, no end, no special case for "wrap around". The extra 8 bytes per node buys exactly that freedom of movement.

WATCH AN EXPERT THINKdoes this chain ever bite its own tail?
You are handed exactly one thing: head, the first node of a chain somebody else built and has been splicing all night — a free list inside an allocator, a queue threaded through device buffers, a run queue like the one above. That run queue closes its ring on purpose; the ring you are hunting here is the other kind. Answer one question about the chain in your hand: does following next ever come back to a node you have already stood on — a ring nobody meant to close, left behind by one splice that aimed a tail back into its own chain? And answer it in constant extra memory — whatever the chain's length, your checker may not allocate anything that grows with it. That constraint is not decoration. The reason anyone audits a million-node chain is usually that memory is already the problem, and a checker that allocates a million entries to inspect a million nodes doubles the footprint of the thing it is inspecting.
FIRST INSTINCT
Remember where you have been. Walk the chain, drop a breadcrumb on every node you stand on, and the moment you step on your own breadcrumb there is a loop; fall off the end into None and there is not. Eight lines, correct on the first try, and genuinely the right code when the chain is small. One detail bites in the first thirty seconds: you cannot put the nodes themselves into a set. @dataclass generates __eq__, which sets __hash__ = None, so {node} raises TypeError: unhashable type: 'Node'. You store id(node) instead — which is the identity you meant all along, since two structurally equal nodes are not the same node.
WHY IT STALLS
The breadcrumbs are the cost. On the million-node chain below that set peaks at 70.5 MB of extra memory — measured, not estimated — against the 48 MB its million nodes occupy at section 02's prices. To find out whether a structure loops, we spent more memory on the question than the nodes themselves take up. Inside an allocator, on a device, or in a watchdog that must not disturb the heap it is watching, that budget is simply not there, and the honest reply to “just use more RAM” is that RAM is the thing under audit. And notice what those bytes hold: a second copy of information the chain already carries. The arrows know the shape. We are transcribing it.
THE TELL
Stop trying to remember where you have been, and look at what a loop is: a track with no exit. Put two names on the chain, slow and fast — one steps one node per turn, the other two. If there is no loop, fast runs out of chain and falls off the end — the structure answers the question for you, for free. If there is a loop, both names end up inside it and neither can ever leave; and from that turn on the gap between them closes by exactly one node per step, because fast gains one node a turn. A gap that shrinks by one can never step over zero. So they must land on the same node, within one lap. On a closed track the faster of the two eventually catches the slower; on a road that ends, there is nothing to catch. Two names, no allocation, and the answer is the same either way.
from dataclasses import dataclass
import time, tracemalloc

@dataclass(slots=True)
class Node:
    val: int
    nxt: "Node | None" = None

def chain(n, joins_at):          # n nodes; the tail links back to index joins_at (-1 = ends at None)
    ns = [Node(i) for i in range(n)]
    for i in range(n - 1):
        ns[i].nxt = ns[i + 1]
    if joins_at >= 0:
        ns[-1].nxt = ns[joins_at]
    return ns[0]

def has_cycle(head):             # Floyd -- two names, whatever the chain's length
    slow = fast = head
    while fast is not None and fast.nxt is not None:
        slow = slow.nxt          # one hop
        fast = fast.nxt.nxt      # two hops
        if slow is fast:         # fast has caught slow inside the loop
            return True
    return False                 # fast ran off the end: no loop

def where_it_joins(head):        # phase two: WHICH node the loop closes back onto
    slow = fast = head
    while fast is not None and fast.nxt is not None:
        slow, fast = slow.nxt, fast.nxt.nxt
        if slow is fast:         # met somewhere inside the loop
            slow = head          # send slow back to the head; now BOTH step by one
            while slow is not fast:
                slow, fast = slow.nxt, fast.nxt
            return slow          # they meet exactly at the join
    return None

def has_cycle_seen(head):        # the referee: obviously right, and it pays in memory
    seen, cur = set(), head
    while cur is not None:
        if id(cur) in seen:
            return True
        seen.add(id(cur))
        cur = cur.nxt
    return False

N = 1_000_000
looped, straight = chain(N, 600_000), chain(N, -1)

print("looped   chain -> Floyd", has_cycle(looped),   "| referee", has_cycle_seen(looped))
print("straight chain -> Floyd", has_cycle(straight), "| referee", has_cycle_seen(straight))
print("the loop closes back onto node", where_it_joins(looped).val)

for name, fn in (("seen-set", has_cycle_seen), ("Floyd   ", has_cycle)):
    t = time.perf_counter(); fn(straight); ms = (time.perf_counter() - t) * 1000
    tracemalloc.start(); fn(straight)                  # memory measured on its own pass
    peak = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
    print(name, "walked 1,000,000 nodes in", round(ms), "ms, peak extra memory",
          round(peak / 1e6, 1), "MB")
looped   chain -> Floyd True | referee True
straight chain -> Floyd False | referee False
the loop closes back onto node 600000
seen-set walked 1,000,000 nodes in 160 ms, peak extra memory 70.5 MB
Floyd    walked 1,000,000 nodes in 15 ms, peak extra memory 0.0 MB
trigger: reach for a slow name and a fast one when a chain has to be measured or searched and you cannot afford to remember it — here that is 70.5 MB of breadcrumbs against zero on the same million nodes, with the seen-set referee agreeing on both answers. Three honest edges, because this is a tool and not a trophy. The seen-set is still the better code most days: eight lines, survives being read at 2 a.m., and at ten thousand nodes its memory is a rounding error — the two speeds earn their keep when memory is the constraint, not merely when the chain is long. The 10× time win (15 ms against 160) is a side effect, not the argument: both walk O(n) nodes, and Floyd is quicker only because a pointer hop is cheaper than hashing an id. And where_it_joins is a second, separable trick — once they meet, send slow back to head, step both by one, and they meet again exactly at the join, which the run puts at node 600,000, precisely where we closed the loop; why that lands is a short piece of modular arithmetic about two distances, not something the picture makes obvious, and it is worth deriving on paper once instead of memorising. The same two-speed move also finds the middle of a chain in a single pass — slow is standing there when fast falls off the end — which is how you split a chain to merge-sort it. That is the shape to carry: when you cannot store the structure, walk it twice at once.
prev · val · next"Levels" prev · val · next"Wake Me Up" prev · val · next"Titanium" next → ← prev With prev and next, deleting the middle node is O(1) from the node itself — wire its two neighbours together.
Fig — A doubly linked node carries prev and next (56 bytes vs 48). Two-way arrows let you delete a node you're standing on in O(1), without ever walking from the head.

You almost never type class Node in Python. The built-in list and deque (Chapter 53) cover the common needs faster, thanks to their block layout. The linked list's real importance is that the idea is everywhere underneath, doing the one job it owns. That job is rearranging a sequence, or splicing and unsplicing elements, in O(1) when you hold a handle to the spot. It does this with no reallocation, and with node identities that stay valid while the structure changes around them.

Here's the one that surprises people: CPython itself runs on linked lists. Every container object the garbage collector tracks is threaded onto a doubly linked list, and that threading lives inside the 32-byte header we metered back in section 02. The collector walks that list to hunt unreachable cycles, and it splices objects on and off it in O(1) as they're created and freed. So the pointer tax from section 02 wasn't pure overhead — part of it was buying exactly this. You've been leaning on a linked list on every line of Python you've ever run, one level down.

Where you meet this — every day, mostly hidden
An LRU cache — the thing that decides what your OS, your browser, and your database keep in fast memory — is a hash map paired with a doubly linked list: a cache hit splices its node to the front in O(1); the least-recently-used item falls off the back. Python's own functools.lru_cache and OrderedDict are built exactly this way. The Linux kernel threads a circular doubly linked list (list_head) through nearly everything — process tables, scheduler run-queues. A memory allocator tracks free blocks as a linked free list. A blockchain is a backward linked list — each block points to the previous one's hash — and so is a chain of git commits, each pointing at its parent. Even the classic FAT filesystem stored each file as a linked list of disk clusters. You used an LRU cache to load this page.
The one-line tell for reaching for the linked idea
Being good at data structures is mostly matching an access pattern to a layout. Reach for a linked structure when your pattern is: I hold a reference to a position, and I splice or unsplice there repeatedly, and I rarely need to jump to element i. LRU eviction, an event list you thread and re-thread, a free list, adjacency lists in a graph (Chapter 60), the collision chains inside a hash table (Chapter 54) — all that shape. If instead your pattern is "give me element i" or "scan it fast", the contiguous array wins on both counts, and you should feel the pull toward a list or deque.
The deeper cut
The linked list is one of computing's oldest data structures — Newell, Shaw, and Simon built it into IPL around 1955–56, the list-processing language that seeded LISP. The insight was radical for the era: represent a structure's order with explicit links rather than physical adjacency, decoupling logical shape from memory layout. That decoupling is precisely what lets a tree (Chapter 56) or a graph (Chapter 60) exist at all — they're just nodes with several pointers each instead of one. A subtlety worth knowing: real systems often avoid the 48-byte-per-item tax with an intrusive list, where the next/prev links live inside the data object rather than in a separate wrapper node (the kernel's list_head is embedded in each struct). And an unrolled linked list — several elements packed contiguously per node — is the halfway house the deque takes to the extreme in Chapter 53, trading some pointer flexibility back for the cache locality you just watched matter so much.

One more thing only a linked structure gives cheaply: reversal. Flip every node's arrow so it points at the one behind it, and the whole sequence runs backward with no data copied — just pointers turned around. In an array you'd have to move every element to a new slot. Here you touch nothing but the links. Watch it move only pointers:

arp.pypython
def reverse(head):
    prev = None
    cur  = head
    while cur is not None:
        nxt = cur.next     # remember where we were going
        cur.next = prev    # flip this node's arrow backward
        prev = cur         # advance the two walkers
        cur = nxt
    return prev            # prev is the new head

# a -> b -> c  becomes  c -> b -> a
# before: ['Levels', 'Wake Me Up', 'Titanium']
# after : ['Titanium', 'Wake Me Up', 'Levels']

Line by line: prev trails behind and cur leads. Each turn of the loop stashes the forward link in nxt, or we'd lose the rest of the list the instant we overwrite it. Then it flips cur.next to point backward at prev, and shuffles both walkers one step forward. When cur runs off the end, prev is left holding the last node, the new head. I ran it on Levels → Wake Me Up → Titanium and got Titanium → Wake Me Up → Levels. Not a single value object moved in memory. Only the arrows turned. That is the linked list's whole personality in eight lines: cheap to re-wire, and it never touches the data.

Wait —
if splicing at a held node is the linked list's superpower, what if we deliberately only ever touch one end? Restrict it hard enough and you get a structure so useful it runs the call stack, every Undo button, and the Back button in your browser…

The next chapter does exactly that. Take a list, forbid every operation except push and pop at one end, and you get the stack — the simplest container there is, and the one quietly running your program's function calls right now. →

SAY IT BACKthe chapter in five breaths
  1. A linked list keeps its order in arrows, not addresses — every node is a two-cell box holding a reference to its value and a reference to the next node, and the container remembers exactly one thing, head.
  2. That one decision buys the marquee trick: a splice is two pointer writes, the same two whether the chain holds five nodes or five million — and their order is the algorithm, because overwriting a.next before copying it drops the whole tail into the void.
  3. The same decision sells the array's superpower: with no address to compute, reaching index k costs k hops — the hop count is the index — so walking to the middle of a million nodes measured ~9 ms against the array's ~0.1 µs, and even appending to the end is O(n) until you keep a tail handle.
  4. The bill arrives in bytes: 48 per node against 8 per array slot, a 6× tax charged per element and never waived — and leave off __slots__ and that same node costs 344.
  5. Same O(n), different machine: scattering costs cache lines, so a shuffled chain scanned roughly 8× slower than the identical block while a chain allocated in order nearly matched it — and the linked idea is underneath your LRU cache, the kernel's run queue, a chain of git commits, and the doubly linked list CPython threads through every container object it tracks.
reach for this when… at the moment you edit, you are already holding the node — and the discipline that makes that useful is to say out loud what will be holding it: the free-list head an allocator already keeps, the pointer a scheduler holds on the task it is running this instant, the descriptor a driver gets back the moment a buffer completes. If you cannot name that thing, you do not have a linked-list problem yet; you have a list. Section 06's tell says the same from the access-pattern side; this is the same test asked as a question you can fail. Two riders worth carrying out with it. In Python you almost never type class Node — the shape arrives wearing other names, as OrderedDict, as functools.lru_cache, as the collector's own list — so reaching for the idea usually means recognising which structure in front of you is the linked one in disguise. And on the day you do hand-roll one, __slots__ is not a tuning knob; it is the difference between a 48-byte node and a 344-byte one.
You already owned the pieces: chapter 48 gave you the definition — a data structure is an arrangement plus the operations that arrangement makes cheap — and this chapter is that definition at its starkest, one power bought by selling another; chapter 49 gave you the block, where order is adjacency and position is base + i × stride, which is precisely the rule this chapter refuses; Volume 1's chapter 3 gave you the name as an 8-byte arrow, so next needed no new machinery, only a new job; Volume 1's chapter 11 gave you the reference count, which is why unlinking a node and freeing it are one event rather than two; and Volume 3 gave you the cache line, the only reason two structures in the same complexity class can differ by 8×. Chapter 50 added the arrow itself — and with it the first structure whose cost you have to read twice: once for the operation, and once for the walk that finds where to do it.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 50, in working code

Twelve tiny linked lists you can actually run — we build the node from raw primitives, splice and seek and reverse it, then watch the very same O(n) cost a fortune in cache, and every number below is the machine's own, printed live.

Order lives in the arrows, not the addresses
Build the node from primitives, then walk it — the sequence exists only because each node points to the next, so we can reorder the whole playlist without moving a single value.
Splice is cheap, seek is dear
The linked list's personality in three runs: inserting given the node is a flat two writes, but reaching index k, or the tail, is a hop-by-hop walk — O(1) one way, O(n) the other.
The hidden bills — bytes and cache
Same data, two layouts. A node pays a 6x memory tax for its flexible arrows, and a scattered scan pays it again in cache misses — the constant Big-O quietly throws away.
Two-way streets, and where this really lives
Add a prev pointer and close the ring, and the linked idea shows its real face — reverse in place, delete where you stand, cycle forever, and run the LRU cache that loaded this page.
end of chapter 50 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked