Chapter 6
15 min read
Section 34 of 261

Common Operations and Complexity

Linked Lists

Why an Operations Catalogue Matters

We have built singly, doubly, and circular linked lists in the previous four sections. Now we step back and ask the question every working engineer eventually asks: given a problem, will a linked list run fast enough? The answer comes in two parts, and a serious treatment must include both.

  1. Asymptotic cost — the Big-O behavior as nn \to \infty. This is what tells you that delete-head is constant time and access by index is linear, regardless of language or machine.
  2. Real wall-clock cost — what the CPU actually does. Linked lists pay a hidden price (cache misses, allocator pressure, branch mispredictions) that is invisible to Big-O but very visible on a profiler. Sometimes a linear-time array scan beats a constant-time linked-list operation in practice, and you must know when.
Senior's rule of thumb: Big-O tells you how the algorithm scales. The constant factor tells you whether your users will notice. Linked lists have excellent Big-O for some operations and terrible constants for almost everything. Both facts matter.

The Master Complexity Table

Here is the canonical reference. Memorize the singly-linked column; the rest follow from symmetry. All times are worst case unless noted; nn is the list length, kk is an index.

OperationSingly (no tail)Singly (+ tail)Doubly (+ tail)Doubly CircularDynamic Array
access by index t[k]O(k)O(k)O(min(k, n−k))O(min(k, n−k))O(1)
find by valueO(n)O(n)O(n)O(n)O(n)
prependO(1)O(1)O(1)O(1)O(n)
appendO(n)O(1)O(1)O(1)O(1) amort.
insert after known nodeO(1)O(1)O(1)O(1)O(n)
insert before known nodeO(n)O(n)O(1)O(1)O(n)
insert at index kO(k)O(k)O(min(k, n−k))O(min(k, n−k))O(n−k)
delete headO(1)O(1)O(1)O(1)O(n)
delete tailO(n)O(n)O(1)O(1)O(1)
delete given node ptrO(n)O(n)O(1)O(1)O(n)
delete by valueO(n)O(n)O(n)O(n)O(n)
reverse in placeO(n)O(n)O(n)O(n)O(n)
lengthO(n) / O(1) cachedO(1) cachedO(1) cachedO(1) cachedO(1)
concat (splice) two listsO(n)O(1)O(1)O(1)O(m)

Three patterns to internalise

  • Tail pointer turns append from O(n) into O(1). Without it, a loop of nn appends becomes O(n2)O(n^2) total — a classic interview-killer.
  • Doubly linked turns delete-given-pointer from O(n) into O(1). Singly linked must walk to find the predecessor.
  • Splicing two lists is O(1) with both tail pointers — array concatenation is O(m)O(m). This is the single biggest argument for linked lists in a real codebase.

Asymptotic Cost vs Real Performance

Big-O hides a multiplicative constant that, on modern hardware, can be the difference between a snappy app and a sluggish one. The dominant cost in a linked-list traversal is not the comparison or the pointer dereference; it is the cache miss.

The cache-miss penalty

Memory tierTypical latencyPer-element cost
L1 cache≈ 1 nsSequential array scan hits this on every element
L2 cache≈ 4 nsLarger arrays, still cache-friendly
L3 cache≈ 15 nsWorking sets up to a few MB
Main memory (RAM)≈ 100 nsCold linked-list traversal pays this per node

Linked-list nodes are typically allocated independently on the heap, so consecutive nodes can land anywhere in physical memory. Walking the list is a sequence of essentially-random memory accesses — the CPU's prefetcher cannot predict what comes next, so each node = node.next can stall the pipeline waiting on RAM.

A concrete benchmark

On an x86-64 laptop circa 2024, summing an array of 1,000,000 32-bit ints takes about 1 ms (the prefetcher streams cache lines perfectly). Summing an equivalent std::list<int> of the same length takes 10–50 ms. The asymptotic complexity is identical — O(n)O(n) for both — but the wall-clock difference is 10× to 50×.

Big-O lies (a little)

