Back to the course
Data Structures & Algorithms · Module 03 of 10

Linked Lists

Nodes, pointers, and dynamic memory — the pointer-juggling skills trees and graphs depend on.

Build
Generic list with head/tail
Solve
Reverse, cycle detection, merge, n-th from end
The finish line — what go test shows once every exercise in this module passes
$ go test -v ./03-linked-lists/ | grep '^--- ' --- PASS: TestPushAndPop (0.00s) --- PASS: TestContainsRemove (0.00s) --- PASS: TestReverse (0.00s) --- PASS: TestMiddle (0.00s) --- PASS: TestHasCycle (0.00s) --- PASS: TestMergeSorted (0.00s) --- PASS: TestRemoveNthFromEnd (0.00s)
Module 3 · tests 7
WHY

Why it matters.

A linked list stores each element in its own node, with a pointer to the next one. Nodes can live anywhere in memory. That makes inserting or removing a node O(1) once you're holding a pointer to the spot — no shifting — but finding the spot is O(n), and scattered nodes are hard on the CPU cache.

In everyday Go you'll reach for a slice instead almost every time. Linked lists earn their place in this course because they force you to reason carefully about pointers: what each one points to, in what order to update them, and what happens at the ends. Trees and graphs are the same skill with more pointers.

Where they still show up for real: LRU caches (a list plus a map), free lists in allocators, queues in lock-free code, and undo histories.

IDEAS

The core ideas.

Head and tail. You hold a pointer to the first node (head). Keeping a tail pointer too makes appends O(1).

The dummy (sentinel) node. Most linked-list bugs come from the special case "what if it's the head?" Putting a throwaway node in front of the head gives every real node a predecessor, so one loop handles every case.

Slow and fast pointers. Move one pointer one step and another two steps. When fast reaches the end, slow is at the middle. If there's a cycle, fast eventually laps slow and they meet (Floyd's algorithm).

Reversal with three pointers. Walk the list keeping prev, cur, and next. Save next, point cur back at prev, step forward. O(n) time, O(1) space.

COST

Cost of operations.

OperationSingly linked (with tail)Slice
Access i-th elementO(n)O(1)
Insert/remove at frontO(1)O(n)
Append at backO(1)O(1) amortized
Remove last elementO(n) — need its predecessorO(1)
Insert/remove after a known nodeO(1)O(n)
IN GO

Go specifics.

  • No manual memory management. When nothing points at a node anymore, the garbage collector reclaims it — no free. The bug you'll hit instead is a nil pointer dereference: always check n != nil (and n.Next != nil for two-step moves) before following a pointer.
  • Generics let one Node[T] serve every element type. Equality needs T comparable; ordering needs cmp.Ordered.
  • container/list in the standard library is a doubly linked list of any values. It's fine for an LRU cache; for most else, use a slice.
EXAMPLE

Worked example: pointer-to-pointer removal.

Instead of a dummy node, you can walk a pointer to the link that points at each node. Then "the head" is no longer special — it's just the first link:

func removeFirst[T comparable](head **Node[T], v T) bool {
	for link := head; *link != nil; link = &(*link).Next {
		if (*link).Val == v {
			*link = (*link).Next // bypass the node
			return true
		}
	}
	return false
}

Your Remove exercise also has to keep tail and size correct, which this snippet ignores — that's the part to get right.

PATTERNS

Patterns to recognize.

  • Dummy head — any time the head might change.
  • Slow/fast pointers — middle, cycle detection, "is it a palindrome".
  • Lead/trail pointers — n-th from the end in one pass.
  • Relink, don't copy — merging sorted lists by splicing nodes.
EXERCISES

Your exercises.

Part A builds LinkedList[T] with head and tail pointers; the tests check your invariants (size matches reachable nodes, tail really is the last node) after every step. Part B is five problems on raw node chains.

go test ./03-linked-lists/

