Module 7 · Linked Lists
Build a Linked List From Scratch
Concept~8 min
The node, then the list
The node is two fields. The list wraps a head (and a tail, to make push-back O(1)) plus a size counter — all invariants we'll maintain explicitly:
```python
class Node:
def __init__(self, value, next=None):
self.value = value
self.next = next
class LinkedList:
def __init__(self) -> None:
self.head: Node | None = None
self.tail: Node | None = None
self.size = 0
def push_front(self, value) -> None: # O(1)
self.head = Node(value, next=self.head)
if self.tail is None: # was empty
self.tail = self.head
self.size += 1
def push_back(self, value) -> None: # O(1) via tail
node = Node(value)
if self.tail is None: # empty list
self.head = self.tail = node
else:
self.tail.next = node # old tail links on
self.tail = node
self.size += 1
def find(self, value) -> Node | None: # O(n)
curr = self.head
while curr is not None:
if curr.value == value:
return curr
curr = curr.next
return None
def delete(self, value) -> bool: # O(n)
prev, curr = None, self.head
while curr is not None:
if curr.value == value:
if prev is None: # deleting the head
self.head = curr.next
else:
prev.next = curr.next # splice curr out
if curr is self.tail: # deleting the tail
self.tail = prev
self.size -= 1
return True
prev, curr = curr, curr.next
return False
def to_list(self) -> list: # O(n) — for testing
out, curr = [], self.head
while curr is not None:
out.append(curr.value)
curr = curr.next
return out
```
```typescript
class ListNode<T> {
constructor(
public value: T,
public next: ListNode<T> | null = null,
) {}
}
class LinkedList<T> {
head: ListNode<T> | null = null;
tail: ListNode<T> | null = null;
size = 0;
pushFront(value: T): void {
// O(1)
this.head = new ListNode(value, this.head);
if (this.tail === null) this.tail = this.head; // was empty
this.size++;
}
pushBack(value: T): void {
// O(1) via tail
const node = new ListNode(value);
if (this.tail === null) {
this.head = this.tail = node; // empty list
} else {
this.tail.next = node; // old tail links on
this.tail = node;
}
this.size++;
}
find(value: T): ListNode<T> | null {
// O(n)
let curr = this.head;
while (curr !== null) {
if (curr.value === value) return curr;
curr = curr.next;
}
return null;
}
delete(value: T): boolean {
// O(n)
let prev: ListNode<T> | null = null;
let curr = this.head;
while (curr !== null) {
if (curr.value === value) {
if (prev === null) this.head = curr.next; // deleting the head
else prev.next = curr.next; // splice curr out
if (curr === this.tail) this.tail = prev; // deleting the tail
this.size--;
return true;
}
prev = curr;
curr = curr.next;
}
return false;
}
toArray(): T[] {
// O(n) — for testing
const out: T[] = [];
for (let curr = this.head; curr !== null; curr = curr.next) {
out.push(curr.value);
}
return out;
}
}
```Read it against the invariants
The class maintains three promises, and every method must uphold all of them — this is the discipline the quiz probes:
- head reaches everything: following
nextfrom head visits every node, ending at null. - tail is the last node (null iff empty) — the price of O(1)
push_back is remembering to update tail in every method that can
touch the end (see delete's
curr is tailbranch — the classic forgotten case). Skip that branch and the bug doesn't crash anything:tailis left pointing at the node that was just spliced out — a node no longer reachable fromhead. The nextpush_backreads that staletail, links the new node onto it (tail.next = node), and the new node is now unreachable too — hanging off a ghost, silently dropped from the list. The invariant breaks quietly at delete and the damage only surfaces later, at the next push_back. - size is the node count.
Notice the shape of delete: a (prev, curr) pair walking in
lockstep, because splicing curr out requires writing to
prev.next — a singly linked list can never edit what it's standing on,
only what's ahead of a node it holds. In the previous lesson's
scavenger-hunt terms: removing clue curr from the hunt means rewriting
the PREVIOUS clue's instructions to point past it — you can't rewrite a
clue's own instructions from the clue itself, only from whichever clue
sent you there. That asymmetry drives every pattern in the next lesson.
Trace delete(3) on the list [7, 3, 12] (head = 7, tail = 12,
size = 3) to see the splice concretely:
- Setup.
prev = None,curr = head(node7). - Step 1.
curr.value(7) ≠3— no match. Advance:prev = curr(node7),curr = curr.next(node3). - Step 2 (match).
curr.value(3) ==3.previs notNone, so splice:prev.next = curr.next— node7'snextnow points straight to node12, skipping node3entirely.curr(node3) is nottail, sotailis untouched.sizebecomes2. - Result.
head(7) →12→None. Node3still technically exists in memory with its ownnextpointing at12, but nothing reachable fromheadpoints at node3anymore — like a clue still physically pinned to a wall somewhere, but no earlier clue in the hunt sends anyone to it. It's garbage, reclaimed the next time the language's memory manager runs.
The special cases are the lesson
Count the branches: empty list (push_back), deleting the head (no prev), deleting the tail (tail must retreat). Each exists because the operation touches a boundary where a pointer we normally rewire doesn't exist. The next lesson's dummy-node trick makes most of these branches vanish — by making the boundary itself a normal node.
Complexity
| Operation | Cost | Why |
|---|---|---|
| push_front / push_back | O(1) | constant pointer writes; tail pointer prevents the O(n) walk |
| find / delete by value | O(n) | must walk; delete's splice itself is O(1) once found |
| space | O(n) + pointer overhead | one next-reference per node — real overhead arrays don't pay |
Check yourself
3 questions
Why does delete walk a (prev, curr) PAIR instead of just curr?
push_back forgot the if (curr === tail) tail = prev branch in delete. What breaks, and when?
A million-int array vs a million-int singly linked list — which uses meaningfully more memory, and why?