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

Graphs

Vertices and edges. BFS, DFS, topological order, Dijkstra, and union-find.

Build
Union-find
Solve
BFS, DFS, topological sort, Dijkstra, islands
The finish line — what go test shows once every exercise in this module passes
$ go test -v ./09-graphs/ | grep '^--- ' --- PASS: TestBFS (0.00s) --- PASS: TestDFS (0.00s) --- PASS: TestShortestPath (0.00s) --- PASS: TestTopoSort (0.00s) --- PASS: TestDijkstra (0.00s) --- PASS: TestNumIslands (0.00s) --- PASS: TestUnionFind (0.00s) --- PASS: TestCountComponents (0.00s)
Module 9 · tests 8
WHY

Why it matters.

A graph is the most general structure in this course: things (vertices) and relationships (edges). Road maps, networks, social connections, web links, and dependency trees are all graphs — go mod resolving your module graph, a build system ordering its steps, and routers picking paths are graph algorithms at work. Lists and trees are just graphs with restrictions.

IDEAS

The core ideas.

Kinds of graph. Directed (one-way edges) or undirected; weighted (edges carry a cost) or not; cyclic or acyclic. A directed acyclic graph (DAG) models dependencies.

Representation. An adjacency list stores each vertex's neighbors: O(V + E) space, and iterating neighbors is fast. An adjacency matrix is a V×V grid of booleans: O(V²) space, O(1) "is there an edge?". Use lists unless the graph is dense.

Breadth-first search explores in rings of increasing distance using a queue. In an unweighted graph, the first time BFS reaches a vertex is along a shortest path.

Depth-first search goes as deep as possible before backing up, using recursion or a stack. It's the tool for connectivity, cycle detection, and exhaustive search.

Topological sort orders a DAG so every edge points forward. Kahn's algorithm: repeatedly output a vertex with no remaining incoming edges and remove its outgoing ones. If vertices are left over, they're on a cycle.

Dijkstra's algorithm finds shortest paths with non-negative weights. Keep a priority queue of (distance, vertex); always settle the closest unsettled vertex next and relax its edges. With a binary heap: O((V + E) log V). Negative weights break it — use Bellman–Ford instead.

Union-find (disjoint set union) tracks which vertices are connected as edges arrive. With path compression and union by rank, each operation is effectively constant time.

COST

Cost of operations.

AlgorithmTimeUse it for
BFS / DFSO(V + E)Reachability, unweighted shortest path, components
Topological sortO(V + E)Ordering dependencies, detecting cycles
Dijkstra (binary heap)O((V + E) log V)Weighted shortest path, weights ≥ 0
Union-find, per op~O(1) amortizedDynamic connectivity, Kruskal's MST
IN GO

Go specifics.

  • [][]int adjacency lists are the idiomatic representation for vertices numbered 0..n-1. For named vertices, use map[string][]string.
  • visited as []bool when vertices are dense ints — faster than a map.
  • Grids are implicit graphs. Each cell is a vertex; its four neighbors are its edges. A direction array keeps the code tidy: [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}.
  • Dijkstra uses container/heap from Module 8. The simple version pushes duplicates and skips stale entries when popped ("lazy deletion") instead of decreasing keys in place.
  • Recursive DFS on a huge graph uses a lot of goroutine stack; switch to an explicit stack for millions of vertices.
EXAMPLE

Worked example: cycle detection in a directed graph.

Three-color DFS: white = unvisited, gray = on the current path, black = finished. Reaching a gray vertex means you've looped back onto your own path:

func hasCycle(adj [][]int) bool {
	const white, gray, black = 0, 1, 2
	color := make([]int, len(adj))
	var visit func(u int) bool
	visit = func(u int) bool {
		color[u] = gray
		for _, v := range adj[u] {
			if color[v] == gray {
				return true // back edge: cycle
			}
			if color[v] == white && visit(v) {
				return true
			}
		}
		color[u] = black
		return false
	}
	for u := range adj {
		if color[u] == white && visit(u) {
			return true
		}
	}
	return false
}