Draw boxes and arrows on paper for Reverse and Remove. Seriously — it's the fastest way to see why an update order is wrong.

The files for this module

Everything below is also in the course download, in 03-linked-lists/. Work in exercises.go; the tests decide when you’re done.

▶ types.go the structs and helpers you’re given · 54 lines›
// Package linkedlist is Module 3: singly linked lists.
//
// Go's standard library has container/list (a doubly linked list), but you
// rarely want it in practice — slices win on cache locality. Linked lists
// are here because they teach pointer manipulation, which trees and graphs
// build on.
package linkedlist

// Node is one element of a singly linked chain.
type Node[T any] struct {
	Val  T
	Next *Node[T]
}

// LinkedList keeps a head and tail pointer so both ends are O(1) to append to.
// Invariants your methods must keep:
//   - size == number of nodes reachable from head
//   - tail is the last node (tail.Next == nil), or nil when the list is empty
type LinkedList[T comparable] struct {
	head *Node[T]
	tail *Node[T]
	size int
}

// Len returns the number of elements.
func (l *LinkedList[T]) Len() int { return l.size }

// Values returns the elements from head to tail.
func (l *LinkedList[T]) Values() []T { return ToSlice(l.head) }

// FromSlice builds a chain of nodes and returns its head (nil for empty).
func FromSlice[T any](vals []T) *Node[T] {
	var head, tail *Node[T]
	for _, v := range vals {
		n := &Node[T]{Val: v}
		if head == nil {
			head = n
		} else {
			tail.Next = n
		}
		tail = n
	}
	return head
}

// ToSlice walks a chain from head. It stops after 10,000 nodes so an
// accidental cycle shows up as a wrong answer instead of a hung test.
func ToSlice[T any](head *Node[T]) []T {
	var out []T
	for n := head; n != nil && len(out) < 10_000; n = n.Next {
		out = append(out, n.Val)
	}
	return out
}
exercises.go — your work: every function is a stub marked TODO83 lines
//go:build !solutions

package linkedlist

import "cmp"

// ============================================================
// Part A — build the list (see types.go for the struct and invariants)
// ============================================================

// PushFront adds v at the head. Target: O(1).
func (l *LinkedList[T]) PushFront(v T) {
	// TODO
}

// PushBack adds v at the tail. Target: O(1) — that's what tail is for.
func (l *LinkedList[T]) PushBack(v T) {
	// TODO
}

// PopFront removes and returns the head value, or (zero, false) if empty.
// Don't forget the tail when the list becomes empty. Target: O(1).
func (l *LinkedList[T]) PopFront() (T, bool) {
	// TODO
	var zero T
	return zero, false
}

// Contains reports whether v is in the list. Target: O(n).
func (l *LinkedList[T]) Contains(v T) bool {
	// TODO
	return false
}

// Remove deletes the first node holding v and reports whether one was found.
// Watch the edge cases: removing the head, the tail, and the only node.
// Hint: a dummy node in front of head removes most of them. Target: O(n).
func (l *LinkedList[T]) Remove(v T) bool {
	// TODO
	return false
}

// ============================================================
// Part B — pointer problems on raw node chains
// ============================================================

// Reverse reverses the chain in place and returns the new head.
// Pattern: three pointers (prev, cur, next). Target: O(n) time, O(1) space.
func Reverse[T any](head *Node[T]) *Node[T] {
	// TODO
	return nil
}

// Middle returns the middle node; for an even length, the second of the two
// middles. 1→2→3→4→5 gives 3, 1→2→3→4 gives 3.
// Pattern: slow/fast pointers. Target: one pass, O(1) space.
func Middle[T any](head *Node[T]) *Node[T] {
	// TODO
	return nil
}

// HasCycle reports whether following Next ever revisits a node.
// Pattern: Floyd's tortoise and hare. Target: O(n) time, O(1) space.
func HasCycle[T any](head *Node[T]) bool {
	// TODO
	return false
}

