Learning Goals
3 minBy the end of this lesson you can:
- Define a
Nodeclass withvalueandnext. - Wire nodes into a singly-linked list.
- Traverse the list to print, count, find, or sum its contents.
- Explain why
list[i]on a linked list is O(n) and on a Python list is O(1).
Warm-Up · A Chain of Boxes
5 minEach node is a small box: ┌───┬───┐ ┌───┬───┐ ┌───┬───┐ ┌───┬───┐ │ 1 │ ●─┼──→ │ 2 │ ●─┼──→ │ 3 │ ●─┼──→ │ 4 │ Nil│ └───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘ The list is a reference to the FIRST node (the head). Each node knows only its value and the next node. The last node's "next" is None — the end marker.
Compare to a Python list: contiguous memory, index access in O(1), but resizing is expensive. Linked list: scattered nodes, index access is O(n) (walk every node), but insertion/deletion in the middle is O(1) if you have the surrounding node.
Different shapes have different trade-offs. A linked list is the simplest dynamic data structure — you'll see what Python's list does for you by writing it yourself.
New Concept · The Node Class
14 minThe Node — two attributes
class Node: def __init__(self, value, next=None): self.value = value self.next = next def __repr__(self): return f"Node({self.value!r})"
That's it. A Node holds a value and a reference to the next node (or None if it's the last). The whole linked list is just nodes pointing at each other.
Building a list manually
# Build 1 → 2 → 3 n3 = Node(3) n2 = Node(2, next=n3) n1 = Node(1, next=n2) # The "head" of the list is n1 head = n1 # Walk it current = head while current is not None: print(current.value) current = current.next # 1 # 2 # 3
Traversal is a while current is not None: current = current.next loop. The body does whatever you need; the loop walks the chain.
A LinkedList wrapper
To make this usable, wrap it. The wrapper holds the head; the user calls methods.
class LinkedList: def __init__(self): self.head = None def push_front(self, value): """O(1) — new head.""" self.head = Node(value, next=self.head) def __iter__(self): current = self.head while current is not None: yield current.value current = current.next def __len__(self): n = 0 for _ in self: n += 1 return n def __repr__(self): return "LinkedList[" + " → ".join(str(v) for v in self) + "]"
__iter__ as a generator makes the list usable in for, sum, list(), comprehensions — all the iterable goodies from PY-L3-26-28.
Three common traversals
def length(head): n = 0 cur = head while cur is not None: n += 1 cur = cur.next return n def find(head, target): cur = head while cur is not None: if cur.value == target: return cur cur = cur.next return None def sum_of(head): total = 0 cur = head while cur is not None: total += cur.value cur = cur.next return total
Same walking shape, different operation. All O(n) — every node visited.
Why list[i] on a linked list is O(n)
There's no random access. To reach index 5, you walk 5 nodes. Python's list is a contiguous array — index access is constant time. That's why we can't cheaply slice a linked list either; we'd have to walk the start, then walk to the end of the slice.
Why bother, then?
Insertion / deletion in the middle is O(1) if you have a reference to the node — no shifting needed. Python lists need O(n) for that. For workloads with lots of middle-edits and few index accesses, linked lists win. For everything else (most code), Python's built-in list is better.
Worked Example · The Full LinkedList Class
12 minSave as linked_list.py:
# linked_list.py — singly-linked list, read-only operations class Node: def __init__(self, value, next=None): self.value = value self.next = next def __repr__(self): return f"Node({self.value!r})" class LinkedList: def __init__(self, iterable=None): self.head = None if iterable is not None: for item in reversed(list(iterable)): self.push_front(item) def push_front(self, value): self.head = Node(value, next=self.head) def append(self, value): """O(n) — find the tail and add.""" new_node = Node(value) if self.head is None: self.head = new_node return cur = self.head while cur.next is not None: cur = cur.next cur.next = new_node def __iter__(self): cur = self.head while cur is not None: yield cur.value cur = cur.next def __len__(self): return sum(1 for _ in self) def __contains__(self, value): return any(v == value for v in self) def __getitem__(self, i): """O(n) — walk to index i.""" if i < 0: raise IndexError("Negative indices not supported") for n, v in enumerate(self): if n == i: return v raise IndexError("LinkedList index out of range") def __repr__(self): return "[" + " → ".join(str(v) for v in self) + "]" # Build and explore ll = LinkedList([10, 20, 30, 40, 50]) print(ll) # [10 → 20 → 30 → 40 → 50] print(len(ll)) # 5 print(20 in ll) # True print(ll[2]) # 30 (O(n) lookup) print(sum(ll)) # 150 print(list(ll)) # [10, 20, 30, 40, 50] ll.push_front(0) print(ll) # [0 → 10 → 20 → 30 → 40 → 50] ll.append(60) print(ll) # [0 → 10 → 20 → 30 → 40 → 50 → 60]
Read the diff
One Node class, one LinkedList class with eight methods. __iter__ as a generator gives all the iterable conveniences. __contains__ and __getitem__ use the iterator. push_front is O(1) (just reassign head); append is O(n) (walk to tail). Tomorrow we'll add insert and delete.
Basic
5 minWrite max_of(head) that returns the largest value in a linked list (assume non-empty).
Hint
def max_of(head): biggest = head.value cur = head.next while cur is not None: if cur.value > biggest: biggest = cur.value cur = cur.next return biggest
Or use the iterable: max(ll). The class's __iter__ makes this work.
Challenge 1
4 minWrite to_list_reversed(head) that returns the values in reverse order — without modifying the list.
Hint
def to_list_reversed(head): out = [] cur = head while cur is not None: out.append(cur.value) cur = cur.next return out[::-1] # Or recursively: def to_list_reversed_rec(node): if node is None: return [] return to_list_reversed_rec(node.next) + [node.value]
Iteratively: collect forward, reverse with [::-1]. Recursively: go deeper first, then build on the way back. The recursive version is elegant but Python's recursion limit caps it around 1000 nodes.
Challenge 2
4 minWhat if the last node accidentally points back to an earlier node? You'd loop forever. Write has_cycle(head) that detects this without using a set of seen nodes.
Hint · Floyd's tortoise and hare
def has_cycle(head): slow = head fast = head while fast is not None and fast.next is not None: slow = slow.next fast = fast.next.next if slow is fast: return True return False
Two pointers: slow moves one step, fast moves two. If there's a cycle, fast eventually laps slow. If there's no cycle, fast reaches None. Floyd's algorithm — discovered in 1967, still the canonical cycle-detection technique.
Challenge 3 · Race the Lists
8 minCompare your LinkedList with a Python list:
- Time 100,000
appendcalls on each. The Python list should be ~100× faster (O(1) amortised vs O(n) per append). - Time 100,000
push_frontcalls on each. The linked list should be ~100× faster (O(1) vs O(n) shifts).
Show one possible solution
import time # 1 — append race N = 100_000 start = time.perf_counter() py_list = [] for i in range(N): py_list.append(i) t_py = (time.perf_counter() - start) * 1000 start = time.perf_counter() ll = LinkedList() for i in range(N): ll.append(i) t_ll = (time.perf_counter() - start) * 1000 print(f"Append N={N}:") print(f" python list : {t_py:>8.2f} ms") print(f" linked list : {t_ll:>8.2f} ms ({t_ll / t_py:.0f}x slower)") # 2 — push_front race start = time.perf_counter() py_list = [] for i in range(N): py_list.insert(0, i) t_py = (time.perf_counter() - start) * 1000 start = time.perf_counter() ll = LinkedList() for i in range(N): ll.push_front(i) t_ll = (time.perf_counter() - start) * 1000 print(f"\nPush-front N={N}:") print(f" python list : {t_py:>8.2f} ms") print(f" linked list : {t_ll:>8.2f} ms ({t_py / t_ll:.0f}x faster)")
Non-negotiables: append race + push-front race. The linked list's append is O(n) per call — that's O(n²) total for N appends. Hence the dramatic slowness. Conversely, prepending to a Python list is also O(n) per call. Different shapes, different trade-offs.
Recap
3 minA linked list is a chain of nodes. Each node holds a value and a pointer to the next. The whole list is a reference to the head node; the end is marked by None. Traversal is a while current is not None: current = current.next loop. Indexing is O(n). Push-front is O(1). Linked lists shine for middle insertions (tomorrow) and as a teaching device for understanding what Python's list hides.
Vocabulary Card
- node
- A small object holding a value and a reference to the next node.
- head
- The first node of the list. The whole list is a reference to this.
- singly linked
- Nodes only point forward. Doubly-linked would have a
prevtoo. - traversal
- The walking-the-chain loop. Same shape for length, find, sum, max, anything.
Extra Mission
4 minAdd three traversal-based methods to your LinkedList:
count_of(value)— how many times does a value appear?index_of(value)— the index of the first occurrence, or -1.to_string(sep=" → ")— return the values joined with a separator.
Sample · three more methods
# Add to LinkedList: def count_of(self, value): return sum(1 for v in self if v == value) def index_of(self, value): for i, v in enumerate(self): if v == value: return i return -1 def to_string(self, sep=" → "): return sep.join(str(v) for v in self) # Test ll = LinkedList([10, 20, 30, 20, 40]) print(ll.count_of(20)) # 2 print(ll.index_of(30)) # 2 print(ll.index_of(99)) # -1 print(ll.to_string()) # 10 → 20 → 30 → 20 → 40 print(ll.to_string(sep=", ")) # 10, 20, 30, 20, 40
Non-negotiables: each method uses the iterator. The class works with all the iterable-friendly tools — sum, enumerate, comprehensions. That's the payoff of defining __iter__ in PY-L3-26.