A singly linked list is a finite chain of heap-allocated nodes where each node carries a payload and exactly one outgoing pointer to its successor. From the outside it behaves like a sequence; from the inside it is a graph with out-degree exactly one and no cycles. The whole structure is reachable from a single external pointer called the head; the last node's pointer is the sentinel value None (Python) or nullptr (C++).
Section 6.1 introduced nodes and pointers in isolation. This section assembles them into a complete abstract data type with rigorous performance guarantees, an interactive lab to watch every pointer rewrite happen, and two production-grade implementations — one in Python and one in modern C++ — that survive code review at any serious shop.
Definition and Invariants
Formally, a singly linked list is a tuple with the following invariants. Let and .
- Termination. There exists a finite such that (None / nullptr). No cycles — a cycle would make the structure infinite under
.nexttraversal. - Reachability. Every node in the list is reachable from in hops. Lose head and the list becomes garbage.
- Tail consistency. If , then and . If then both head and tail are .
- Size cache. The stored size equals the number of reachable nodes from head. Every mutator must preserve this.
The Singly Linked List ADT
The minimal ADT we will implement covers every operation a singly linked list supports asymptotically well. Methods that would force a linear scan from head (e.g., access by index) are listed honestly with their cost so you can see why arrays and linked lists complement rather than replace each other.
| Operation | Complexity | What it costs structurally |
|---|---|---|
| is_empty() | O(1) | Check head is None |
| __len__() | O(1) | Return the cached size |
| prepend(v) | O(1) | One allocation, two pointer writes |
| append(v) with tail pointer | O(1) | One allocation, two pointer writes |
| append(v) without tail pointer | O(n) | Walk to the end first |
| insert_after(node, v) | O(1) | Splice with no traversal — node already in hand |
| insert_at(i, v) | O(i) | Walk i steps, then O(1) splice |
| delete_head() | O(1) | Move head one hop |
| delete_after(node) | O(1) | Skip one node — no traversal |
| delete_value(v) | O(n) | Two-pointer walk to find, then unlink |
| find(v) | O(n) | Linear scan from head |
| reverse() | O(n) time, O(1) space | Walk once, flip every next pointer |
| __iter__() | O(n) total | One yield per hop |
| to_list() | O(n) | Eager copy into a Python list |
prepend, insert_after, and delete_after — all of them require either "at the head" or "at a node you already hold". Index-based access loses to arrays by an order of magnitude. Choose the structure that matches your dominant access pattern.The Two-Pointer Traversal Idiom
A singly linked list's deepest constraint is that given a node, you can only see forward. There is no .prev pointer. Yet many mutations need to know the predecessor: to delete a node you must rewrite its predecessor's .next. The fix is the two-pointer walk: maintain a cursor and a one-step lagging cursor that marches with it.
1prev, curr = None, head
2while curr is not None and not condition(curr):
3 prev, curr = curr, curr.next
4# At this point: prev is the predecessor of curr (or None if curr == head).Two facts make this idiom safe and idiomatic. First, the tuple assignment prev, curr = curr, curr.next evaluates both right-hand expressions before any assignment, so we never lose a pointer. Second, prev is exactly when , which gives us a clean branch for the "deleting the head" special case. You will see this pattern in every singly-list mutator below.
The Tail Pointer: Making Append O(1)
Without a tail pointer, append must walk from head to find the last node before splicing — per call. Calling it times in a loop (a stunningly common pattern: building a list from a stream) costs . A tail pointer fixes this for one extra word of state and one extra line of bookkeeping per mutator.
The trade is real but small: every operation that touches the last node must update tail. Specifically:
appendsets tail to the new node.prependon an empty list sets tail to the new node.delete_headon a one-node list clears tail to None.delete_valuewhen the deleted node was the tail sets tail to its predecessor.reversesets tail to the old head before the loop.
head but forgetting tail (or vice versa). Symptom: a few operations later, an append writes to a node that is no longer in the list. Lists with a tail pointer should be tested with the four boundary cases — insert into empty, delete the only element, append after empty, reverse a one-node list — on every change.The Sentinel Head Pattern
Most of the "is it the head?" special cases above can be eliminated by allocating one permanent dummy node and storing real values starting at dummy.next. The list then has the form
dummy → [v₁] → [v₂] → ... → [vₙ] → None
Now every real node has a predecessor, so every insertion and deletion uses the same code path. The cost is a single permanent node (16-32 extra bytes for the lifetime of the list). Linux's kernel-internal list (struct list_head) uses a sentinel for exactly this reason: the kernel cannot afford special cases sprinkled across hundreds of subsystems.
if head is None check is fine.In-Place Reversal — Step by Step
Reversing a singly linked list in time and extra space is the canonical linked-list interview problem and a microcosm of the entire data structure. The algorithm walks the list once, flipping every .next to point backward. Three named pointers carry the state: prev, curr, and nxt.
1def reverse(head):
2 prev = None
3 curr = head
4 while curr is not None:
5 nxt = curr.next # save successor BEFORE we clobber it
6 curr.next = prev # flip this node's pointer
7 prev = curr # advance the lagging pointer
8 curr = nxt # advance the leading pointer
9 return prev # new headThe loop body is the entire algorithm, and the order of those four lines is non-negotiable. Trace it on 1 → 2 → 3 → None:
| State | prev | curr | nxt | list seen from new head |
|---|---|---|---|---|
| initial | None | 1 | — | (building) |
| after iter 1 | 1 | 2 | 2 (saved) | 1 → None |
| after iter 2 | 2 | 3 | 3 (saved) | 2 → 1 → None |
| after iter 3 | 3 | None | None (saved) | 3 → 2 → 1 → None |
| return prev | — | — | — | head = 3 → 2 → 1 → None |
curr.next. If we hadn't already stashed it in nxt, line 4 would have no way to reach the rest of the list. This is the lost-pointer bug, and it's eliminated by always saving before clobbering. Most linked-list bugs are some variation on this rule.A Complete SinglyLinkedList in Python
Here is the full class. Every method is annotated below; every line of the algorithm is explained. Methods are grouped: queries (__len__, is_empty, find), mutators (prepend, append, insert_after, delete_head, delete_value, reverse), and Python protocol (__iter__, to_list).
Driver session that exercises every method:
1>>> lst = SinglyLinkedList()
2>>> lst.append(1); lst.append(2); lst.append(3)
3>>> lst.to_list()
4[1, 2, 3]
5>>> lst.prepend(0)
6>>> lst.to_list()
7[0, 1, 2, 3]
8>>> lst.delete_value(2)
9True
10>>> lst.to_list()
11[0, 1, 3]
12>>> lst.reverse()
13>>> lst.to_list()
14[3, 1, 0]
15>>> len(lst), lst.is_empty()
16(3, False)Modern C++ with unique_ptr Ownership
The same ADT in modern C++ teaches a lesson Python hides: ownership. In C, a singly linked list is a parade of malloc/free calls and every bug is a memory bug — double-free, use-after-free, leak, dangling pointer. In modern C++ we encode ownership in the type system with std::unique_ptr: each node owns its successor, and the destructor chain frees the entire list automatically when the head is destroyed.
unique_ptr chain owns; the tail_ raw pointer observes. Two owners of the same node would be a double-free; one owner plus any number of raw observers is correct.Interactive: Singly Linked List Lab
Drive every method by hand. Each operation animates the affected nodes — cyan for newcomers, rose for deletions, amber for finds, orange for pointer rewrites. The reverse (step) button performs the canonical algorithm one pointer-flip at a time, marking prev in green, curr in amber, and nxt in violet so you can watch the three-cursor dance end to end.
Singly Linked List Lab — head, optional tail, and pointer rewriting
size = 3Try the canonical exercises in this order:
- Append three values, then delete the head once. Watch the size and head update in O(1).
- Delete a value that lives in the middle. The two-pointer walk shows up in the log: the cost reads
O(k)wherekis the index of the match. - Click "reverse (step)" repeatedly on a list of 4 nodes. Confirm that
prevclimbs from None up the list whilecurrfalls off the back. After the last step,head = previnstalls the reversed list. - Find a value that isn't there. The log shows
O(n) walked all— an unsuccessful search has the same cost as a worst-case successful one.
Asymptotic Comparison vs Array
The asymptotic table tells half the story; the constant-factor table tells the rest. Big-O hides the cost of cache misses, and on real CPUs that cost dwarfs the structural difference for small .
| Operation | Dynamic Array | Singly Linked List | Winner |
|---|---|---|---|
| Random access lst[i] | O(1) | O(n) | array (by an order of magnitude) |
| Append at end | O(1) amortized | O(1) with tail ptr | tie |
| Prepend at front | O(n) (shift) | O(1) | list |
| Insert at known node/index | O(n) (shift) | O(1) with node, O(i) by index | list (when you hold the node) |
| Delete at known node | O(n) (shift) | O(1) with predecessor | list |
| Search by value | O(n) | O(n) | tie asymptotically; array wins on cache |
| Memory per element | ≈ sizeof(T) | sizeof(T) + 8-16 (next ptr) + allocator overhead | array (≈ 2-3× denser) |
| Cache locality | excellent (contiguous) | poor (each node is its own heap chunk) | array (often 5-20× faster scan) |
| Reallocation cost | occasional O(n) copy | none — nodes never move | list (when stable addresses matter) |
| Iterator stability under mutation | invalidated on resize | stable as long as node lives | list |
.next dereference is a cache miss. Choose linked lists for their structural advantages, not for raw scan throughput.Where Singly Linked Lists Run in Production
Singly linked lists are everywhere a system needs cheap insertion and doesn't need random access. A non-exhaustive tour of places this exact data structure runs on every machine you own:
- The Linux kernel uses intrusive singly-linked lists (
struct hlist_head) for hash-table chaining in the page cache, dentry cache, and futex hash. The singly-linked variant saves one pointer per node — multiplied by millions of dentries, that is tens of MB of kernel memory. - Hash table buckets in essentially every language runtime: Python's old-style collisions, Java's
HashMapat low load factors, Go'smapoverflow buckets, Redis's dictionary. A bucket is a singly linked list of(key, value)entries. - Adjacency lists in graphs. Each vertex stores a singly linked list of its outgoing edges. BFS and DFS (Chapter 17) walk these lists without needing random access, so the linear-only access pattern is fine.
- Memory allocators (jemalloc, tcmalloc, ptmalloc, and every kernel slab allocator) keep free lists: a singly linked list of available chunks. Allocation pops the head (O(1)); deallocation pushes onto the head (O(1)).
- LISP, Scheme, OCaml, Erlang, Haskell. The list is the primary native data structure.
conscells are nodes; the "head" of a list is itscar; the rest is itscdr. The whole functional-programming idiom of pattern-matching[head, ...tail]rests on this representation. - Operating-system process control blocks. The scheduler keeps runnable processes in a singly linked list per priority class; context switches dequeue from the head.
- Async task queues (Java's
ConcurrentLinkedQueue, libuv's pending-callback queue, Tokio's task queue): lock-free singly linked lists with atomic compare-and-swap on the head/tail pointers. - Music playlists with only "next track" semantics (no history): a singly linked list. Want a history button? Upgrade to a doubly linked list (next section).
- Stack frames in CPS-transformed compilers. Continuation-passing style explicitly represents the call stack as a singly linked list of activation records on the heap.
- Undo logs for transactional storage engines (SQLite's rollback journal, Postgres's xlog buffers): each transaction prepends a record describing how to undo the change.
Pitfalls and How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
| Forgetting to update head when deleting the head | Old head node still reachable after delete; size and content desync | Branch on `prev is None` and reassign head explicitly |
| Lost-pointer bug in reverse (clobber next before saving) | List truncates after first iteration; only one node remains | ALWAYS save curr.next into nxt BEFORE writing curr.next = prev |
| Memory leak in C when removing without free() | Heap grows unboundedly under repeated insert/remove | Always `free(doomed)` after unlinking; in C++ use unique_ptr to make it automatic |
| append called in a loop without a tail pointer | O(n^2) performance; building a 100k-element list takes seconds | Maintain a tail pointer; or build with prepend then reverse once |
| Forgetting to update tail after delete_value removes the last node | Next append writes into a node not in the list; later traversal corrupted | After unlink, check `if curr is self.tail: self.tail = prev` |
| Concurrent modification during iteration | Iterator returns deleted values, skips inserted ones, or segfaults in C | Don't share singly linked lists across threads without a lock; or use a lock-free variant |
| Comparing nodes with == instead of `is` | Confuses identity (same node) with equality (same value); subtle bugs in delete logic | Use `is` / `is not` for node identity; reserve `==` for value comparison |
| Building a cycle by accident | find / iter / __len__ never terminates; CPU hits 100% | Never assign a later node into an earlier node's next pointer; Floyd's tortoise-and-hare detects cycles in O(n) if you suspect one |
Quick Checks
Quick Check
You have head -> 1 -> 2 -> 3 -> 4 -> None. You run the canonical in-place reverse. After EXACTLY two iterations of the while loop have completed, what are the values of prev, curr, and the relevant pointer state?
Quick Check
A teammate's SinglyLinkedList has head and size but NO tail pointer. They call append in a tight loop to build a list of n elements. What is the total cost?
Singly linked lists give you insert and delete at any node you already hold, a stable iterator under mutation, and a footprint that scales linearly without ever copying. They lose random access and cache locality. The next two structural variants — doubly linked lists (Section 6.3) and circular linked lists (Section 6.4) — trade extra memory for backward traversal and sentinel-free wraparound. Section 6.5 then revisits the array-vs-list comparison with measured benchmarks instead of asymptotic hand-waving.