// MergeSorted splices two ascending chains into one ascending chain by
// relinking nodes (don't allocate new ones, except an optional dummy).
// Target: O(n+m).
func MergeSorted[T cmp.Ordered](a, b *Node[T]) *Node[T] {
	// TODO
	return nil
}

// RemoveNthFromEnd removes the n-th node from the end (n=1 is the last
// node) and returns the head. If n exceeds the length, return head unchanged.
// Pattern: a lead pointer n steps ahead. Target: one pass.
func RemoveNthFromEnd[T any](head *Node[T], n int) *Node[T] {
	// TODO
	return nil
}
▶ linkedlist_test.go the tests — the same file checks yours or the reference · 172 lines›
package linkedlist

import (
	"slices"
	"testing"
)

func checkInvariants[T comparable](t *testing.T, l *LinkedList[T]) {
	t.Helper()
	n := len(l.Values())
	if n != l.size {
		t.Errorf("size = %d but %d nodes are reachable from head", l.size, n)
	}
	if (l.head == nil) != (l.tail == nil) {
		t.Errorf("head and tail must be nil together (head nil: %v, tail nil: %v)", l.head == nil, l.tail == nil)
	}
	if l.tail != nil && l.tail.Next != nil {
		t.Error("tail.Next should be nil")
	}
	if l.head != nil {
		last := l.head
		for last.Next != nil {
			last = last.Next
		}
		if last != l.tail {
			t.Error("tail does not point at the last node")
		}
	}
}

func TestPushAndPop(t *testing.T) {
	var l LinkedList[int]
	l.PushBack(2)
	l.PushBack(3)
	l.PushFront(1)
	if got, want := l.Values(), []int{1, 2, 3}; !slices.Equal(got, want) {
		t.Fatalf("Values() = %v, want %v", got, want)
	}
	checkInvariants(t, &l)
	for _, want := range []int{1, 2, 3} {
		if v, ok := l.PopFront(); !ok || v != want {
			t.Fatalf("PopFront() = (%d, %v), want (%d, true)", v, ok, want)
		}
		checkInvariants(t, &l)
	}
	if _, ok := l.PopFront(); ok {
		t.Error("PopFront() on empty list should return ok=false")
	}
	l.PushBack(9) // tail must have been reset for this to work
	if got := l.Values(); !slices.Equal(got, []int{9}) {
		t.Errorf("after emptying and PushBack(9): %v, want [9]", got)
	}
	checkInvariants(t, &l)
}

func TestContainsRemove(t *testing.T) {
	var l LinkedList[string]
	for _, s := range []string{"a", "b", "c", "d"} {
		l.PushBack(s)
	}
	if !l.Contains("c") || l.Contains("z") {
		t.Fatal("Contains gave the wrong answer")
	}
	steps := []struct {
		remove string
		ok     bool
		want   []string
	}{
		{"a", true, []string{"b", "c", "d"}}, // head
		{"d", true, []string{"b", "c"}},      // tail
		{"x", false, []string{"b", "c"}},     // missing
		{"b", true, []string{"c"}},
		{"c", true, nil}, // only node
	}
	for _, s := range steps {
		if ok := l.Remove(s.remove); ok != s.ok {
			t.Fatalf("Remove(%q) = %v, want %v", s.remove, ok, s.ok)
		}
		if got := l.Values(); !slices.Equal(got, s.want) {
			t.Fatalf("after Remove(%q): %v, want %v", s.remove, got, s.want)
		}
		checkInvariants(t, &l)
	}
	l.PushBack("z")
	if got := l.Values(); !slices.Equal(got, []string{"z"}) {
		t.Errorf("PushBack after removing everything: %v, want [z]", got)
	}
}

