Graphs
Things and the connections between them: how to store a graph, explore it with BFS and DFS, find cycles, order dependencies with topological sort, find cheapest routes with Dijkstra, and where graphs hide inside real systems — in Go and JavaScript.
The 30-second version
Think of a road map. The cities are nodes; the roads between them are edges. “What's the fastest way from Hanoi to Hue?” is a question about that map, and it has nothing to do with which country the map shows. The same algorithm works for any map.
That's the trick of this topic: very different problems turn into the same picture.
You've met graphs before even if nobody called them that:
- Social networks: people are nodes, friendships are edges. “People you may know” = friends of your friends.
- Package dependencies:
npm installandgo buildwork out what each package needs, and in what order to build them. - Microservices: service A calls B, B calls C. When C is slow, which user-facing endpoints suffer?
- Build systems and CI pipelines: “test needs build, build needs codegen”. What can run in parallel?
- The web: pages are nodes, links are edges. Search engines rank pages by who links to them.
What you'll be able to do by the end
Store a graph
Explore it
Answer the big questions
Everything comes with Go and JavaScript code you can run, and the staff-level part (§11) shows where these algorithms run in real systems: dependency resolvers, deadlock detectors, tracing, search ranking.
Nodes, edges and the words around them
A Graph has just two ingredients. A node (also called a vertex; plural vertices) is one thing. An edge connects two nodes. Everything else is a variation on that. Pick each kind in the widget and click a node:
The vocabulary, in one table
| Word | Means | Everyday example |
|---|---|---|
| Undirected | Edges work both ways | Friendship, a two-way street |
| Directed | Edges are one-way arrows | “follows”, “imports”, “links to”, a one-way street |
| Weighted | Each edge has a number | Minutes of driving, ms of latency, cost |
| Degree | Edges touching a node (in/out for directed) | How many friends you have |
| Path | A chain of edges from one node to another | A route on a map |
| Cycle | A path that returns to its start | A roundabout; A imports B imports A |
| DAG | Directed, with no cycles | Build steps, course prerequisites |
| Connected component | A piece where everyone can reach everyone | Islands with no bridges between them |
Two more words you'll see in the tables: V is the number of nodes (vertices) and E is the number of edges. A map of 6 towns with 9 roads has V = 6 and E = 9.
Why the three questions matter
| Your graph | Typical question | Algorithm | Section |
|---|---|---|---|
| Any, unweighted | Fewest hops from A to B? | BFS | §04 |
| Any | Can I reach B at all? Visit everything? | BFS or DFS | §04–§05 |
| Directed | Is there a cycle? | DFS with three colours | §06 |
| DAG | In what order can I do these tasks? | Topological sort | §07 |
| Weighted, no negative weights | Cheapest route from A? | Dijkstra | §08 |
| Weighted, undirected | Cheapest way to connect everyone? | Kruskal + union-find | §09 |
Storing a graph in memory
There are three common ways to store the same graph. Think of a school's friendship records:
- Adjacency list — each student has a page in a notebook listing their friends. To find Dee's friends, open Dee's page.
- Adjacency matrix — a huge grid with every student on both axes and a tick where two are friends. Checking “are Ana and Dee friends?” is one glance, but the grid has a square for every pair, even strangers.
- Edge list — a loose pile of cards, one per friendship: “Ana–Ben”, “Ana–Cy”. Easy to write, but finding Dee's friends means flipping through every card.
Toggle between them. Click a node, and change the question:
The trade-offs
| Adjacency list | Adjacency matrix | Edge list | |
|---|---|---|---|
| Memory | O(V + E) | O(V²) | O(E) |
| “Is there an edge u→v?” | O(degree of u) | O(1) | O(E) |
| “List u's neighbours” | O(degree of u) | O(V) | O(E) |
| Good for | Almost everything: BFS, DFS, Dijkstra | Small or very dense graphs | Kruskal (sort the edges), loading from files |
(O(…) is Big-O notation: how the cost grows as the graph grows. O(1) means “the same no matter how big”.)
Concrete numbers make the memory row bite. A social network with 1 million users who have ~200 friends each has about 100 million friendships:
- Adjacency list: ~200 million entries (each friendship is stored in both people's lists). At 8 bytes each, roughly 1.6 GB.
- Adjacency matrix: 1,000,000 × 1,000,000 = 10¹² cells. Even at one Bit per cell that's about 125 GB — and 99.98% of those cells are 0.
Real graphs are almost always sparse: each node connects to a tiny fraction of the others. That's why the adjacency list is the default.
In code
// (inside func main, with import "fmt")
// Adjacency list: node → its neighbours.
g := map[string][]string{
"A": {"B", "C"},
"B": {"A", "D"},
"C": {"A", "D"},
"D": {"B", "C", "E"},
"E": {"D"},
}
for _, n := range g["D"] { // neighbours of D: B, C, E
fmt.Println(n)
}
// Adjacency matrix: nodes numbered 0..4 (A=0, B=1, …).
m := [][]int{
// A B C D E
{0, 1, 1, 0, 0}, // A
{1, 0, 0, 1, 0}, // B
{1, 0, 0, 1, 0}, // C
{0, 1, 1, 0, 1}, // D
{0, 0, 0, 1, 0}, // E
}
fmt.Println(m[2][3] == 1) // is there an edge C–D? true
// Edge list: just the pairs.
type Edge struct{ From, To string }
edges := []Edge{{"A", "B"}, {"A", "C"}, {"B", "D"}, {"C", "D"}, {"D", "E"}}
fmt.Println(len(edges)) // 5Note that this graph is undirected, so every edge is written twice in the list: D appears in B's list and B in D's. For a directed graph you'd store only the arrow's direction.
BFS: ripples in a pond
Drop a stone in a pond. The ripple reaches everything 1 metre away, then 2 metres, then 3. Nothing at 3 metres gets wet before everything at 2 metres has. BFS does the same on a graph — and that's exactly what “degrees of separation” means: your friends are 1 hop away, their friends 2 hops, and so on.
The recipe
BFS needs two helpers:
- A queue — a line at a checkout: new arrivals join at the back, and you always serve the front. It holds nodes we've found but not explored yet (the frontier).
- A seen set — every node we've already found, so we never add one twice.
Then repeat: take the node at the front, look at its neighbours, and add every new one to the back. Because a node 2 hops away can only be added by a node 1 hop away, everyone at distance 2 lines up behind everyone at distance 1. The queue keeps the rings in order for us.
Step through it on a small friendship graph (neighbours are tried in alphabetical order). Leave the toggle on BFS for now; §05 flips it.
1function bfs(graph, start) {2 const seen = new Set([start]);3 const queue = [start];4 while (queue.length > 0) {5 const node = queue.shift(); // take from the FRONT6 visit(node); // whatever you need to do with it7 for (const next of graph.get(node)) {8 if (!seen.has(next)) {9 seen.add(next); // mark when you ADD it10 queue.push(next); // join at the BACK11 }12 }13 }14}
A. Mark it as seen and put it in the queue. Its distance is 0 (the small d= label above a node is its distance, in edges, from A).Getting the actual path: parent pointers
BFS as written tells you that H is reachable. (The stepper also tracks each node's distance — the d= labels; in code that's one more map, dist[next] = dist[node] + 1.) To get the route, record a parent pointer whenever you add a node: “I reached F from D.” At the end, start at the goal and follow parents back to the start — H ← F ← D ← B ← A — then reverse the list. The last step of the stepper above does exactly that.
The same thing on a maze
A grid maze is a graph too: every open cell is a node, and it has an edge to each open cell directly above, right, below and left of it. Run BFS and watch the yellow frontier spread out like a ripple:
On the default maze BFS finds a path of 28 moves after visiting 117 cells. It visits a lot, because it explores every direction evenly — but the path it returns is guaranteed to be the shortest one.
Cost
Every node enters the queue at most once, and every edge is looked at once from each end. So BFS takes O(V + E) time, and O(V) extra memory for the queue and the seen set. That's as fast as you can possibly go: you can't explore a graph without looking at it.
DFS: one corridor at a time
Picture yourself in a maze. You walk down a corridor, and at each fork you take the first unexplored turning. When you hit a dead end, you walk back to the last fork that still has an unexplored turning, and try that. Chalk marks on the walls stop you from exploring a corridor twice. That's DFS: going back is called backtracking, and the chalk marks are the same seen set BFS used.
Recursion does the remembering
The neatest way to write DFS uses Recursion: to explore a node, mark it, then explore each unseen neighbour by calling the same function. When a call returns, you're automatically back at the node you came from — the Call stack remembers the way back for you.
Flip the toggle below to DFS — it's the same graph as §04 — and step through:
1function dfs(graph, node, seen = new Set()) {2 seen.add(node); // discover: first time here3 for (const next of graph.get(node)) {4 if (!seen.has(next)) {5 dfs(graph, next, seen); // go DEEPER before trying siblings6 }7 }8 // finish: every neighbour explored9}
dfs(A). A is discovered (marked seen). The call stack panel shows the chain of calls that are still running — that's DFS's memory of how to get back.Two moments matter for every node, and the stepper tracks both:
- Discovered — the first time DFS arrives (the function is called).
- Finished — when every neighbour has been explored (the function returns).
Here the discovery order is A B D C E F H G, and the first node to finish is G, the deepest one. Finish order turns out to be the key to topological sorting (§07).
Compare the shapes. BFS's tree edges fan out in rings, so its path to H has 4 hops. DFS's tree is one long snake, and the path it happened to use to reach H has 6. DFS finds a path, not the shortest path.
On the default maze DFS visits only about 40 cells — far fewer than BFS — because it happened to pick good corridors early. But its path is 38 moves against BFS's 28. Add a few walls and the luck can flip: DFS can also wander through nearly the whole maze before stumbling on the exit. If you need the shortest path, use BFS.
So what is DFS good for?
- “Can I reach X?” and “visit everything” — BFS works too; DFS is often shorter to write.
- Counting connected components — run DFS from every node that isn't seen yet; each run colours one component. (“Is our network split in two?”)
- Finding cycles (§06) and ordering dependencies (§07) — this is where DFS really shines, because of its discovered/finished bookkeeping.
- Exploring all possibilities — solving puzzles, generating mazes, trying every move in a game. These are DFS on a graph that's never fully built in memory.
The catch: the call stack has a ceiling
Every recursive call adds a frame to the call stack, and a long path means many nested calls. Here's recursive DFS on a “chain” graph (0 → 1 → 2 → … → 99,999), run on this machine:
const n = 100_000;
const g = Array.from({ length: n }, (_, i) => (i + 1 < n ? [i + 1] : []));
const seen = new Uint8Array(n);
function dfs(u) {
seen[u] = 1;
for (const v of g[u]) if (!seen[v]) dfs(v);
}
dfs(0);
// RangeError: Maximum call stack size exceeded
// (it got 5,707 calls deep on Node 22)JavaScript gives you a fixed, fairly small stack, so recursive DFS dies at a few thousand levels — very reachable in a real dependency or social graph. Go starts each Goroutine with a tiny stack and grows it up to 1 GB, so on this machine it lasted 15 million levels and died at 20 million. That's far longer, but a Stack overflow in Go is a fatal crash, not a catchable error. Stack & heap §03 shows both in detail.
The fix is to manage the stack yourself: a plain array you push to and pop from, living on the heap where it can grow as large as memory allows.
func DFS(g map[string][]string, start string) []string {
seen := map[string]bool{}
var order []string
stack := []string{start}
for len(stack) > 0 {
n := stack[len(stack)-1] // look at the top
stack = stack[:len(stack)-1] // pop it
if seen[n] {
continue // pushed twice before we got to it
}
seen[n] = true
order = append(order, n)
nbrs := g[n]
for i := len(nbrs) - 1; i >= 0; i-- { // push in reverse…
if !seen[nbrs[i]] {
stack = append(stack, nbrs[i]) // …so the first neighbour is popped first
}
}
}
return order
}On the friendship graph both print A B D C E F H G — the same discovery order as the recursive version. Pick “DFS (explicit stack)” in the stepper above to watch it run, including a node that gets pushed twice and skipped.
Finding cycles: white, grey, black
Cycles are usually bad news in software. Package A imports B, B imports C, C imports A: which one gets built first? None can. Go refuses to compile it, and it tells you the exact loop:
$ go build .
package example.com/app
imports example.com/app/a from main.go
imports example.com/app/b from a.go
imports example.com/app/a from b.go: import cycle not allowedThat's an Import cycle, and go build found it — while loading packages, before compiling anything — with the algorithm in this section.
Why “seen / not seen” isn't enough
Your first idea might be: run DFS, and if you ever reach a node you've seen before, there's a cycle. Try it on this shape:
app → log → fmt
app → auth → fmtDFS goes app → log → fmt, then app → auth → fmt. fmt was seen already, but there's no cycle — two packages simply share a dependency (a diamond). We need to tell apart “seen, and still in progress” from “seen, and completely finished”. Hence three colours:
| Colour | Meaning | In recursive DFS |
|---|---|---|
| WHITE | Not visited yet | Never called |
| GREY | Visited, still exploring its edges | Its call is on the call stack right now |
| BLACK | Finished: everything reachable from it is explored | Its call has returned |
The rule: an edge to a GREY node is a back edge, and a back edge means a cycle. The grey node is still waiting for the current chain of calls to finish — and that chain just led back to it. An edge to a BLACK node is harmless: that node, and everything after it, was already fully explored without finding a way back.
1function visit(n) {2 color[n] = GREY; // on the current path3 for (const d of imports[n]) {4 if (color[d] === GREY) {5 throw cycle(d); // back edge: d is still open!6 }7 if (color[d] === WHITE) visit(d);8 // BLACK: finished earlier and known safe → skip9 }10 color[n] = BLACK; // done: no cycle through n11}
visit(app) paints app GREY: “I'm in the middle of exploring this one.” Grey nodes are exactly the ones on the current path — the call stack.When the back edge config → auth shows up, the cycle is sitting right there on the call stack: start at auth and read to the top — auth → db → config → auth. That's how compilers and package managers print the loop for you instead of just saying “cycle somewhere”.
Undirected graphs are simpler (with one twist)
In an undirected graph, every edge works both ways, so the moment DFS goes A → B, B can see A as a neighbour. That's not a cycle — it's the edge you just walked along. The rule becomes: a seen neighbour that isn't the node you came from means a cycle. Two colours (seen / not seen) are enough, as long as you remember each node's parent.
An even simpler way for undirected graphs is union-find (§09): add edges one at a time, and if an edge joins two nodes that are already connected, that edge closes a loop.
Topological sort: a valid order for dependencies
Getting dressed: socks before shoes, trousers before shoes, shirt before tie. There are many valid orders (shirt first or socks first — both fine) and some invalid ones (shoes before socks). A Topological sort picks one valid order. Your build tool, your package manager and your CI system do this every time they run.
Kahn's algorithm: “who's waiting for nothing?”
Draw an arrow from each task to the tasks that need it: install → lint means “install must finish before lint”. A task's In-degree is the number of arrows pointing at it — how many things it's still waiting for.
needs["test"] = ["build"]
after["build"] = ["test"]
after, and why §10's TopoSort, which is given needs, first builds the reverse map.Kahn's algorithm is how a sensible person would schedule work:
- Find every task that waits for nothing (in-degree 0). Those can start now.
- Take one, do it, and cross out its outgoing arrows. Each task it pointed to now waits for one thing fewer.
- Any task whose count drops to 0 has become ready. Add it to the ready list.
- Repeat until the ready list is empty.
If every task got done, the order you did them in is a valid order. If some tasks are left over, there's a cycle: each leftover task is either on a cycle, or waits (directly or indirectly) for a task that is.
Play with it. The starting graph is a small CI pipeline. Then add deploy must finish before install and watch it fail:
Two things to notice:
- Waves = parallelism. Every task in the same column became ready at the same moment, so with enough workers they can run together.
codegenandlintboth only needinstall. The number of waves is the shortest possible total time if every task takes one unit — the length of the longest dependency chain, the critical path. - A cycle stops the algorithm early, and it tells you who's stuck. The good error message then names the actual loop, using the three-colour DFS from §06 on the stuck tasks. “Cycle detected” alone is a terrible error;
install → codegen → build → test → package → deploy → installis a useful one.
The code
Here's the CI pipeline as data, and Kahn's algorithm in both languages:
// Kahn returns tasks in a valid order. after[n] lists what must come after n.
func Kahn(after map[string][]string, nodes []string) ([]string, error) {
indeg := map[string]int{}
for _, n := range nodes {
for _, m := range after[n] {
indeg[m]++ // m waits for one more thing
}
}
var ready []string
for _, n := range nodes {
if indeg[n] == 0 {
ready = append(ready, n)
}
}
var order []string
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
order = append(order, n)
for _, m := range after[n] {
indeg[m]--
if indeg[m] == 0 { // the last thing m waited for is done
ready = append(ready, m)
}
}
}
if len(order) < len(nodes) {
return nil, errors.New("cycle!")
}
return order, nil
}
// In main:
nodes := []string{"checkout", "install", "codegen", "lint", "build", "test", "package", "deploy"}
after := map[string][]string{
"checkout": {"install"},
"install": {"codegen", "lint"},
"codegen": {"build"},
"build": {"test"},
"lint": {"package"},
"test": {"package"},
"package": {"deploy"},
}
fmt.Println(Kahn(after, nodes))
// [checkout install codegen lint build test package deploy] <nil>Why does the Go version take a separate nodes list? Two reasons. A task with no arrows at all (and deploy, which has no key in after) would otherwise never be seen. And Go randomises map iteration order, so looping over after would give a different, still valid, order on every run; a slice keeps it stable. The JS version collects every task it meets into the indeg Map instead, and a JS Map remembers insertion order.
§10 has the production version, which also reports which cycle.
The DFS way: reverse finish order
There's a second classic algorithm, and it falls straight out of §05. In DFS, a node finishes only after everything reachable from it has finished. So if every arrow points from “first” to “after”, each node finishes after all the things that must come after it. Reverse the finish order, and every task comes before what follows it:
function topoDFS(after) {
const seen = new Set();
const finished = [];
const visit = (n) => {
seen.add(n);
for (const m of after.get(n) ?? []) if (!seen.has(m)) visit(m);
finished.push(n); // n finishes only after everything that comes after it
};
for (const n of after.keys()) if (!seen.has(n)) visit(n);
return finished.reverse();
}
// checkout → install → lint → codegen → build → test → package → deployDifferent from Kahn's output (lint before codegen), and equally valid. This short version doesn't notice cycles — add the three colours from §06 and fail on a grey neighbour to make it safe. It also uses recursion, so §05's stack-depth warning applies.
Dijkstra: cheapest paths with weights
BFS counts hops, which is wrong as soon as edges cost different amounts: a 3-road route of 2 minutes each beats a 1-road route of 40 minutes. On a weighted graph we need Dijkstra's algorithm.
The idea, with towns
You're in town A. Every other town starts as “∞ — no route known”. Now repeat:
- Among towns you haven't finished, pick the one with the smallest known distance. Call it
u. - Mark
uas finished: its distance is final. - For each road
u → vof lengthw: if going throughuis cheaper than the best route tovknown so far (dist[u] + w < dist[v]), updatedist[v]. This is called relaxing the edge.
Why is step 2 safe? Say u is the closest unfinished town, at distance 8. Any other route to u would have to leave the finished area through some unfinished town — and all of those are already at least 8 away. Since road lengths are never negative, the detour can only add to that. So nothing can ever beat 8. That argument is the whole algorithm, and it's also exactly what breaks with negative weights (below).
Finding “the smallest” fast: a priority queue
Step 1 asks for the minimum over all unfinished towns, again and again. Scanning every town each time costs O(V) per step. A Priority queue does it in O(log V): it hands out the item with the smallest key first — like an emergency room calling the most urgent patient, not whoever arrived first. It's usually built on a binary heap; the Heaps topic shows how one works inside. Here we just use it: push(dist, town) and pop() → the smallest.
Step through it. The numbers above the towns are the best distances known so far; the heap is shown sorted for readability:
1dist.set(src, 0);2heap.push([0, src]);3while (heap.size > 0) {4 const [d, node] = heap.pop(); // closest unfinished5 if (done.has(node)) continue; // stale entry → skip6 done.add(node);7 for (const [to, w] of graph.get(node)) {8 const nd = d + w;9 if (!dist.has(to) || nd < dist.get(to)) {10 dist.set(to, nd); // found a cheaper way11 prev.set(to, node);12 heap.push([nd, to]);13 }14 }15}
A: its distance is 0; every other town is ∞ (“no route known yet”). Put (0, A) in the priority queue. The number above each town is the best distance found so far.Two things the stepper shows that textbooks often skip:
-
Distances improve. B starts at 4 (the direct road) and drops to 3 when we find A → C → B. E starts at 12, then drops to 10.
-
Stale entries. When B improved to 3, its old entry
(4, B)was still in the queue. There are two ways to deal with that:- Update it in place (“decrease-key”). Go's
container/heapcan do this withheap.Fix, but only if every item remembers its current position in the heap array, which you have to keep up to date on every swap — Heaps §07 shows how. JavaScript has no built-in heap at all. - Leave it and push a fresh copy. Push
(3, B)as well, and when the old(4, B)is eventually popped, skip it, because B is already finished. This is Lazy deletion. The queue holds a few extra entries (at most one per edge), but the code stays short and no position tracking is needed.
We use lazy deletion here, and it's the more common choice in practice. Both give the same answers and the same O-cost.
- Update it in place (“decrease-key”). Go's
The code
import "container/heap"
type Edge struct {
To string
Weight int // must be >= 0 for Dijkstra
}
// item is one entry in the priority queue: "we can reach node at cost dist".
type item struct {
node string
dist int
}
// pq implements heap.Interface: a min-heap ordered by dist.
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
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
func Dijkstra(g map[string][]Edge, src string) (dist map[string]int, prev map[string]string) {
dist = map[string]int{src: 0}
prev = map[string]string{}
done := map[string]bool{}
h := &pq{{src, 0}}
for h.Len() > 0 {
cur := heap.Pop(h).(item) // the closest node we haven't finished
if done[cur.node] {
continue // stale entry: we already found a shorter way here
}
done[cur.node] = true
for _, e := range g[cur.node] {
nd := cur.dist + e.Weight
if old, ok := dist[e.To]; !ok || nd < old { // relax the edge
dist[e.To] = nd
prev[e.To] = cur.node
heap.Push(h, item{e.To, nd}) // the old entry stays; skipped later
}
}
}
return dist, prev
}Run on the map above, from A, both report F = 12 via A C B D E F (§10 has the tests).
Cost
Every edge can push one entry, and every push and pop costs O(log E). Total: O((V + E) log V) (log E and log V differ by at most a factor of 2, since E ≤ V²). For a road network with a million junctions, that's tens of millions of cheap steps — well under a second.
Negative weights break it — Bellman-Ford instead
Dijkstra's safety argument was “detours can only add”. A negative edge (a road that pays you to use it — in real systems: a cashback or a time credit) breaks that: a route through a far-away node can come back cheaper, after the node it improves was already finished. Q5 in the quiz (§13) walks through exactly how the result goes wrong.
Bellman-Ford handles negative edges by brute patience: relax every edge, and repeat V − 1 times. A cheapest path uses at most V − 1 edges, and each round fixes at least one more of them. It's O(V·E) — much slower — but it has a bonus: if a V-th round still improves something, there's a Negative cycle and no shortest path exists at all. Some network routing protocols, like RIP (an old protocol routers use to share routes with their neighbours), use this family of algorithms because it works with each router knowing only its neighbours.
A*: Dijkstra with a sense of direction
Dijkstra spreads out in every direction equally, like BFS's ripple but weighted. If you're driving from Hanoi to Hue, it will happily explore roads going north first. A* search adds a Heuristic: an estimate of the remaining distance, like the straight-line distance to Hue. It orders the priority queue by distance so far + estimated distance left, so roads pointing toward the goal get explored first. As long as the estimate never overestimates (a straight line is never longer than the real road) — and, if you never revisit finished nodes as our code does, it also never drops by more than the length of the road you just took (it's consistent) — A* still finds the cheapest route, usually after visiting a small fraction of the nodes. Straight-line distance satisfies both conditions. Map apps and game pathfinding use A* and its relatives.
Union-find and minimum spanning trees
Imagine you're laying fibre cable between towns. Every possible link has a cost, and you want every town connected to every other (directly or through others) for the least total cable. You don't care about the route between any particular pair — just that the network is in one piece. That's a minimum spanning tree (MST): the cheapest set of edges that connects all nodes, with no loops (a loop would mean one cable is redundant).
Kruskal's algorithm: cheapest first, skip loops
Kruskal's algorithm is almost embarrassingly simple:
- Sort all edges from cheapest to most expensive.
- Go through them in order. Take an edge unless its two towns are already connected — then it would only close a loop.
- Stop when you've taken V − 1 edges (that's exactly how many a tree on V nodes has).
Step 2 needs to answer “are A and B already connected?” over and over, while the answer keeps changing as edges are added. Running BFS each time would cost O(V + E) per edge. That's what union-find is for.
Union-find in one picture
Union-find keeps every item in a group, and each group has one representative (its root). Think of clubs, where each club has a president:
find(x)— “who is x's president?” Follow x'sparentpointer, then that node's parent, and so on, until you reach a node that is its own parent: the root.union(a, b)— merge two clubs: make one president report to the other.
Two items are connected exactly when find(a) == find(b). Step through Kruskal on the same map as Dijkstra:
union hangs the smaller tree's root under the bigger one's.type UnionFind struct {
parent, size []int
}
func NewUnionFind(n int) *UnionFind {
u := &UnionFind{parent: make([]int, n), size: make([]int, n)}
for i := range n { // Go 1.22+: range over an int counts 0..n-1
u.parent[i] = i // everyone starts as their own root
u.size[i] = 1
}
return u
}
func (u *UnionFind) Find(x int) int {
for u.parent[x] != x {
u.parent[x] = u.parent[u.parent[x]] // path halving: skip a level as we climb
x = u.parent[x]
}
return x
}
// Union merges the groups of a and b. It reports false if they were already together.
func (u *UnionFind) Union(a, b int) bool {
ra, rb := u.Find(a), u.Find(b)
if ra == rb {
return false
}
if u.size[ra] < u.size[rb] {
ra, rb = rb, ra // attach the smaller tree under the bigger one
}
u.parent[rb] = ra
u.size[ra] += u.size[rb]
return true
}Kruskal, for real
UnionFind works on numbers 0…n−1, but our towns have names. So step one is to give each town a number; then sort the roads and keep each one that Union accepts:
type Road struct {
A, B string
Cost int
}
// Kruskal returns the roads of a minimum spanning tree and their total cost.
func Kruskal(towns []string, roads []Road) ([]Road, int) {
idx := map[string]int{} // UnionFind works on 0..n-1, so number the towns
for i, t := range towns {
idx[t] = i
}
sorted := slices.Clone(roads) // don't reorder the caller's slice
slices.SortStableFunc(sorted, func(x, y Road) int { return cmp.Compare(x.Cost, y.Cost) })
uf := NewUnionFind(len(towns))
var tree []Road
total := 0
for _, r := range sorted {
if uf.Union(idx[r.A], idx[r.B]) { // false = already connected → skip
tree = append(tree, r)
total += r.Cost
if len(tree) == len(towns)-1 {
break // a tree on V nodes has exactly V-1 edges
}
}
}
return tree, total
}
// In main:
towns := []string{"A", "B", "C", "D", "E", "F"}
roads := []Road{
{"A", "B", 4}, {"A", "C", 2}, {"B", "C", 1}, {"B", "D", 5}, {"C", "D", 8},
{"C", "E", 10}, {"D", "E", 2}, {"D", "F", 6}, {"E", "F", 2},
}
fmt.Println(Kruskal(towns, roads))
// [{B C 1} {A C 2} {D E 2} {E F 2} {B D 5}] 12Same five roads as the stepper, total 12.
Why it's “almost O(1)”
Two small tricks keep the parent chains extremely short:
- Union by size — always hang the smaller tree under the bigger one. A node's depth only grows when its group at least doubles, so no chain gets longer than log₂ V.
- Path compression — while
findclimbs to the root, re-point the nodes it passes closer to the root, so the nextfindis shorter. The code above uses the simplest version, path halving: each node it passes skips one level.
With both tricks, a sequence of operations costs so little that the per-operation cost grows slower than log of log of anything you'll ever store. For practical purposes, constant time.
Where union-find shows up
- Connectivity while edges arrive: “are these two servers in the same network partition?”, “are these two pixels part of the same blob?”
- Grouping duplicates: if record A matches B and B matches C, union them all; each final group is one real-world customer.
- Cycle detection in undirected graphs: an edge whose two ends are already in the same group closes a cycle (§06).
What it can't do: split groups again. Union-find only merges, so it doesn't fit graphs where edges get deleted.
The real code, with tests
Everything below was run on this machine with Go 1.24 and Node 22. The Go package has four files: bfs.go, topo.go, dijkstra.go (exactly the code in §08) and a test file. The JS module mirrors it.
1. BFS shortest path
// ShortestPath returns the fewest-hops path from start to goal, or nil if
// goal can't be reached. g maps each node to its neighbours.
func ShortestPath[N comparable](g map[N][]N, start, goal N) []N {
parent := map[N]N{start: start} // doubles as the visited set
queue := []N{start}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:] // dequeue from the front
if cur == goal {
// Walk the parent pointers back to start, then reverse.
path := []N{cur}
for cur != start {
cur = parent[cur]
path = append(path, cur)
}
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}
for _, next := range g[cur] {
if _, seen := parent[next]; !seen { // mark when ENQUEUED, not when dequeued
parent[next] = cur
queue = append(queue, next)
}
}
}
return nil
}The JS version uses a head index instead of queue.shift(). shift() has to move every remaining element one slot to the left, so it costs O(length of the queue). With a small queue that's invisible, but BFS on a big graph can have a huge frontier. Measured on this machine (Node 22), BFS over a social graph with 200,000 people and ~10 friends each, where the queue peaked at 123,935 entries:
| Queue style | Time |
|---|---|
queue[head++] | 153 ms |
queue.shift() | 3,325 ms (~22× slower) |
The Go version's queue = queue[1:] is O(1): it just moves the start of the slice forward, no copying.
2. Topological sort that names the cycle
This version takes the graph the way package managers and build files describe it: each task lists what it needs (needs["test"] = ["build"]). That's the §06 direction (arrows point at what you need), the opposite of §07's after — so the code first builds the reverse map, users, which is exactly §07's after. It runs Kahn's algorithm (§07), and if tasks are left over, runs the three-colour DFS from §06 on them to find a cycle to report.
import (
"maps"
"slices"
"strings"
)
// CycleError reports one cycle that makes an ordering impossible.
type CycleError struct{ Cycle []string }
func (e *CycleError) Error() string {
return "dependency cycle: " + strings.Join(e.Cycle, " → ")
}
// TopoSort orders tasks so every task comes after everything it needs.
// needs["test"] = ["build"] means build must run before test.
func TopoSort(needs map[string][]string) ([]string, error) {
// 1. Collect every node and count, for each one, how many things it still waits for.
indeg := map[string]int{}
users := map[string][]string{} // reverse edges: dep → tasks that need it
for task, deps := range needs {
if _, ok := indeg[task]; !ok {
indeg[task] = 0
}
for _, d := range deps {
if _, ok := indeg[d]; !ok {
indeg[d] = 0
}
indeg[task]++
users[d] = append(users[d], task)
}
}
// 2. Start with every task that waits for nothing. Sorted, so output is stable
// (Go randomises map iteration order).
var ready []string
for _, n := range slices.Sorted(maps.Keys(indeg)) {
if indeg[n] == 0 {
ready = append(ready, n)
}
}
// 3. Kahn's loop: take a ready task, "finish" it, unlock whoever waited on it.
var order []string
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
order = append(order, n)
next := users[n]
slices.Sort(next)
for _, u := range next {
indeg[u]--
if indeg[u] == 0 {
ready = append(ready, u)
}
}
}
if len(order) == len(indeg) {
return order, nil
}
// 4. Some tasks never became ready: they sit on (or behind) a cycle. Find one.
return nil, &CycleError{Cycle: findCycle(needs, indeg)}
}
// findCycle runs DFS over the leftover tasks (indeg > 0) with three colours.
func findCycle(needs map[string][]string, indeg map[string]int) []string {
const (
white = iota // not visited yet
grey // on the current DFS path
black // finished, known cycle-free
)
color := map[string]int{}
var path []string
var cycle []string
var visit func(n string) bool
visit = func(n string) bool {
color[n] = grey
path = append(path, n)
for _, d := range needs[n] {
switch color[d] {
case grey: // back edge: d is already on the path → cycle
i := slices.Index(path, d)
cycle = append(slices.Clone(path[i:]), d)
return true
case white:
if visit(d) {
return true
}
}
}
path = path[:len(path)-1]
color[n] = black
return false
}
for _, n := range slices.Sorted(maps.Keys(indeg)) {
if indeg[n] > 0 && color[n] == white && visit(n) {
break
}
}
return cycle
}3. Dijkstra
The Go side is exactly the Dijkstra function from §08. JavaScript has no built-in priority queue, so graph.js defines a tiny binary min-heap above dijkstra (the function from §08). How it works is the subject of Heaps:
// A tiny binary min-heap of [dist, node] pairs (see the Heaps topic for how it works).
class MinHeap {
#a = [];
get size() { return this.#a.length; }
push(x) {
const a = this.#a;
a.push(x);
for (let i = a.length - 1; i > 0; ) {
const p = (i - 1) >> 1;
if (a[p][0] <= a[i][0]) break;
[a[p], a[i]] = [a[i], a[p]];
i = p;
}
}
pop() {
const a = this.#a;
const top = a[0];
const last = a.pop();
if (a.length) {
a[0] = last;
for (let i = 0; ; ) {
const l = 2 * i + 1, r = l + 1;
let m = i;
if (l < a.length && a[l][0] < a[m][0]) m = l;
if (r < a.length && a[r][0] < a[m][0]) m = r;
if (m === i) break;
[a[m], a[i]] = [a[i], a[m]];
i = m;
}
}
return top;
}
}The tests
package graph
import (
"errors"
"fmt"
"slices"
"testing"
)
var friends = map[string][]string{
"A": {"B", "C"}, "B": {"A", "D"}, "C": {"A", "D", "E"},
"D": {"B", "C", "F"}, "E": {"C", "F", "G"}, "F": {"D", "E", "H"},
"G": {"E", "H"}, "H": {"F", "G"},
}
func TestShortestPath(t *testing.T) {
tests := []struct {
from, to string
want []string
}{
{"A", "A", []string{"A"}},
{"A", "H", []string{"A", "B", "D", "F", "H"}},
{"A", "Z", nil}, // unreachable
}
for _, tc := range tests {
got := ShortestPath(friends, tc.from, tc.to)
if !slices.Equal(got, tc.want) {
t.Errorf("ShortestPath(%s→%s) = %v, want %v", tc.from, tc.to, got, tc.want)
}
}
}
func TestTopoSortCycle(t *testing.T) {
needs := map[string][]string{"a": {"b"}, "b": {"c"}, "c": {"a"}, "d": {"a"}}
_, err := TopoSort(needs)
var ce *CycleError
if !errors.As(err, &ce) {
t.Fatalf("want *CycleError, got %v", err)
}
if len(ce.Cycle) != 4 || ce.Cycle[0] != ce.Cycle[3] {
t.Errorf("bad cycle %v", ce.Cycle)
}
}
func ExampleTopoSort() {
order, err := TopoSort(map[string][]string{
"test": {"build"}, "build": {"install"}, "lint": {"install"},
"deploy": {"test", "lint"},
})
fmt.Println(order, err)
_, err = TopoSort(map[string][]string{
"auth": {"db"}, "db": {"config"}, "config": {"auth"}, "app": {"auth"},
})
fmt.Println(err)
// Output:
// [install build lint test deploy] <nil>
// dependency cycle: auth → db → config → auth
}$ go test -v .
=== RUN TestShortestPath
--- PASS: TestShortestPath (0.00s)
=== RUN TestTopoSortOrder
--- PASS: TestTopoSortOrder (0.00s)
=== RUN TestTopoSortCycle
--- PASS: TestTopoSortCycle (0.00s)
=== RUN TestDijkstra
--- PASS: TestDijkstra (0.00s)
=== RUN ExampleTopoSort
--- PASS: ExampleTopoSort (0.00s)
=== RUN ExampleDijkstra
--- PASS: ExampleDijkstra (0.00s)
PASS
ok example.com/graph 0.003s
$ node --test --test-reporter=spec
✔ BFS finds the fewest hops (0.9ms)
✔ topoSort puts dependencies first (0.3ms)
✔ topoSort reports the cycle (0.4ms)
✔ dijkstra (0.6ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0(The full Go test file also has TestTopoSortOrder, which checks every dependency comes before its task, plus TestDijkstra and ExampleDijkstra on the road map from §08 — 12 [A C B D E F].)
Complexity at a glance
V = number of nodes, E = number of edges. See Big-O if the notation is new.
| Algorithm | Time | Extra memory | Needs |
|---|---|---|---|
| BFS | O(V + E) | O(V) | Unweighted edges for shortest paths |
| DFS (and 3-colour cycle check) | O(V + E) | O(V) — recursion depth up to V | — |
| Topological sort (Kahn or DFS) | O(V + E) | O(V) | A DAG (else: report the cycle) |
| Dijkstra with a binary heap | O((V + E) log V) | O(V + E) with lazy deletion | Weights ≥ 0 |
| Bellman-Ford | O(V · E) | O(V) | Works with negative weights |
| Union-find, per operation | ≈ O(1) | O(V) | Only merges, never splits |
| Kruskal MST | O(E log E) — the sort dominates | O(V) | Undirected, weighted |
Graphs inside real systems
Dependency resolution: same graph, two philosophies
Every package manager walks a dependency graph: your project needs packages, which need packages, and so on. The hard part isn't the walk (that's BFS or DFS). It's choosing which version of each package to use when different packages ask for different ones.
Go modules: minimal version selection. Each go.mod states the minimum version it needs of each dependency (a Go module requirement like require example.com/a v1.2.0 means “1.2.0 or newer”). Go walks the whole requirement graph and, for each module, picks the highest of the minimums anyone asked for — never anything newer than that. A worked example:
| Who | Requires |
|---|---|
| your app | A ≥ v1.2.0, B ≥ v1.0.0 |
| B v1.0.0 | A ≥ v1.3.0 |
| A v1.3.0 | (nothing) |
Go selects A v1.3.0 — the max of 1.2.0 and 1.3.0 — even if A v1.9.0 exists. It's one graph walk, no search, and the same inputs always give the same answer, which is why Go doesn't need a lockfile to be reproducible (go.sum stores checksums, not choices). You can print the graph yourself with go mod graph, one edge per line.
npm: newest matching, with duplicates allowed. npm requirements are Semantic versioning ranges like ^1.2.0 (“any 1.x from 1.2.0 up”), and npm picks the newest version that fits. When two packages need incompatible versions of the same library, npm can install both, nesting one copy under the package that needs it in node_modules. The result depends on what was published today, so package-lock.json records the exact choices to make installs repeatable.
Deadlock detection: look for a cycle
A Deadlock is a cycle in a Wait-for graph: draw an arrow from each waiting task to the task holding what it needs. T1 waits for T2's lock, T2 waits for T3's, T3 waits for T1's — a cycle, so nobody can ever continue.
Databases look for exactly that. PostgreSQL, for example, lets a blocked transaction wait for deadlock_timeout (1 second by default), then builds the wait-for graph among the waiting transactions and searches it for a cycle. If it finds one, it aborts one of the transactions on it with ERROR: deadlock detected, which breaks the cycle and lets the others continue. The full story is in Race conditions §10.
One subtlety: Go's fatal error: all goroutines are asleep - deadlock! is not cycle detection. The runtime just notices that no goroutine can run at all. Two goroutines deadlocked on each other while a third keeps running (say, an HTTP server) won't trigger it — that's why production hangs need the debugging tools from Race conditions §11.
Call graphs, tracing and blast radius
Services calling services form a directed Call graph. You rarely draw it by hand: distributed tracing records every request as a tree of spans, and tracing tools merge millions of those trees into a service map — a graph of who calls whom, with request rates and latencies as edge weights.
Once you have that graph, everyday questions become graph walks. Try the first two here:
- “If auth goes down, what breaks?” — BFS over the arrows backwards from auth: everything that calls it, directly or indirectly. This is your blast radius, and it's how you decide what to fail gracefully.
- “What does checkout need to be up?” — BFS forwards. If the answer is “nearly everything”, checkout's availability is roughly the product of all their availabilities.
- “Why is this request slow?” — inside one trace, the spans form a tree (each span's parent is the call that started it). The critical path is the longest chain of spans that ran one after another; speeding up anything off that path doesn't make the request faster.
PageRank: importance flowing along edges
Early search engines ranked pages by their words, which was easy to game. PageRank ranked them by the link graph instead: a page is important if important pages link to it. Picture a random surfer who keeps clicking random links (and now and then jumps to a random page). PageRank is the fraction of time they'd spend on each page. You compute it by starting every page with equal score and repeatedly passing each page's score out along its links, split evenly, until the numbers stop changing — a few dozen rounds over the whole graph. The same idea ranks influential accounts, important packages in an ecosystem, and suspicious accounts in fraud rings.
Graph queries in a database
When the graph lives in a relational database, SQL's WITH RECURSIVE can walk it. Here's “degrees of separation” on the friendship graph from §04, run on PostgreSQL 16:
WITH RECURSIVE reach(node, hops) AS (
SELECT 'A', 0 -- start
UNION -- UNION (not UNION ALL) drops exact duplicates
SELECT e.dst, r.hops + 1
FROM reach r JOIN edge e ON e.src = r.node
WHERE r.hops < 4 -- stop after 4 hops, or it never ends
)
SELECT node, min(hops) AS degrees
FROM reach GROUP BY node ORDER BY degrees, node;
-- A 0 · B 1 · C 1 · D 2 · E 2 · F 3 · G 3 · H 4(edge is a view that lists each friendship in both directions.) It's BFS level by level, with one important difference: there's no global visited set. UNION only removes identical (node, hops) rows, so a node reached at hop 2 is explored again at hop 3 and 4. Here the 8 people produced 14 rows; on a real social graph that blows up fast, which is why the hop limit is mandatory. When multi-hop traversals are the core of the product, a graph database (Graph database) stores each node's neighbours as direct pointers and runs these walks natively.
When the graph doesn't fit in memory
A web crawl or a large social network has billions of nodes and hundreds of billions of edges — terabytes, far beyond one machine's RAM. The playbook:
- Compress first. Replace string IDs with integers 0…V−1 and store neighbours in CSR form (two flat arrays, §03). Plenty of “big” graphs fit on one large machine after that, and a single machine avoids all network costs.
- Partition. Split the nodes across machines. The catch: every edge that crosses machines turns one array read into a network message, and real graphs have no clean cuts — a few “celebrity” nodes connect to everything. Partitioners try to keep neighbours together, often by splitting celebrity nodes' edges across machines instead.
- Think like a vertex. Systems in the Pregel family (Google's Pregel, Apache Giraph, Spark GraphX) run algorithms in rounds: every node reads the messages its neighbours sent last round, updates itself, and sends new messages. BFS becomes “if I just got reached, tell my neighbours”; PageRank becomes “send my score / out-degree to each neighbour”. Each round is one big parallel step with a barrier at the end.
- Precompute. Production systems rarely run BFS over a billion-node graph per request. They precompute (friends-of-friends lists, PageRank scores, reachability indexes) in batch jobs and serve the results from a cache or key-value store.
Pitfalls gallery
No visited set → infinite loop
Forget to check “have I seen this node?” and a single cycle — even A–B in an undirected graph, which goes A → B → A — sends BFS or DFS round forever. On a graph without cycles it still terminates, but can visit shared nodes over and over (exponentially many times on some graphs). Always keep a seen set, and in BFS mark nodes when you enqueue them (§04), not when you dequeue them.
Using DFS when you need the shortest path
DFS returns the first path it stumbles into. In the maze (§05) that was 38 moves against BFS's 28; in the friendship graph, 6 hops against 4. If the question contains “shortest”, “fewest” or “nearest”, you want BFS (all edges equal) or Dijkstra (weighted).
Recursive DFS on a deep graph
Recursive DFS in Node died at 5,707 levels on this machine (§05). A long dependency chain, a linked-list-shaped graph, or a big grid maze can be that deep. Go lasted 15 million levels, but a Go Stack overflow is a fatal crash that recover (Go's way to catch a panic) can't catch. For graphs you don't control: if you only need to visit nodes, use the explicit-stack loop from §05; if you need an order or a cycle check, use Kahn's algorithm (§07), which has no recursion at all. (The explicit-stack loop gives discovery order only — §05 explains why.)
Go map order makes output random
Ranging over a Go map gives a different order on every run, on purpose. A topological sort or traversal that loops over map keys returns a valid but different answer each time — and tests that compare exact output become flaky. Sort the keys (slices.Sorted(maps.Keys(m))) or keep nodes in a slice. (JS Map remembers insertion order, so this bites Go code much more often.)
Dijkstra with negative weights
Dijkstra won't crash or loop; it just quietly returns wrong distances (quiz Q5 shows one). If any weight can be negative — refunds, credits, log-transformed rates — use Bellman-Ford, or rethink the weights.
queue.shift() on a big frontier
In JS, shift() copies the whole remaining array. Fine for 100 items; for a BFS whose queue peaked at ~124,000 entries it made the run ~22× slower (§10). Use a head index (queue[head++]) or a proper deque.
Adding undirected edges only one way
For a friendship A–B you must add B to A's list and A to B's. Add only one and BFS from B can't find A — a bug that hides until someone searches from the “wrong” end. Write one addEdge(a, b) helper that does both, and use it everywhere.
“Seen before” ≠ cycle in a directed graph
Two packages importing the same third package is a diamond, not a cycle. Only an edge to a node that's still grey (on the current DFS path) proves a cycle (§06). Two-colour cycle checks report false cycles on every shared dependency.
An adjacency matrix for a big sparse graph
A matrix costs V² no matter how few edges exist: 100,000 nodes → 10 billion cells, even at one byte each that's 10 GB, to store a graph that fits in a few MB as an adjacency list. Use a matrix only for small or genuinely dense graphs (§03).
“Cycle detected” with no cycle
An error that says a cycle exists, without saying which nodes, sends a person grepping through hundreds of files. The cycle is already sitting on your DFS path when you find the back edge — print it, like Go does for import cycles and like TopoSort in §10.
Check yourself
Predict before you click. Q1–Q2 are covered in §04–§05, Q3 in §04 and §12, Q4 in §07, Q5 in §08 and §10, Q6 in §09.
A social app shows “2nd-degree connection” on profiles. You need the smallest number of friendship hops between two users. Every friendship counts the same.
const graph = new Map([
["A", ["B", "C"]], ["B", ["D"]], ["C", ["D"]], ["D", []],
]);
// BFS from A, neighbours in listed order, mark on enqueue
console.log(bfsOrder(graph, "A").join(" "));queue := []string{start}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, next := range g[cur] {
queue = append(queue, next) // no seen check
}
}Tasks: test needs build; build needs codegen; lint needs nothing. Which order is not a valid topological order?
Directed edges: S→A cost 2, S→B cost 5, B→A cost −4, A→C cost 1. You run the dijkstra function from §10 starting at S. What does it report for C? (The true cheapest route is S→B→A→C = 5 − 4 + 1 = 2.)
Kruskal is considering edge C–E. find(C) returns D and find(E) returns D. What happens?
Cheat sheet
map[string][]string / Map<node, node[]>. O(V + E) memory. Matrix only for small or dense graphs.| I want to… | Use | Cost |
|---|---|---|
| Fewest hops between two nodes | BFS + parent pointers | O(V + E) |
| Visit everything reachable / count components | BFS or DFS from every unseen node | O(V + E) |
| Know if a directed graph has a cycle (and which) | DFS with white / grey / black | O(V + E) |
| Order tasks so dependencies come first | Kahn's algorithm (waves = parallelism) | O(V + E) |
| Cheapest route, weights ≥ 0 | Dijkstra with a priority queue | O((V + E) log V) |
| Cheapest route toward one known goal, with a good estimate | A* | Usually far less than Dijkstra |
| Cheapest route with negative weights | Bellman-Ford (also detects negative cycles) | O(V · E) |
| “Are these two connected?” while edges keep arriving | Union-find | ≈ O(1) per operation |
| Cheapest network connecting every node | Kruskal (sort + union-find) | O(E log E) |
| Who breaks if X fails? | BFS over reversed edges | O(V + E) |