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.
Try It Yourself
13 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.
Write 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.
What 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.
Mini-Challenge · 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.
Homework
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.