func TestReverse(t *testing.T) {
	for _, in := range [][]int{{1, 2, 3, 4, 5}, {1, 2}, {1}, nil} {
		want := slices.Clone(in)
		slices.Reverse(want)
		if got := ToSlice(Reverse(FromSlice(in))); !slices.Equal(got, want) {
			t.Errorf("Reverse(%v) = %v, want %v", in, got, want)
		}
	}
}

func TestMiddle(t *testing.T) {
	for _, tc := range []struct {
		in   []int
		want int
	}{
		{[]int{1, 2, 3, 4, 5}, 3},
		{[]int{1, 2, 3, 4}, 3},
		{[]int{1}, 1},
	} {
		m := Middle(FromSlice(tc.in))
		if m == nil || m.Val != tc.want {
			t.Errorf("Middle(%v) = %v, want node %d", tc.in, m, tc.want)
		}
	}
	if Middle[int](nil) != nil {
		t.Error("Middle(nil) should be nil")
	}
}

func TestHasCycle(t *testing.T) {
	head := FromSlice([]int{1, 2, 3, 4, 5})
	if HasCycle(head) {
		t.Error("straight list reported as a cycle")
	}
	tail := head
	for tail.Next != nil {
		tail = tail.Next
	}
	tail.Next = head.Next.Next // 5 -> 3
	if !HasCycle(head) {
		t.Error("cycle 5->3 not detected")
	}
	self := &Node[int]{Val: 1}
	self.Next = self
	if !HasCycle(self) {
		t.Error("self-loop not detected")
	}
	if HasCycle[int](nil) {
		t.Error("nil list reported as a cycle")
	}
}

func TestMergeSorted(t *testing.T) {
	for _, tc := range []struct{ a, b, want []int }{
		{[]int{1, 3, 5}, []int{2, 4, 6}, []int{1, 2, 3, 4, 5, 6}},
		{[]int{1, 1, 9}, []int{1, 2}, []int{1, 1, 1, 2, 9}},
		{nil, []int{4}, []int{4}},
		{[]int{0}, nil, []int{0}},
		{nil, nil, nil},
	} {
		if got := ToSlice(MergeSorted(FromSlice(tc.a), FromSlice(tc.b))); !slices.Equal(got, tc.want) {
			t.Errorf("MergeSorted(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want)
		}
	}
}

func TestRemoveNthFromEnd(t *testing.T) {
	for _, tc := range []struct {
		in   []int
		n    int
		want []int
	}{
		{[]int{1, 2, 3, 4, 5}, 2, []int{1, 2, 3, 5}},
		{[]int{1, 2, 3, 4, 5}, 5, []int{2, 3, 4, 5}},
		{[]int{1, 2, 3, 4, 5}, 1, []int{1, 2, 3, 4}},
		{[]int{1}, 1, nil},
		{[]int{1, 2}, 3, []int{1, 2}},
	} {
		if got := ToSlice(RemoveNthFromEnd(FromSlice(tc.in), tc.n)); !slices.Equal(got, tc.want) {
			t.Errorf("RemoveNthFromEnd(%v, %d) = %v, want %v", tc.in, tc.n, got, tc.want)
		}
	}
}
Before you open the solution

Try not to open solution.go until your tests pass, or until you’ve been stuck for a solid half hour. Then compare: different-but-correct is fine; notice where the reference is simpler. go test -tags solutions ./03-linked-lists/ runs the same tests against it.

▶ solution.go reference solutions — build tag solutions · 135 lines›
//go:build solutions

package linkedlist

import "cmp"

func (l *LinkedList[T]) PushFront(v T) {
	n := &Node[T]{Val: v, Next: l.head}
	l.head = n
	if l.tail == nil {
		l.tail = n
	}
	l.size++
}

func (l *LinkedList[T]) PushBack(v T) {
	n := &Node[T]{Val: v}
	if l.tail == nil {
		l.head, l.tail = n, n
	} else {
		l.tail.Next = n
		l.tail = n
	}
	l.size++
}

func (l *LinkedList[T]) PopFront() (T, bool) {
	var zero T
	if l.head == nil {
		return zero, false
	}
	n := l.head
	l.head = n.Next
	if l.head == nil {
		l.tail = nil
	}
	l.size--
	return n.Val, true
}

func (l *LinkedList[T]) Contains(v T) bool {
	for n := l.head; n != nil; n = n.Next {
		if n.Val == v {
			return true
		}
	}
	return false
}

func (l *LinkedList[T]) Remove(v T) bool {
	// A dummy node in front of head removes the "is it the head?" special case.
	dummy := &Node[T]{Next: l.head}
	for prev := dummy; prev.Next != nil; prev = prev.Next {
		if prev.Next.Val == v {
			if prev.Next == l.tail {
				l.tail = prev
			}
			prev.Next = prev.Next.Next
			l.head = dummy.Next
			if l.head == nil {
				l.tail = nil
			}
			l.size--
			return true
		}
	}
	return false
}

func Reverse[T any](head *Node[T]) *Node[T] {
	var prev *Node[T]
	for cur := head; cur != nil; {
		next := cur.Next
		cur.Next = prev
		prev, cur = cur, next
	}
	return prev
}

func Middle[T any](head *Node[T]) *Node[T] {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
	}
	return slow
}