Two algorithms with the same O(n)O(n) bound can differ by orders of magnitude in practice. Whenever Big-O says "same," cache locality is the tiebreaker. Whenever Big-O says "linked list wins by O(n) → O(1)," check that the operation is actually frequent enough to justify the cache-miss tax on every other operation.

Anatomy of Each Operation

1. Access by index — O(n), unavoidable

There is no t[k]t[k] shortcut. We start at head and follow next exactly kk times. Doubly linked lists with a tail pointer can choose the closer end: cost becomes O(min(k,nk))O(\min(k,\, n-k)).

2. Prepend — O(1), the linked list's superpower

Three writes: allocate node, node.next = head, head = node. Compare with a dynamic array's prepend, which must shift every existing element right.

3. Append — O(1) with tail, O(n) without

Without a tail pointer we must walk to the end. This is the most common production bug: a beginner builds a list with nn appends and accidentally pays O(n2)O(n^2). With a tail pointer, append is symmetric to prepend.

4. Insert after a known node — O(1)

If you already hold a pointer to the predecessor, you rewire two next-fields and you are done. This is the operation that makes intrusive linked lists popular in OS kernels: handlers receive a node pointer directly, skip the find phase, and unlink in constant time.

5. Insert before a known node — O(n) singly, O(1) doubly

Singly: you have node but not node.prev, so you walk from head until you find the predecessor. Doubly: node.prev is already there.

6. Delete head / delete tail

  • Delete head: head = head.next. Always O(1)O(1).
  • Delete tail (singly): even with a tail pointer we must find the second-to-last node by walking from head. O(n)O(n).
  • Delete tail (doubly): tail = tail.prev; tail.next = null. O(1)O(1).

7. Delete given a node pointer — the canonical doubly-linked win

Doubly linked: node.prev.next = node.next; node.next.prev = node.prev. O(1)O(1). Singly linked: must walk to find the predecessor. O(n)O(n). This single operation is why every production LRU cache uses a doubly linked list.

8. Reverse — O(n), constant extra space

Walk the list flipping each next pointer in place. Three local variables (prev, curr, nxt) and one pass. Far more memory-efficient than building a reversed copy.

9. Length — O(n) by default, O(1) if cached

A bare linked list does not know its own length; counting requires a full traversal. Production implementations cache size as a field and update it on insert/delete — O(1)O(1) queries, no extra asymptotic cost.

10. Concatenate — O(1) with both tails

a.tail.next = b.head; a.tail = b.tail. Two writes, regardless of how long the lists are. This is the single operation where linked lists genuinely beat every array variant: a billion-element list can be appended to another in constant time.


Interactive: Operation Cost Visualizer

Pick an operation, choose a list size, and step through the work. The cyan bar shows pointer hops in the linked list; the green bar shows the equivalent step count in a dynamic array. Watch how the two curves cross as the operation changes — that crossing is the only reason to ever pick a linked list.

Operation Cost Visualizer — pointer hops vs. array equivalent

n = 8 · visited 0/5
head01234567/
Linked list — O(n)
5 hops
Array — O(1)
1 step

Walk from head until we reach index n/2. No shortcut exists — pointers, not arithmetic.

Notice three things while you play:

  1. access middle visits n/2n/2 nodes; arrays jump in one step.
  2. append (no tail) visits every node; append (with tail) visits one. Same operation, different data structure.
  3. delete head visits one node for the linked list, nn for the array. The linked list is asymptotically better, even though the array beats it on most other ops.

Worked Example I: Merge Two Sorted Lists

This is the canonical linked-list interview problem and the recursive step inside merge sort. Given two sorted singly linked lists, produce a single sorted list by rewiring pointers — no new nodes, no copies. The famous trick is the dummy head: a placeholder node whose only purpose is to absorb the "is this the first element?" special case so the loop body has zero branches.

Merging two sorted singly linked lists, in place
🐍merge_sorted.py
1Standard imports

