Linked Lists¶
Data structures come in two fundamental flavors. Contiguous structures — arrays, dynamic arrays, heaps, hash tables — live in a single block of memory. Linked structures — lists, trees, graph adjacency lists — are separate chunks of memory tied together by pointers. Nearly every data structure you will ever use is built from one of these two, or both.
The linked list is the simplest linked structure, and it is worth studying less for its own sake (in Python, you'll rarely choose one) than for what it teaches: how to reason about pointer manipulation, how to exploit recursive structure, and how the choice between contiguous and linked storage makes some operations cheap and others expensive.
Contiguous vs. Linked¶
| Array (contiguous) | Linked list | |
|---|---|---|
| Access the \(i\)-th element | \(O(1)\): compute the address | \(O(i)\): follow \(i\) pointers |
| Insert / delete at a known position | \(O(n)\): shift elements | \(O(1)\): rewire a couple of pointers |
| Insert / delete at the front | \(O(n)\) | \(O(1)\) |
| Append at the end | \(O(1)\) amortized (dynamic array) | \(O(1)\) with a tail pointer |
| Memory overhead | None beyond the data | One or two pointers per element |
| Cache behavior | Excellent: sequential memory | Poor: every hop may be a cache miss |
| Growth | Occasional \(O(n)\) copy on resize | Never needs to move existing elements |
Arrays win on random access, space, and locality. Linked lists win when you need to splice: insert or remove elements in the middle, given a reference to where, without disturbing anything else. They also let you move large records around by moving pointers instead of copying the records themselves.
Python's list is an array
Python's list is a dynamic array, and collections.deque is a doubly linked list of fixed-size blocks, giving \(O(1)\) operations at both ends. In real Python code, explicit node-based linked lists appear mainly inside other structures — the LRU cache below, OrderedDict's internals — where \(O(1)\) removal of a node you already hold is exactly what's needed.
The same operations, different costs¶
It's illuminating to compare how each list variant supports the basic dictionary operations. Assume we're handed a reference to the node \(x\) for delete / successor / predecessor.
| Operation | Singly, unsorted | Singly, sorted | Doubly, unsorted | Doubly, sorted |
|---|---|---|---|---|
| Search(\(k\)) | \(O(n)\) | \(O(n)\) | \(O(n)\) | \(O(n)\) |
| Insert(\(x\)) | \(O(1)\) | \(O(n)\) | \(O(1)\) | \(O(n)\) |
| Delete(\(x\)) | \(O(n)\) † | \(O(n)\) † | \(O(1)\) | \(O(1)\) |
| Successor(\(x\)) | \(O(n)\) | \(O(1)\) | \(O(n)\) | \(O(1)\) |
| Predecessor(\(x\)) | \(O(n)\) | \(O(n)\) | \(O(n)\) | \(O(1)\) |
| Minimum / Maximum | \(O(n)\) | \(O(1)\) | \(O(n)\) | \(O(1)\) |
A few entries deserve explanation:
- Sorting a list doesn't speed up search. Binary search needs the middle element in \(O(1)\), and a list can't provide it.
- Deletion in a singly linked list needs the predecessor, since it's the predecessor's
nextthat must change. Given only \(x\), finding the predecessor takes a linear scan. A doubly linked list stores it directly. - † Deleting from a singly linked list in \(O(1)\) is possible with a trick: instead of removing node \(x\), copy its successor's value into \(x\) and remove the successor. This fails for the last node, and it changes which node object holds which value — which matters if other code holds references to nodes. It's LC 237, Delete Node in a Linked List.
Take-Home Lesson
Data structure design is a balancing act between operations. The fastest structure for operations A and B is often not the fastest for A alone or B alone. Pick representations by listing the operations your algorithm actually performs, and how often.
Lists Are Recursive Objects¶
Remove the first node of a linked list and what remains is — a smaller linked list. This recursive view is often the cleanest way to design list algorithms: handle the head, recurse on the rest, and trust induction for correctness.
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ListNode:
val: int = 0
next: ListNode | None = None
def length(head: ListNode | None) -> int:
return 0 if head is None else 1 + length(head.next)
def merge_two_lists(a: ListNode | None, b: ListNode | None) -> ListNode | None:
"""LC 21: merge two sorted lists by relinking their nodes."""
if a is None or b is None:
return a or b
if a.val <= b.val:
a.next = merge_two_lists(a.next, b)
return a
b.next = merge_two_lists(a, b.next)
return b
merge_two_lists is correct by induction: the smaller head must come first, and assuming the recursive call correctly merges what remains, attaching it after that head gives a correctly merged list.
The recursive versions are elegant but use \(O(n)\) stack space, and Python's recursion limit (about 1000) makes them fail on long lists. In practice, write the iterative version — with the recursive argument in mind.
Core Techniques¶
1. The dummy (sentinel) head¶
Many list algorithms have an annoying special case: the head itself might change (it gets deleted, or a new node goes before it). A dummy node placed before the head turns the head into an ordinary node, so the special case disappears.
def merge_two_lists_iter(a: ListNode | None, b: ListNode | None) -> ListNode | None:
dummy = tail = ListNode()
while a and b:
if a.val <= b.val:
tail.next, a = a, a.next
else:
tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
def remove_elements(head: ListNode | None, val: int) -> ListNode | None:
"""LC 203: delete every node with the given value."""
dummy = ListNode(0, head)
prev = dummy
while prev.next:
if prev.next.val == val:
prev.next = prev.next.next # splice out; don't advance prev
else:
prev = prev.next
return dummy.next
Note how remove_elements walks with prev rather than the current node: in a singly linked list, you delete a node by standing on its predecessor.
2. Rewire carefully: save before you overwrite¶
Every pointer manipulation bug has the same shape: a next field is overwritten while it was still the only path to the rest of the list. Before assigning to x.next, ask: is anything reachable only through the old value? If so, save it first.
Reversal is the canonical example:
def reverse_list(head: ListNode | None) -> ListNode | None:
"""LC 206."""
prev = None
curr = head
while curr:
nxt = curr.next # 1. save the rest of the list
curr.next = prev # 2. reverse this one pointer
prev, curr = curr, nxt # 3. advance both
return prev
Invariant: prev is the head of the already-reversed prefix; curr is the head of the untouched suffix. Each iteration moves one node from the suffix to the front of the prefix. When curr is None, prev is the whole reversed list.
When in doubt, draw three boxes and arrows on paper and trace one iteration. Pointer code is much easier to get right by picture than by reasoning in your head.
3. Runners: two pointers at different speeds¶
Arrays let you jump to the middle; lists don't. But two pointers moving at different speeds can compute positions in a single pass.
Find the middle. slow moves one step, fast moves two. When fast reaches the end, slow has gone half as far.
def middle_node(head: ListNode) -> ListNode:
"""LC 876: second middle for even lengths."""
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
The \(n\)-th node from the end (LC 19). Advance fast \(n\) steps first, then move both at the same speed. When fast falls off the end, slow is \(n\) nodes behind it. Starting both at a dummy node leaves slow at the predecessor of the target, which is exactly where you need to stand to delete it.
def remove_nth_from_end(head: ListNode | None, n: int) -> ListNode | None:
dummy = ListNode(0, head)
slow = fast = dummy
for _ in range(n):
fast = fast.next
while fast.next:
slow, fast = slow.next, fast.next
slow.next = slow.next.next
return dummy.next
Cycle Detection: Floyd's Tortoise and Hare¶
A list might loop back on itself. A set of visited nodes detects this in \(O(n)\) space, but two runners do it in \(O(1)\).
Detection (LC 141). Move slow one step and fast two. If there's no cycle, fast reaches the end. If there is, both eventually enter the cycle, and from then on fast gains exactly one node per step on slow — so the gap between them shrinks by one each step and must hit zero. They meet within one lap.
Finding the entrance (LC 142). Let \(a\) be the distance from the head to the cycle's entrance, \(L\) the cycle length, and suppose they meet \(b\) steps past the entrance. When they meet, slow has walked \(a + b\) and fast has walked twice that. The extra distance fast covered is a whole number of laps:
So from the meeting point, walking \(a\) more steps means walking \(kL - b\) steps — which lands exactly on the entrance (you're \(b\) past it; \(L - b\) more steps completes the lap, and the extra \((k-1)\) laps change nothing). A pointer starting from the head also reaches the entrance after \(a\) steps. Walk both at the same speed; they meet at the entrance.
def detect_cycle(head: ListNode | None) -> ListNode | None:
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast:
finder = head
while finder is not slow:
finder, slow = finder.next, slow.next
return finder
return None
The same algorithm works on any function iterated from a starting point, \(x \to f(x) \to f(f(x)) \to \cdots\), which is how it solves Find the Duplicate Number (LC 287) with \(f(i) = \text{nums}[i]\) and Happy Number (LC 202).
Composing Primitives¶
Harder list problems are usually a few of the primitives above, glued together. Recognizing the pieces is most of the work.
Reorder list (LC 143): middle + reverse + merge¶
Rearrange \(L_0 \to L_1 \to \cdots \to L_n\) into \(L_0 \to L_n \to L_1 \to L_{n-1} \to \cdots\).
def reorder_list(head: ListNode | None) -> None:
if not head or not head.next:
return
# 1. split at the middle
slow, fast = head, head.next
while fast and fast.next:
slow, fast = slow.next, fast.next.next
second, slow.next = slow.next, None
# 2. reverse the second half
second = reverse_list(second)
# 3. interleave the two halves
first = head
while second:
first_rest, second_rest = first.next, second.next # save both before rewiring
first.next = second
second.next = first_rest
first, second = first_rest, second_rest
The same split-and-reverse approach checks whether a list is a palindrome in \(O(1)\) space (LC 234): reverse the second half and compare it against the first.
Reverse a sublist (LC 92)¶
Reverse nodes at positions left through right. Walk a pointer to the node before the sublist, then repeatedly take the node right after the sublist's current first node and move it to the front:
def reverse_between(head: ListNode | None, left: int, right: int) -> ListNode | None:
dummy = ListNode(0, head)
before = dummy
for _ in range(left - 1):
before = before.next
first = before.next # will end up as the sublist's tail
for _ in range(right - left):
moved = first.next
first.next = moved.next
moved.next = before.next
before.next = moved
return dummy.next
Reverse in groups of \(k\) (LC 25)¶
The same move-to-front, applied to successive blocks of \(k\). Two details: first check that \(k\) nodes remain (a short final block stays as is), and after reversing, the block's original first node is its new tail — the before of the next block.
def reverse_k_group(head: ListNode | None, k: int) -> ListNode | None:
dummy = ListNode(0, head)
before = dummy
while True:
probe = before
for _ in range(k): # are there k nodes left?
probe = probe.next
if probe is None:
return dummy.next
first = before.next
for _ in range(k - 1): # move-to-front, as in LC 92
moved = first.next
first.next = moved.next
moved.next = before.next
before.next = moved
before = first
\(O(n)\) time: each node is probed once and moved once. \(O(1)\) space.
Sort a list (LC 148): mergesort¶
Mergesort is the natural sort for linked lists: splitting uses the middle-finding runners, merging relinks nodes with no extra space, and nothing needs random access. See Sorting.
def sort_list(head: ListNode | None) -> ListNode | None:
if not head or not head.next:
return head
slow, fast = head, head.next # slow stops at the end of the first half
while fast and fast.next:
slow, fast = slow.next, fast.next.next
second, slow.next = slow.next, None # cut, or the halves stay connected
return merge_two_lists_iter(sort_list(head), sort_list(second))
\(O(n \log n)\) time, \(O(\log n)\) recursion depth. Starting fast at head.next matters: with fast = head, a two-node list splits into two nodes and nothing, and the recursion never ends.
Merge \(k\) sorted lists (LC 23)¶
Merging lists pairwise, one after another, costs \(O(nk)\) for \(n\) total nodes. Two better approaches, both \(O(n \log k)\): keep the \(k\) current heads in a min-heap and repeatedly pop the smallest (see Heaps), or merge lists in pairs like the levels of mergesort. With nodes, push (node.val, i, node): nodes aren't orderable, and the list index i breaks ties before Python ever compares two nodes.
More Classic Problems¶
Intersection of two lists (LC 160)¶
Two lists merge at some node and share a tail. Let the unshared parts have lengths \(a\) and \(b\), and the shared tail \(c\). A pointer that walks list A and then switches to list B travels \(a + c + b\) before reaching the intersection a second time; one that walks B then A travels \(b + c + a\). Same distance — so they arrive at the intersection at the same step (or both reach None together if there is none).
def get_intersection_node(a: ListNode | None, b: ListNode | None) -> ListNode | None:
p, q = a, b
while p is not q:
p = p.next if p else b
q = q.next if q else a
return p
Add two numbers (LC 2)¶
Digits stored in reverse order are exactly the order in which grade-school addition processes them, least significant first. Walk both lists together, carrying as you go; a dummy head collects the result.
def add_two_numbers(l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
dummy = tail = ListNode()
carry = 0
while l1 or l2 or carry:
total = carry + (l1.val if l1 else 0) + (l2.val if l2 else 0)
carry, digit = divmod(total, 10)
tail.next = ListNode(digit)
tail = tail.next
l1 = l1.next if l1 else None
l2 = l2.next if l2 else None
return dummy.next
Copy a list with random pointers (LC 138)¶
Each node has an extra random pointer to an arbitrary node. The difficulty: when copying node \(x\), \(x\)'s random target may not have been copied yet. The clean fix is a hash map from original node to copy: one pass creates all the copies, a second pass wires next and random through the map. \(O(n)\) time and space. (An \(O(1)\)-extra-space version interleaves each copy right after its original, uses x.next.random = x.random.next, then unweaves the lists.)
Design: The LRU Cache (LC 146)¶
An LRU (least-recently-used) cache with capacity \(c\) supports get(key) and put(key, value) in \(O(1)\), evicting the least recently used key when full.
No single structure does this:
- A hash map finds a key in \(O(1)\), but knows nothing about recency.
- A list ordered by recency can move an item to the front and evict from the back in \(O(1)\) — if you already hold a reference to the node, and if it's doubly linked so that the node can unlink itself.
Combine them: the hash map stores, for each key, a pointer to its node in a doubly linked recency list. The map provides the reference; the list provides the \(O(1)\) splicing.
class _Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key: int = 0, val: int = 0):
self.key, self.val = key, val
self.prev: _Node | None = None
self.next: _Node | None = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.nodes: dict[int, _Node] = {}
self.head, self.tail = _Node(), _Node() # sentinels: head.next is most recent
self.head.next, self.tail.prev = self.tail, self.head
def _unlink(self, node: _Node) -> None:
node.prev.next, node.next.prev = node.next, node.prev
def _push_front(self, node: _Node) -> None:
node.prev, node.next = self.head, self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key: int) -> int:
node = self.nodes.get(key)
if node is None:
return -1
self._unlink(node)
self._push_front(node)
return node.val
def put(self, key: int, value: int) -> None:
if key in self.nodes:
node = self.nodes[key]
node.val = value
self._unlink(node)
else:
if len(self.nodes) == self.capacity:
lru = self.tail.prev
self._unlink(lru)
del self.nodes[lru.key] # why nodes store their key
node = _Node(key, value)
self.nodes[key] = node
self._push_front(node)
Two sentinels (head and tail) mean _unlink and _push_front never check for None — no special cases for the first or last element.
Take-Home Lesson
Building algorithms around the right combination of data structures leads to both clean code and good performance. When no single structure supports all the operations you need, look for two that each support half, and link them with pointers.
In production Python, collections.OrderedDict implements exactly this design: move_to_end(key) and popitem(last=False) give an LRU cache in a few lines. In an interview, be ready to build it by hand.
Common Mistakes¶
-
Losing the rest of the list. Overwriting
node.nextbefore saving it. Always save first. -
Forgetting the head can change. Deleting the first node, inserting before it, or reversing changes which node is the head. Use a dummy node, and return
dummy.next. -
Null-pointer errors in runners.
fast.next.nextfails iffast.nextisNone. The conditionwhile fast and fast.nextprotects both hops. -
Comparing values instead of identities. Two different nodes can hold equal values. Use
isto compare nodes (intersection, cycle detection),==only for values. -
Leaving a cycle behind. After splitting a list, set the end of the first half to
None; otherwise later code will traverse into the second half or loop forever. -
Recursion depth. A recursive solution on a 10⁵-node list will exceed Python's recursion limit. Prefer iteration.
Practice Problems¶
| Problem | Technique |
|---|---|
| Reverse Linked List (LC 206) | Three-pointer reversal |
| Merge Two Sorted Lists (LC 21) | Dummy head |
| Remove Nth Node From End (LC 19) | Runners with a gap |
| Middle of the Linked List (LC 876) | Slow / fast |
| Linked List Cycle II (LC 142) | Floyd + entrance argument |
| Delete Node in a Linked List (LC 237) | Copy successor into the node |
| Palindrome Linked List (LC 234) | Middle + reverse + compare |
| Reorder List (LC 143) | Middle + reverse + merge |
| Reverse Linked List II (LC 92) | Move-to-front within a sublist |
| Reverse Nodes in k-Group (LC 25) | Repeated sublist reversal |
| Intersection of Two Linked Lists (LC 160) | Switch heads, equal distances |
| Add Two Numbers (LC 2) | Grade-school addition with carry |
| Copy List with Random Pointer (LC 138) | Hash map old → new |
| Sort List (LC 148) | Mergesort |
| Merge k Sorted Lists (LC 23) | Min-heap of heads |
| LRU Cache (LC 146) | Hash map + doubly linked list |