AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 5
Linked Lists
A treasure hunt where each clue tells you where the next clue is. You can splice a new clue into the middle for free — but you can never skip ahead.
By the end of this chapter you can
- Build a linked list from nodes and traverse it without an index
- Perform insertion and deletion by pointer surgery, and count the writes involved
- Explain precisely why indexing is O(n) here and O(1) in an array
- Reverse a list in place with three pointers, and say why the order of the three lines matters
- Detect a cycle in O(1) space with Floyd's two runners, and justify why they must meet
- Decide honestly when a linked list is the right choice — which is rarer than textbooks imply
1Giving up the street
Chapter 3 got O(1) indexing from contiguity. This chapter gives contiguity away, and gets something else in return.
A treasure hunt: each clue tells you where to find the next clue. The clues can be anywhere — under a bench, behind a painting, in another building. Their physical positions are irrelevant, because each one carries the address of its successor.
Adding a new clue to the middle of the hunt is trivial: change one clue to point at your new one, and make your new one point where the old one used to. Nothing else moves.
But there is no way to jump to “clue number seven.” You must follow six clues to get there. Always.
That is a linked list, exactly. A node holds a value and a reference to the next
node. There is no block of contiguous memory, no uniform slot size, no address arithmetic — and
therefore no O(1) indexing.
class Node:
def __init__(self, value, nxt=None):
self.value = value
self.next = nxt # a reference to another Node, or None
# Build 7 -> 3 -> 9 -> None, back to front
c = Node(9)
b = Node(3, c)
a = Node(7, b)
head = a
# Traverse. Note: no index anywhere.
cur = head
while cur is not None:
print(cur.value)
cur = cur.nextAn array gets O(1) indexing from contiguity, and pays O(n) for
insertion because everything must shift.
A linked list gets O(1) insertion from indirection, and pays O(n) for
indexing because it must walk.
Same two operations, opposite bills. Neither is better — they are bets on different access patterns.
2Three flavours
Singly, doubly and circular. Each adds a pointer and buys a specific capability.
| Flavour | Each node holds | Buys you | Costs you |
|---|---|---|---|
| Singly linked | value, next | The simplest possible node | You can only move forward; deletion needs the previous node |
| Doubly linked | value, next, prev | Backwards traversal; delete a node given only that node | One extra pointer per node, and two pointers to fix on every edit |
| Circular | value, next (last → first) | Endless rotation; no special case for the tail | No natural stopping point — loops need a different termination test |
class DNode:
"""A doubly linked node. The extra pointer is what makes the LRU cache
in Chapter 29 possible."""
def __init__(self, value):
self.value = value
self.next = None
self.prev = None
def delete_singly(head, target):
"""Singly linked: we must track prev ourselves, because a node
cannot see backwards."""
if head is None:
return None
if head.value == target:
return head.next
prev, cur = head, head.next
while cur is not None:
if cur.value == target:
prev.next = cur.next # route around it
return head
prev, cur = cur, cur.next
return head
def delete_doubly(node):
"""Doubly linked: given only the node itself, O(1). No search, no head."""
if node.prev:
node.prev.next = node.next
if node.next:
node.next.prev = node.prev
# Two writes. This is the operation an LRU cache needs a thousand
# times a second, and it is why the extra pointer earns its keep.“Deletion from a linked list is O(1)” is only true given a
pointer to the right node. Deleting by value is O(n), because finding
it is O(n).
The O(1) claim is real — it is precisely why an LRU cache pairs a hash map
(which supplies the pointer instantly) with a doubly linked list (which does the O(1) surgery). One
without the other is useless.
3Reversing in place
The classic linked-list exercise. Three pointers, six lines, and one ordering constraint that everyone gets wrong once.
To reverse the list we walk it once, flipping each arrow as we pass. The problem: the moment you
change cur.next, you have destroyed the only reference to the rest of the list. So you
must save it first.
def reverse(head):
prev = None
cur = head
while cur is not None:
nxt = cur.next # 1. SAVE the rest of the list ← do this first
cur.next = prev # 2. flip this node's arrow backwards
prev = cur # 3. prev moves forward
cur = nxt # 4. cur moves forward
return prev # prev is the new head
# Swap lines 1 and 2 and the list is destroyed: cur.next now points at
# prev, so `nxt = cur.next` walks backwards and you loop forever over
# two nodes. Try it in the playground — it is a good failure to see once.The recursive version
def reverse_rec(head):
if head is None or head.next is None:
return head # base case: 0 or 1 nodes
new_head = reverse_rec(head.next) # reverse everything after me
head.next.next = head # my successor now points back at me
head.next = None # I become the new tail
return new_head
# Elegant, and O(n) SPACE — one stack frame per node. On a million-node
# list this raises RecursionError. The iterative version is O(1) space.
# When both exist and one uses less memory, prefer that one.4Floyd's two runners
A list with a loop never ends. Detecting that in constant space is one of the prettiest small results in the subject.
If some node's next points back to an earlier node, traversal never terminates.
Detecting it with a set of visited nodes is easy and costs O(n) memory.
Floyd's algorithm does it in O(1).
Two runners start together on a circular track, one twice as fast as the other. The fast one must eventually lap the slow one — the gap between them closes by exactly one position per step, so it cannot be avoided.
On a straight track, the fast runner simply reaches the end and stops. They never meet again.
So: if the two runners meet, the track is a loop. If the fast one runs off the end, it is not.
def has_cycle(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next # one step
fast = fast.next.next # two steps
if slow is fast: # `is`, not `==` — identity, not value
return True
return False # fast ran off the end: no cycle
# O(n) time, O(1) space.
#
# Why it terminates: once both runners are inside the loop, the fast one
# gains exactly one position per step on the slow one. A gap that shrinks
# by one every step and lives on a finite circle must reach zero.Finding where the loop starts
A second, less obvious step. Once the runners meet, reset one to the head and advance both one step at a time. They meet again exactly at the entrance to the loop.
def cycle_start(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast:
slow = head # reset one runner
while slow is not fast: # now BOTH move one step
slow, fast = slow.next, fast.next
return slow # the loop entrance
return None
# The proof is a short piece of algebra: if the tail before the loop has
# length T and the meeting point is M steps into a loop of length L, then
# the distance from the meeting point back round to the entrance is exactly
# T. So two walkers, one from the head and one from the meeting point,
# moving at the same speed, arrive together.Two pointers moving at different speeds is a general technique, not a party trick. It also gives you:
- The middle node in one pass — when fast reaches the end, slow is halfway.
- The n-th from last node — start fast n ahead, then move both together.
- Whether a list is a palindrome — find the middle, reverse the second half, compare.
All in one pass and O(1) space. All in the exercises.
5When to actually use one
Textbooks love linked lists. Production code uses them far less than you would expect, and it is worth understanding why.
* doubly linked. Singly linked needs the previous node, so it is O(n)
unless you already have it.
The case against
Three real objections, in order of how much they matter in practice:
- Cache locality. Array elements sit together, so one memory fetch loads several of them into the CPU cache. Linked-list nodes are scattered, so each hop is potentially a cache miss — and a cache miss costs roughly a hundred times a cache hit. In real benchmarks an array scan routinely beats a linked-list scan by an order of magnitude, even when the Big-O favours the list.
- Memory overhead. Every node carries at least one extra pointer. In Python, every node is also a full object with its own header — typically 50+ bytes to store one integer.
- You usually do not have the pointer. The
O(1)insert assumes you already hold a reference to the right node. If you have to search for it, you have paidO(n)and the advantage is gone.
The case for
Linked lists are the right answer when you genuinely hold pointers to the nodes you edit, and you edit constantly:
- LRU caches — a hash map supplies the pointer, the list supplies the order. Chapter 29.
- Deques and queues —
collections.dequeis a doubly linked list of array blocks, getting cache locality andO(1)at both ends. - Adjacency lists in graphs, and chaining in hash tables — many short lists, always appended to at the head.
- Persistent / immutable structures — a functional list can share its entire tail with the version before it. No array can do that.
You will rarely write a linked list in production Python. list covers the array
case and collections.deque covers the queue case, both implemented in C.
Learn linked lists anyway — not because you will write one, but because trees and graphs are linked structures. Every intuition you build here about following references, saving before you overwrite, and thinking in pointers transfers directly to Chapters 14 through 23.
What to carry forward
- A linked list trades contiguity for indirection: O(1) splicing, O(n) indexing. Exactly the opposite bill from an array.
- The O(1) insert and delete are only O(1) once you hold a pointer to the right node. Finding it is O(n).
- Reversal is save, flip, advance, advance — in that order. Overwrite before saving and you lose the rest of the list.
- Floyd's two runners detect a cycle in O(1) space, because a faster runner on a circular track must lap a slower one.
- Different-speed pointers also give you the middle node, the n-th from last, and palindrome checking in one pass.
- In practice, cache locality usually beats the better Big-O. Learn linked lists because trees and graphs are linked structures.
>_Playground
A complete singly linked list to poke at. Try breaking the reversal by swapping two lines — that failure teaches more than the working version.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 6 — Stacks
A pile of plates: last on, first off. That single restriction is what matches brackets, powers undo, and explains how functions call each other.