We import the dataclass decorator (so we don't write a constructor by hand) and Optional for the nullable next-pointer.

2Optional for nullable links

Optional[Node] is shorthand for Union[Node, None]. The end of a linked list is None — explicit typing keeps the type checker honest.

4@dataclass eliminates boilerplate

Generates __init__, __repr__, and __eq__ automatically. For a 2-field Node class, this is exactly the constructor we'd write by hand.

5Node class definition

A linked-list node holds a value and a pointer to the next node. Two fields, no methods — the data structure is intentionally minimal.

6val: the payload

Typed as int here for clarity. In production code this would typically be a generic type parameter.

7next: the link to the rest of the list

Default of None means a freshly created node is a single-element list. Optional['Node'] uses a forward reference (string) because Node isn't fully defined yet on this line.

9Function signature

Takes two list heads (either may be None — an empty list is a perfectly valid input) and returns the head of the merged list.

EXAMPLE
merge_sorted(1->4->5, 2->3->6) returns 1->2->3->4->5->6
10The dummy head pattern

We allocate one throwaway node BEFORE the real list. Its only job is to give us a stable predecessor for the first real element, so we never have to special-case 'is this the first append?'. The dummy never escapes this function.

11tail = the writer

tail always points at the last node we have appended. We will write the next node into tail.next. Initialising it to dummy means the very first append writes into dummy.next — exactly where the head of the real list will live.

13Loop while BOTH lists have elements

As soon as one list is exhausted, the remainder of the other can be attached in a single pointer write — no need to keep iterating.

14Compare heads and pick the smaller

<= (not <) makes the merge STABLE — equal keys keep their relative order, which matters when the values carry extra payload like timestamps or tie-breakers.

EXAMPLE
If a.val=3 and b.val=3, we take from a first. Stable merge sort relies on this.
15Splice the chosen node onto the result

tail.next = a links the smaller node into our growing list. Crucially, we do NOT copy the node — we re-use the existing one. Memory allocations: zero.

16Advance the source pointer

a moves to its successor. The just-spliced node still has its original .next field, but we will overwrite it on the next iteration when we set tail.next.

17The else branch is symmetric

Same logic, b instead of a. Keeping both branches structurally identical makes the loop trivially correct by inspection.

21Advance the writer

tail now points at the node we just appended. Next iteration will write into tail.next, overwriting whatever stale next-pointer that node had.

23Attach the remainder

Exactly one of a, b is non-None here (the loop ended because the other ran out). One pointer write attaches the entire remaining tail of length up to n+m. This is the operation that makes a linked-list merge cheaper than an array merge.

EXAMPLE
If a = 9->10 remains and b is None, tail.next = a installs both nodes with one assignment — no element-by-element copy.
24Return the real head

dummy.next is the first real node we appended. The dummy itself is now garbage — Python's reference counter reclaims it when this stack frame returns.

6 lines without explanation
1from dataclasses import dataclass
2from typing import Optional
3
4@dataclass
5class Node:
6    val: int
7    next: Optional["Node"] = None
8
9def merge_sorted(a: Optional[Node], b: Optional[Node]) -> Optional[Node]:
10    dummy = Node(val=0)        # sentinel — never returned to caller
11    tail = dummy               # always points at the last node we appended
12
13    while a is not None and b is not None:
14        if a.val <= b.val:
15            tail.next = a
16            a = a.next
17        else:
18            tail.next = b
19            b = b.next
20        tail = tail.next       # advance the writer
21
22    tail.next = a if a is not None else b   # attach the remainder in O(1)
23    return dummy.next                       # skip the sentinel

Why the dummy head matters

Without it, the first iteration needs a special case: "if result is None, set the head; else append to tail." That branch executes exactly once but bloats the loop body and is the #1 source of off-by-one bugs in linked-list code. The dummy node trades one wasted allocation for an absolutely uniform loop body.

Complexity. Each iteration moves one of a, b forward by one and does O(1)O(1) work, so the total cost is O(n+m)O(n + m) time and O(1)O(1) extra space (we allocate one dummy node, period). This is the engine that powers merge sort on linked lists: O(nlogn)O(n \log n) time, O(logn)O(\log n) recursion stack, and no need for random access — making it the sort of choice for linked structures.


Worked Example II: k-th Node From the End

"Find the kk-th node from the end" is asked at every coding interview because it forces the candidate to discover the two-pointer (lead/follow) idiom. The naive solution walks the list twice (once to compute length, once to walk nkn - k steps); the elegant solution does it in one pass.

The two-pointer (lead/follow) idiom
🐍kth_from_end.py
1Function signature

Returns the k-th node counting from the tail (k=1 means the last node). Returns None when the list is too short or k is invalid.

EXAMPLE
On 1->2->3->4->5 with k=2, returns the node holding 4.
2Guard against k <= 0

k=0 means 'zero from the end' — undefined. Negative k is meaningless. Returning None is cleaner than raising for typical interview/library contracts.

5lead = head: the scout

We will advance lead k steps first, opening a fixed-size gap of exactly k between lead and follow.

6Advance lead by k

After this loop, lead is k nodes ahead of where follow will start. The underscore _ is Pythonic for 'I don't need the loop variable'.

EXAMPLE
k=2, list=1->2->3->4->5: after the loop, lead points at the node holding 3.
7Short-list early exit

If lead becomes None before we've taken k steps, the list has fewer than k nodes, so a k-th-from-end doesn't exist. We bail with None.

8lead = lead.next

Standard one-step advance. Note that we walk lead BEFORE checking — that's why we check 'is None' inside the loop, not as the loop condition.

11follow = head: the result pointer

Now the gap between lead and follow is exactly k. We will walk both forward in lock-step until lead falls off the end — at that moment, follow is the answer.

12Walk lock-step

Each iteration advances both pointers by one. The k-step gap is preserved. The loop ends when lead becomes None — i.e., lead has stepped past the last real node.

13Advance lead

Standard one-step. We don't bounds-check here because the while-condition already filtered out lead == None.

14Advance follow in lock-step

The invariant 'lead is exactly k ahead of follow' is preserved every iteration.

16follow holds the answer

When lead first becomes None, follow is exactly k positions back from None — i.e., the k-th node counting from the end.

EXAMPLE
k=2, list=1->2->3->4->5: lead becomes None after pointing past 5. follow has stepped from 1 to 4. Return the node holding 4.
5 lines without explanation
1def kth_from_end(head: Optional[Node], k: int) -> Optional[Node]:
2    if k <= 0:
3        return None
4
5    lead = head
6    for _ in range(k):           # advance lead k steps first
7        if lead is None:
8            return None          # list shorter than k — no answer
9        lead = lead.next
10
11    follow = head
12    while lead is not None:      # walk both until lead falls off
13        lead = lead.next
14        follow = follow.next
15
16    return follow                # follow is now k from the end

Complexity. One pass, two pointer hops per node visited. O(n)O(n) time, O(1)O(1) extra space — and crucially one traversal, not two. On a list whose nodes live in cold RAM, halving the number of passes can halve real wall-clock time even though Big-O is unchanged.

Where this pattern reappears

The two-pointer lead/follow idiom shows up in three more linked-list classics covered in the next section:
  1. Find the middle — slow advances 1, fast advances 2; when fast falls off, slow is at the midpoint.
  2. Detect a cycle — Floyd's tortoise and hare; if there's a cycle, fast catches slow.
  3. Find cycle start — once they meet, reset one pointer to head and walk both at speed 1.
Three different problems, one underlying idea: two pointers, different speeds, watch the relative offset.

Same Algorithms in TypeScript

Here is merge_sorted in TypeScript with class-based nodes. The shape is identical — the dummy-head trick is language-agnostic — but the type system makes the nullable next-pointer explicit, which catches a whole class of bugs at compile time.

📘typescript
1class ListNode {
2  constructor(public val: number, public next: ListNode | null = null) {}
3}
4
5function mergeSorted(
6  a: ListNode | null,
7  b: ListNode | null,
8): ListNode | null {
9  const dummy = new ListNode(0);
10  let tail = dummy;
11
12  while (a !== null && b !== null) {
13    if (a.val <= b.val) {
14      tail.next = a;
15      a = a.next;
16    } else {
17      tail.next = b;
18      b = b.next;
19    }
20    tail = tail.next!; // safe: we just assigned it
21  }
22
23  tail.next = a !== null ? a : b;
24  return dummy.next;
25}
26
27function kthFromEnd(head: ListNode | null, k: number): ListNode | null {
28  if (k <= 0) return null;
29
30  let lead: ListNode | null = head;
31  for (let i = 0; i < k; i++) {
32    if (lead === null) return null;
33    lead = lead.next;
34  }
35
36  let follow: ListNode | null = head;
37  while (lead !== null) {
38    lead = lead.next;
39    follow = follow!.next;
40  }
41  return follow;
42}

Two language-level observations:

  • The ! non-null assertion on tail.next! is a deliberate signal to the reader: "I have just written a non-null value here, the type checker can't prove it, but I can." In performance-critical code, the alternative (a runtime null check) costs a branch per loop iteration.
  • A C or C++ port would look almost identical, but you would also write delete / free calls when nodes are removed from a list — Python and TypeScript hand that off to the GC.

The Linked-List Algorithm Bestiary

Beyond the basic CRUD operations, a small zoo of classic algorithms operate on linked lists. Memorising these templates pays off in interviews, in production code review, and (most importantly) in spotting when a problem is secretly "just" one of these.

AlgorithmTimeSpaceKey idea
Detect cycle (Floyd's tortoise & hare)O(n)O(1)Slow +1, fast +2; if they meet, there's a cycle.
Find cycle entryO(n)O(1)After meeting, reset one to head; walk both at speed 1; meet at entry.
Find middleO(n)O(1)Slow +1, fast +2; when fast falls off, slow is at the midpoint.
k-th from endO(n)O(1)Two pointers with a fixed k-step lead.
Merge two sorted listsO(n + m)O(1)Dummy head + writer pointer; rewire links in place.
Merge sort on linked listO(n log n)O(log n) stackSplit at middle, recurse, merge — no random access needed.
ReverseO(n)O(1)prev/curr/nxt; flip each next pointer in one sweep.
Reverse k-groupO(n)O(1)Reverse k nodes at a time, link sub-lists.
Reorder L0 → Ln → L1 → Ln-1 …O(n)O(1)Find middle, reverse second half, interleave.
Palindrome checkO(n)O(1)Find middle, reverse second half, compare.

Why merge sort, not quick sort, for linked lists

Quick sort needs O(1)O(1) indexing to pick a good pivot and partition efficiently — linked lists do not have that. Merge sort, by contrast, only ever splits at the midpoint (one slow/fast pass) and merges via pointer rewiring — both of which are perfectly suited to linked lists. As a bonus, linked merge sort needs no auxiliary array, just O(logn)O(\log n) recursion stack.

Where These Operations Run in Production

SystemLinked-list roleWhy it works there
LRU cache (Redis, browser caches, OS page cache)Doubly linked list + hashmap; move-to-front on accessO(1) move-to-front and O(1) eviction-at-tail; hashmap gives O(1) lookup.
Linux kernel (list.h)Intrusive doubly-linked lists everywhere — task lists, lock waiters, free pagesO(1) splice/unlink given a pointer; no allocator round-trip.
Java collections — java.util.LinkedListDoubly linked DequeRarely the right choice today; ArrayDeque beats it on cache locality.
Python — collections.dequeNOT a linked list — block-of-arrays doubly linkedBest of both worlds: O(1) ends, cache-friendly middle.
Git reflog / commit chainDoubly linked head/tail historyO(1) append on each commit; walk-back for git log.
Browser tab navigation historyDoubly linked back/forward stackO(1) back, O(1) forward, splice when navigating from a non-tip page.
Database row-level locks (PostgreSQL ProcArray)Linked waiter queues per lockO(1) enqueue/dequeue; intrusive — the waiting transaction holds the link.
Compiler IRs (LLVM ilist)Doubly linked instruction lists per basic blockOptimisation passes splice and reorder instructions in O(1).
Garbage collector free listsSingly linked list of free memory blocksO(1) push/pop on allocation/free; per-size-class free lists.
Message queues (Linux msgrcv, in-kernel)FIFO linked queueO(1) enqueue at tail, O(1) dequeue at head; lock-free variants exist.
The pattern across all ten: linked lists win when (a) you already hold pointers to nodes (intrusive use), (b) the dominant operations are splice/unlink, and (c) random access is unnecessary. They lose any time you would have walked the structure linearly anyway.

Pattern Recognition: Reach for What?

You need…Best choiceWhy not a linked list
Random access by indexdynamic arrayO(1) vs O(n); cache-friendly
Sorted with binary searchdynamic arraybinary search needs O(1) indexing
Frequent push/pop at both endsdeque (block-array)O(1) ends + cache locality
O(1) splice of large sub-listsdoubly linked listthis is THE case where lists win
LRU cachedoubly linked list + hashmapthe textbook combination
Priority queuebinary heap (array-backed)list scan is O(n)
Sorted dynamic setbalanced BST or skip listO(log n) search
Hot-path traversal of millions of itemsarray / vectorcache-line streaming
Intrusive ownership in C/kernel codedoubly linked listno allocator overhead, O(1) unlink
FIFO queue, simpledeque or ring bufferrarely worth a linked list

The senior engineer's default

Start with a contiguous array. Reach for a linked list only when you can name the specific operation whose Big-O improvement justifies the cache-miss tax on every other operation — and verify that operation is on the hot path with a profiler. "Linked list because computer science said so" is, in 2026, almost always the wrong answer.

Pitfalls and Performance Traps

1. The accidental O(n²) append loop

🐍python
1# WRONG: append walks to the tail every time
2for v in values:                # values has length n
3    head = append_no_tail(head, v)   # O(n) per call → O(n^2) total
4
5# RIGHT: keep a tail pointer, OR build in reverse and reverse once
6tail = None
7for v in values:
8    node = Node(v)
9    if head is None:
10        head = tail = node
11    else:
12        tail.next = node
13        tail = node

2. Reversing without saving next first

🐍python
1# WRONG: we clobber curr.next BEFORE saving it
2prev = None
3curr = head
4while curr is not None:
5    curr.next = prev      # destroys the link to the rest of the list!
6    prev = curr
7    curr = curr.next      # now points at prev — infinite loop
8
9# RIGHT: save nxt, then rewire
10prev = None
11curr = head
12while curr is not None:
13    nxt = curr.next       # save BEFORE we overwrite
14    curr.next = prev
15    prev = curr
16    curr = nxt

3. Forgetting to update tail on delete-tail

After deleting the last node, tail is dangling. Production bug: the next append writes through the old (freed) tail. Always update tail in lockstep with the structural change.

4. Concatenation without both tail pointers

a.tail.next = b.head is O(1)O(1) only if you have a.tail. Without it, you walk a first — back to O(n)O(n). The whole point of a tail pointer is to make this case constant time; do not lose it.

5. Caching length but updating it inconsistently

Cached size is one of the most common sources of correctness bugs. Every mutation method must update the count. A single forgotten increment in insert_after and your iterator runs short by one. Either cache it religiously or compute it on demand — never half-and-half.

Memory: the silent killer

Each node is an independent allocation. On a 64-bit system, a Node{value, next} with an int payload is ~24 bytes of useful data plus ~16 bytes of allocator overhead — a 40-byte node for 4 bytes of payload (10× overhead). The same data in an int[] is 4 bytes per element. For million-element collections, the linked list uses 10× the RAM and runs 10× slower. Choose accordingly.

Quick Checks

Quick Check

A junior engineer writes a function that appends n items to a singly linked list with no tail pointer. What is the total time complexity, and why?

Quick Check

You have two sorted singly linked lists of length 100 each. What is the time complexity of merging them with the dummy-head algorithm?

Quick Check

In the LRU cache pattern (doubly linked list + hashmap), what makes 'move recently-used item to front' an O(1) operation?


With the operations catalogued and their real costs understood, we are ready to study the algorithms that make linked lists genuinely indispensable. The next section dives into the two-pointer family: Floyd's cycle detection, finding the middle, finding the cycle entry, and the deeper invariant-based proofs that show why these tricks always work.

Loading comments...