func HasCycle[T any](head *Node[T]) bool {
	slow, fast := head, head
	for fast != nil && fast.Next != nil {
		slow = slow.Next
		fast = fast.Next.Next
		if slow == fast {
			return true
		}
	}
	return false
}

func MergeSorted[T cmp.Ordered](a, b *Node[T]) *Node[T] {
	dummy := &Node[T]{}
	tail := dummy
	for a != nil && b != nil {
		if a.Val <= b.Val {
			tail.Next, a = a, a.Next
		} else {
			tail.Next, b = b, b.Next
		}
		tail = tail.Next
	}
	if a != nil {
		tail.Next = a
	} else {
		tail.Next = b
	}
	return dummy.Next
}

func RemoveNthFromEnd[T any](head *Node[T], n int) *Node[T] {
	dummy := &Node[T]{Next: head}
	lead := dummy
	for i := 0; i < n; i++ {
		if lead.Next == nil {
			return head // n is larger than the list
		}
		lead = lead.Next
	}
	trail := dummy
	for lead.Next != nil {
		lead, trail = lead.Next, trail.Next
	}
	trail.Next = trail.Next.Next
	return dummy.Next
}
FURTHER

Going further.

  • Build a doubly linked LRUCache with Get and Put in O(1) using a list plus a map[K]*node.
  • LeetCode practice: Reorder List, Add Two Numbers, Copy List with Random Pointer.
QUIZ

Check yourself.

Three questions. Answer each one in your head — or out loud — before you open the answer.

Question 1

With head and tail pointers, which operation is still O(n) on a singly linked list?

  • APush at the front
  • BPush at the back
  • CPop from the front
  • DRemove the last node
Show the answer

D — Removing the last node means updating its predecessor's Next — and finding the predecessor requires walking from the head. A doubly linked list fixes this.

Question 2

What does a dummy node in front of the head buy you?

  • AFaster lookups
  • BEvery real node has a predecessor, so removing or inserting at the head needs no special case
  • CIt prevents cycles
  • DIt lets the garbage collector free nodes sooner
Show the answer

B — Most linked-list bugs are head edge cases. With a dummy, one loop handles them all; return dummy.Next at the end.

Question 3

Why is Floyd's cycle detection guaranteed to have fast meet slow inside a cycle?

  • AFast moves randomly
  • BOnce both are in the cycle, the gap between them shrinks by one each step
  • CSlow stops moving when it enters the cycle
  • DIt isn't guaranteed; you also need a visited set
Show the answer

B — Fast gains exactly one node per step on slow, so the distance hits zero within one lap. O(n) time, O(1) space.

NEXT

Where to next.