Your TopoSort exercise detects cycles a different way (Kahn's leftovers) — knowing both is worth it.

PATTERNS

Patterns to recognize.

  • Shortest path, unweighted → BFS. Weighted, non-negative → Dijkstra.
  • "Can X be done before Y?" / build order → topological sort.
  • "How many groups?" / "are these connected?" → DFS flood fill or union-find.
  • Grid puzzles — islands, mazes, flood fill — are graph searches in disguise.
EXERCISES

Your exercises.

Part A: BFS, DFS, shortest path with parent pointers, and Kahn's topological sort. Part B: Dijkstra with container/heap. Part C: islands on a grid, and union-find with path compression.

go test ./09-graphs/

Traversal orders are deterministic because neighbors are visited in the order edges were added. The sample graph is drawn in a comment at the top of graphs_test.go.

The files for this module

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

▶ types.go the structs and helpers you’re given · 78 lines›
// Package graphs is Module 9: graphs.
//
// Vertices are ints 0..n-1 and edges live in adjacency lists, which is the
// representation you want for almost everything (an adjacency matrix only
// pays off for dense graphs). Neighbors are visited in the order edges were
// added, so traversal orders in the tests are deterministic.
package graphs

// Graph is an unweighted graph stored as adjacency lists.
type Graph struct {
	adj      [][]int
	directed bool
}

// NewGraph returns a graph with n vertices and no edges.
func NewGraph(n int, directed bool) *Graph {
	return &Graph{adj: make([][]int, n), directed: directed}
}

// AddEdge adds u->v (and v->u when the graph is undirected).
func (g *Graph) AddEdge(u, v int) {
	g.adj[u] = append(g.adj[u], v)
	if !g.directed {
		g.adj[v] = append(g.adj[v], u)
	}
}

// N returns the number of vertices.
func (g *Graph) N() int { return len(g.adj) }

// Neighbors returns u's adjacency list (don't modify it).
func (g *Graph) Neighbors(u int) []int { return g.adj[u] }

// Edge is a weighted, directed edge.
type Edge struct {
	To, Weight int
}

// WeightedGraph is a directed graph with non-negative int weights.
type WeightedGraph struct {
	adj [][]Edge
}

// NewWeightedGraph returns a weighted graph with n vertices and no edges.
func NewWeightedGraph(n int) *WeightedGraph {
	return &WeightedGraph{adj: make([][]Edge, n)}
}

// AddEdge adds a directed edge u->v with weight w.
func (g *WeightedGraph) AddEdge(u, v, w int) {
	g.adj[u] = append(g.adj[u], Edge{To: v, Weight: w})
}

// N returns the number of vertices.
func (g *WeightedGraph) N() int { return len(g.adj) }

// Neighbors returns u's outgoing edges (don't modify them).
func (g *WeightedGraph) Neighbors(u int) []Edge { return g.adj[u] }

// UnionFind (disjoint set union) tracks which elements are in the same set.
// parent[i] == i means i is a root. count is the number of distinct sets.
type UnionFind struct {
	parent []int
	rank   []int
	count  int
}

// NewUnionFind returns n singleton sets {0} {1} ... {n-1}.
func NewUnionFind(n int) *UnionFind {
	uf := &UnionFind{parent: make([]int, n), rank: make([]int, n), count: n}
	for i := range uf.parent {
		uf.parent[i] = i
	}
	return uf
}

// Count returns the number of disjoint sets.
func (uf *UnionFind) Count() int { return uf.count }
exercises.go — your work: every function is a stub marked TODO90 lines
//go:build !solutions

package graphs

// ============================================================
// Part A — traversals
// ============================================================

// BFS returns vertices in breadth-first order from start, visiting
// neighbors in adjacency-list order. Mark a vertex visited when you
// ENQUEUE it, not when you dequeue it, or it can be queued twice.
// Target: O(V + E).
func BFS(g *Graph, start int) []int {
	// TODO
	return nil
}

// DFS returns vertices in depth-first preorder from start (recursive is
// fine), visiting neighbors in adjacency-list order. Target: O(V + E).
func DFS(g *Graph, start int) []int {
	// TODO
	return nil
}

// ShortestPath returns the vertices on a shortest path from src to dst
// (inclusive) in an unweighted graph, or nil if dst is unreachable.
// Pattern: BFS recording each vertex's parent, then walk back from dst.
// Target: O(V + E).
func ShortestPath(g *Graph, src, dst int) []int {
	// TODO
	return nil
}

// TopoSort returns a topological order of a directed graph (every edge
// u->v has u before v), or (nil, false) if the graph has a cycle.
// Pattern: Kahn's algorithm — repeatedly take vertices with in-degree 0.
// Target: O(V + E).
func TopoSort(g *Graph) ([]int, bool) {
	// TODO
	return nil, false
}

// ============================================================
// Part B — weighted shortest paths
// ============================================================

// Dijkstra returns the shortest distance from src to every vertex, with -1
// for unreachable ones. Weights are non-negative.
// Pattern: priority queue (container/heap) of (vertex, distance); skip
// stale entries whose distance is worse than the best known.
// Target: O((V + E) log V).
func Dijkstra(g *WeightedGraph, src int) []int {
	// TODO
	return nil
}

// ============================================================
// Part C — grids and union-find
// ============================================================

// NumIslands counts groups of '1' cells connected up/down/left/right.
// A grid is just an implicit graph: each cell is a vertex, its four
// neighbors are its edges. Don't modify grid. Target: O(rows·cols).
func NumIslands(grid [][]byte) int {
	// TODO
	return 0
}

// Find returns the root of x's set. Apply path compression (point nodes
// closer to the root as you walk) so later Finds are nearly O(1).
func (uf *UnionFind) Find(x int) int {
	// TODO
	return x
}

// Union merges the sets containing a and b and returns true, or returns
// false if they were already in the same set. Attach the lower-rank root
// under the higher-rank one, and keep count up to date.
func (uf *UnionFind) Union(a, b int) bool {
	// TODO
	return false
}

// CountComponents returns how many connected components an undirected
// graph with n vertices and the given edges has. Use your UnionFind.
// Target: O(E · α(n)) — effectively linear.
func CountComponents(n int, edges [][2]int) int {
	// TODO
	return 0
}
▶ graphs_test.go the tests — the same file checks yours or the reference · 183 lines›
package graphs

import (
	"slices"
	"testing"
)

// sample is this undirected graph:
//
//	0 — 1 — 3
//	|   |
//	2 — 4    5 — 6
func sample() *Graph {
	g := NewGraph(7, false)
	for _, e := range [][2]int{{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 4}, {5, 6}} {
		g.AddEdge(e[0], e[1])
	}
	return g
}

func TestBFS(t *testing.T) {
	if got, want := BFS(sample(), 0), []int{0, 1, 2, 3, 4}; !slices.Equal(got, want) {
		t.Errorf("BFS(0) = %v, want %v", got, want)
	}
	if got, want := BFS(sample(), 5), []int{5, 6}; !slices.Equal(got, want) {
		t.Errorf("BFS(5) = %v, want %v", got, want)
	}
}

func TestDFS(t *testing.T) {
	if got, want := DFS(sample(), 0), []int{0, 1, 3, 4, 2}; !slices.Equal(got, want) {
		t.Errorf("DFS(0) = %v, want %v", got, want)
	}
	if got, want := DFS(sample(), 6), []int{6, 5}; !slices.Equal(got, want) {
		t.Errorf("DFS(6) = %v, want %v", got, want)
	}
}

func TestShortestPath(t *testing.T) {
	g := sample()
	for _, tc := range []struct {
		src, dst int
		wantLen  int // number of vertices on the path; 0 = unreachable
	}{
		{0, 3, 3}, {3, 2, 4}, {2, 2, 1}, {0, 6, 0},
	} {
		path := ShortestPath(g, tc.src, tc.dst)
		if tc.wantLen == 0 {
			if path != nil {
				t.Errorf("ShortestPath(%d, %d) = %v, want nil", tc.src, tc.dst, path)
			}
			continue
		}
		if len(path) != tc.wantLen || path[0] != tc.src || path[len(path)-1] != tc.dst {
			t.Errorf("ShortestPath(%d, %d) = %v, want %d vertices from %d to %d", tc.src, tc.dst, path, tc.wantLen, tc.src, tc.dst)
			continue
		}
		for i := 1; i < len(path); i++ {
			if !slices.Contains(g.Neighbors(path[i-1]), path[i]) {
				t.Errorf("ShortestPath(%d, %d) = %v: %d-%d is not an edge", tc.src, tc.dst, path, path[i-1], path[i])
			}
		}
	}
}

func TestTopoSort(t *testing.T) {
	// Course prerequisites: an edge u->v means "take u before v".
	g := NewGraph(6, true)
	edges := [][2]int{{5, 2}, {5, 0}, {4, 0}, {4, 1}, {2, 3}, {3, 1}}
	for _, e := range edges {
		g.AddEdge(e[0], e[1])
	}
	order, ok := TopoSort(g)
	if !ok || len(order) != 6 {
		t.Fatalf("TopoSort = (%v, %v), want a full order and true", order, ok)
	}
	pos := make(map[int]int)
	for i, v := range order {
		pos[v] = i
	}
	if len(pos) != 6 {
		t.Fatalf("TopoSort = %v: every vertex must appear exactly once", order)
	}
	for _, e := range edges {
		if pos[e[0]] > pos[e[1]] {
			t.Errorf("TopoSort = %v: edge %d->%d is violated", order, e[0], e[1])
		}
	}

	cyclic := NewGraph(3, true)
	cyclic.AddEdge(0, 1)
	cyclic.AddEdge(1, 2)
	cyclic.AddEdge(2, 0)
	if order, ok := TopoSort(cyclic); ok {
		t.Errorf("TopoSort on a cycle = (%v, true), want (nil, false)", order)
	}
}

func TestDijkstra(t *testing.T) {
	g := NewWeightedGraph(6)
	for _, e := range [][3]int{
		{0, 1, 7}, {0, 2, 9}, {0, 5, 14},
		{1, 2, 10}, {1, 3, 15},
		{2, 3, 11}, {2, 5, 2},
		{5, 4, 9}, {3, 4, 6},
	} {
		g.AddEdge(e[0], e[1], e[2])
	}
	if got, want := Dijkstra(g, 0), []int{0, 7, 9, 20, 20, 11}; !slices.Equal(got, want) {
		t.Errorf("Dijkstra(0) = %v, want %v", got, want)
	}
	if got, want := Dijkstra(g, 3), []int{-1, -1, -1, 0, 6, -1}; !slices.Equal(got, want) {
		t.Errorf("Dijkstra(3) = %v, want %v", got, want)
	}
}

func grid(rows ...string) [][]byte {
	out := make([][]byte, len(rows))
	for i, r := range rows {
		out[i] = []byte(r)
	}
	return out
}

func TestNumIslands(t *testing.T) {
	for _, tc := range []struct {
		g    [][]byte
		want int
	}{
		{grid("11110", "11010", "11000", "00000"), 1},
		{grid("11000", "11000", "00100", "00011"), 3},
		{grid("101", "010", "101"), 5},
		{grid("000"), 0},
		{nil, 0},
	} {
		before := slices.Clone(tc.g)
		for i := range before {
			before[i] = slices.Clone(tc.g[i])
		}
		if got := NumIslands(tc.g); got != tc.want {
			t.Errorf("NumIslands(%q) = %d, want %d", before, got, tc.want)
		}
		if !slices.EqualFunc(before, tc.g, slices.Equal[[]byte]) {
			t.Errorf("NumIslands modified its input grid")
		}
	}
}

func TestUnionFind(t *testing.T) {
	uf := NewUnionFind(6)
	if !uf.Union(0, 1) || !uf.Union(2, 3) || !uf.Union(1, 3) {
		t.Fatal("Union of separate sets should return true")
	}
	if uf.Union(0, 2) {
		t.Error("Union(0, 2) should return false: already connected via 1-3")
	}
	if uf.Find(0) != uf.Find(3) {
		t.Error("0 and 3 should share a root")
	}
	if uf.Find(4) == uf.Find(0) {
		t.Error("4 should not be connected to 0")
	}
	if uf.Count() != 3 {
		t.Errorf("Count() = %d, want 3 ({0,1,2,3} {4} {5})", uf.Count())
	}
}

func TestCountComponents(t *testing.T) {
	for _, tc := range []struct {
		n     int
		edges [][2]int
		want  int
	}{
		{5, [][2]int{{0, 1}, {1, 2}, {3, 4}}, 2},
		{5, [][2]int{{0, 1}, {1, 2}, {2, 3}, {3, 4}}, 1},
		{4, nil, 4},
		{3, [][2]int{{0, 1}, {1, 0}, {0, 1}}, 2},
	} {
		if got := CountComponents(tc.n, tc.edges); got != tc.want {
			t.Errorf("CountComponents(%d, %v) = %d, want %d", tc.n, tc.edges, 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 ./09-graphs/ runs the same tests against it.

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

package graphs

import (
	"container/heap"
	"slices"
)

func BFS(g *Graph, start int) []int {
	visited := make([]bool, g.N())
	visited[start] = true
	queue := []int{start}
	var order []int
	for len(queue) > 0 {
		u := queue[0]
		queue = queue[1:]
		order = append(order, u)
		for _, v := range g.Neighbors(u) {
			if !visited[v] {
				visited[v] = true // mark on enqueue, not dequeue
				queue = append(queue, v)
			}
		}
	}
	return order
}

func DFS(g *Graph, start int) []int {
	visited := make([]bool, g.N())
	var order []int
	var visit func(int)
	visit = func(u int) {
		visited[u] = true
		order = append(order, u)
		for _, v := range g.Neighbors(u) {
			if !visited[v] {
				visit(v)
			}
		}
	}
	visit(start)
	return order
}

func ShortestPath(g *Graph, src, dst int) []int {
	parent := make([]int, g.N())
	for i := range parent {
		parent[i] = -1
	}
	visited := make([]bool, g.N())
	visited[src] = true
	queue := []int{src}
	for len(queue) > 0 && !visited[dst] {
		u := queue[0]
		queue = queue[1:]
		for _, v := range g.Neighbors(u) {
			if !visited[v] {
				visited[v] = true
				parent[v] = u
				queue = append(queue, v)
			}
		}
	}
	if !visited[dst] {
		return nil
	}
	var path []int
	for v := dst; v != -1; v = parent[v] {
		path = append(path, v)
	}
	slices.Reverse(path)
	return path
}

func TopoSort(g *Graph) ([]int, bool) {
	indeg := make([]int, g.N())
	for u := 0; u < g.N(); u++ {
		for _, v := range g.Neighbors(u) {
			indeg[v]++
		}
	}
	var queue, order []int
	for u, d := range indeg {
		if d == 0 {
			queue = append(queue, u)
		}
	}
	for len(queue) > 0 {
		u := queue[0]
		queue = queue[1:]
		order = append(order, u)
		for _, v := range g.Neighbors(u) {
			indeg[v]--
			if indeg[v] == 0 {
				queue = append(queue, v)
			}
		}
	}
	if len(order) != g.N() {
		return nil, false // the leftover vertices are on a cycle
	}
	return order, true
}

type item struct{ v, dist int }
type pq []item

func (h pq) Len() int           { return len(h) }
func (h pq) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h pq) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *pq) Push(x any)        { *h = append(*h, x.(item)) }
func (h *pq) Pop() any {
	old := *h
	v := old[len(old)-1]
	*h = old[:len(old)-1]
	return v
}

func Dijkstra(g *WeightedGraph, src int) []int {
	dist := make([]int, g.N())
	for i := range dist {
		dist[i] = -1
	}
	dist[src] = 0
	h := &pq{{src, 0}}
	for h.Len() > 0 {
		cur := heap.Pop(h).(item)
		if cur.dist > dist[cur.v] {
			continue // stale entry: we already found a shorter path
		}
		for _, e := range g.Neighbors(cur.v) {
			nd := cur.dist + e.Weight
			if dist[e.To] == -1 || nd < dist[e.To] {
				dist[e.To] = nd
				heap.Push(h, item{e.To, nd})
			}
		}
	}
	return dist
}

func NumIslands(grid [][]byte) int {
	rows := len(grid)
	if rows == 0 {
		return 0
	}
	cols := len(grid[0])
	seen := make([][]bool, rows)
	for i := range seen {
		seen[i] = make([]bool, cols)
	}
	var sink func(r, c int)
	sink = func(r, c int) {
		if r < 0 || r >= rows || c < 0 || c >= cols || seen[r][c] || grid[r][c] != '1' {
			return
		}
		seen[r][c] = true
		sink(r+1, c)
		sink(r-1, c)
		sink(r, c+1)
		sink(r, c-1)
	}
	count := 0
	for r := 0; r < rows; r++ {
		for c := 0; c < cols; c++ {
			if grid[r][c] == '1' && !seen[r][c] {
				count++
				sink(r, c)
			}
		}
	}
	return count
}

func (uf *UnionFind) Find(x int) int {
	for uf.parent[x] != x {
		uf.parent[x] = uf.parent[uf.parent[x]] // path halving
		x = uf.parent[x]
	}
	return x
}

func (uf *UnionFind) Union(a, b int) bool {
	ra, rb := uf.Find(a), uf.Find(b)
	if ra == rb {
		return false
	}
	switch {
	case uf.rank[ra] < uf.rank[rb]:
		uf.parent[ra] = rb
	case uf.rank[ra] > uf.rank[rb]:
		uf.parent[rb] = ra
	default:
		uf.parent[rb] = ra
		uf.rank[ra]++
	}
	uf.count--
	return true
}

func CountComponents(n int, edges [][2]int) int {
	uf := NewUnionFind(n)
	for _, e := range edges {
		uf.Union(e[0], e[1])
	}
	return uf.Count()
}
FURTHER

Going further.

  • Return the actual path from Dijkstra, not just distances, by recording each vertex's predecessor.
  • LeetCode practice: Course Schedule II, Network Delay Time, Redundant Connection.
QUIZ

Check yourself.

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

Question 1

Which algorithm finds shortest paths in an unweighted graph?

  • ADFS
  • BBFS
  • CDijkstra is required
  • DTopological sort
Show the answer

B — BFS explores in order of distance, so the first time it reaches a vertex is via a shortest path. Dijkstra is for weighted edges.

Question 2

Dijkstra's algorithm gives wrong answers when…

  • AThe graph is directed
  • BThe graph has cycles
  • CSome edge weights are negative
  • DThere are more than 1,000 vertices
Show the answer

C — Dijkstra assumes a settled vertex can't get cheaper later. A negative edge breaks that. Use Bellman–Ford instead.

Question 3

Kahn's algorithm outputs 4 vertices of a 6-vertex directed graph, then the queue is empty. What does that tell you?

  • AThe graph is disconnected
  • BThe remaining 2 vertices are on (or behind) a cycle — there is no topological order
  • CYou must restart from another vertex
  • DThe graph is undirected
Show the answer

B — Vertices on a cycle never reach in-degree 0, so they're never queued.

Question 4

In BFS, why mark a vertex visited when you enqueue it rather than when you dequeue it?

  • AIt's required for DFS
  • BOtherwise it can be enqueued several times by different neighbors before it's processed
  • CSo the queue stays sorted
  • DIt doesn't matter
Show the answer

B — Marking late lets duplicates pile up in the queue — wasted work and, in dense graphs, a lot of it.

NEXT

Where to next.