GUIDE · Graphs
Graph patterns
nodes + edges; traverse (BFS/DFS), order (topo), connect (union-find), shortest paths.
At a glance
- WHAT
- nodes + edges; traverse (BFS/DFS), order (topo), connect (union-find), shortest paths.
- TELL
- "edges", "connected", "prerequisites / dependencies", "shortest path", "islands / grid", "can reach", "cycle", "minimum spanning", "transform word to word".
- SAY
- adjacency list / matrix, BFS (level order, shortest in unweighted), DFS, visited set, connected components, topological sort (Kahn's / DFS post-order), Dijkstra (non-negative), Bellman-Ford (negative edges), union-find / disjoint set union (path compression, union by rank), Kruskal / Prim (MST), bipartite (2-coloring), implicit graph.
- BIG-O
- BFS/DFS O(V + E); Dijkstra O((V + E) log V); Bellman-Ford O(VE); union-find ~O(alpha(n)) per op.
The idea#
Picture a subway map. The dots are stations. The lines between them are tracks. Some tracks run one way. Some have a travel time written on them. You can ride every station, decide which station has to be built before another, glue stations into groups, or look for the cheapest ride.
A graph is nodes plus edges. You traverse it with breadth-first or depth-first search in O(V + E), order a directed graph with a topological sort, connect components with union-find at about O(α(n)) per operation, and find shortest paths with Dijkstra in O((V + E) log V) when every weight is non-negative, or with Bellman-Ford in O(V · E) when a weight can be negative.
Breadth-first search uses a queue, so the first time you reach a station is the fewest hops. Depth-first search uses a stack and finishes one branch before it starts the next.
A visited set keeps both walks finite. Directed cycle checks use three colors: white unseen, gray on the current path, black finished. An edge into a gray node is a cycle. Undirected, a visited neighbor is a cycle only when it is not the parent you just stepped from.
Why it matters#
The words are edges, connected, prerequisites, shortest path, islands, can reach, cycle, minimum spanning tree, and turning one word into another. Prerequisites are a directed graph, and a topological order is a legal build sequence. If the order comes out short, some nodes never reached in-degree zero, and the leftover is a cycle: the courses cannot be finished.
Shortest path forks before you write code. Unweighted means breadth-first search. Non-negative weights mean Dijkstra. A negative weight means Bellman-Ford, because a heap can close a node before a cheaper path arrives.
A small example#
function componentCount(n, edges) { const graph = Array.from({ length: n }, () => []); for (const [u, v] of edges) { graph[u].push(v); graph[v].push(u); } const seen = new Set(); function walk(u) { seen.add(u); for (const v of graph[u]) if (!seen.has(v)) walk(v); } let count = 0; for (let i = 0; i < n; i++) { if (!seen.has(i)) { walk(i); count++; } } return count;} const groups = componentCount(5, [[0, 1], [1, 2], [3, 4]]);groups is 2. One walk covers 0, 1, and 2. The next walk starts at 3 and picks up 4. Each new start is a component.
What the code below is doing#
Section 1 builds the adjacency list. Undirected is the default, so each edge is stored twice. Pass directed to store it once. Pass weighted and each neighbor is a pair [neighbor, weight]. O(V + E).
The breadth-first template is a queue, a visited set, and a parent map. The first visit is the shortest hop-count. Walk parents from the target back to the source and reverse. Section 2 is that template as a function, O(V + E) time and O(V) space. The queue dequeues with a head index so removing the front stays O(1). Unreachable returns an empty path.
The depth-first template is recursive (mark, then visit unseen neighbors) or iterative (a stack, skip what you have marked, push the neighbors). Section 3 writes both and records visit order. Section 4 counts connected components in an undirected graph: start a search from every unvisited node, and the number of starts is the number of components. Section 5 treats a grid as a graph. Four directions, and visiting land sinks it to '0' so you do not need a separate visited set. Time is O(m · n). Section 6 clones a graph. The map from original node to copy is also the visited set, so a cycle does not clone forever.
Section 7 detects a directed cycle with the three colors. A neighbor that is gray means a back edge. Section 8 detects an undirected cycle: a visited neighbor that is not your parent. Both are O(V + E).
Section 9 is Kahn's topological sort. Count in-degrees, queue every node at zero, pop it, and decrement its neighbors, enqueueing any neighbor that hits zero. If the result is shorter than n, the graph has a cycle and the function returns an empty order. Course schedule is "is the length n," after you point each edge from the prerequisite to the course. Section 10 is the depth-first version: push a node after its descendants, then reverse. A gray neighbor throws, and the catch returns an empty order.
Section 11 is Dijkstra for non-negative weights. A min-heap of [distance, node]. When you pop a pair whose distance is worse than the best already stored, that entry is stale and you skip it. O((V + E) log V). Section 12 is the Bellman-Ford sketch. Relax all edges V - 1 times. A further improvement means a negative cycle. O(V · E).
Section 13 is union-find: path compression, union by rank, about O(α(n)) per operation. Section 14 is Kruskal, sorting edges and keeping one when its ends sit in different components, O(E log E). Section 15 two-colors every component. The same color on both ends of an edge means the graph is not bipartite. Section 16 is a word ladder, an implicit graph of words one letter apart, breadth-first by level, O(N · L · 26).
When you talk about it, say this: breadth-first and depth-first are O(V + E), Dijkstra needs non-negative weights, Bellman-Ford tolerates negative weights in O(V · E), and union-find merges groups in nearly constant time.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Build adjacency list from edge list#
Map node -> [neighbors] (or [[nbr, w]] weighted)
function buildGraph(n, edges, { directed = false, weighted = false } = {}) { const g = Array.from({ length: n }, () => []); for (const [u, v, w] of edges) { g[u].push(weighted ? [v, w] : v); if (!directed) g[v].push(weighted ? [u, w] : u); } return g;}Verified by 2 asserts
assert.deepEqual(buildGraph(3, [[0, 1], [1, 2]]), [[1], [0, 2], [1]]);assert.deepEqual(buildGraph(3, [[0, 1, 5]], { directed: true, weighted: true }), [[[1, 5]], [], []]);BFS TEMPLATE (shortest path in unweighted graph; parent map for path reconstruction)#
// queue = [src]; visited = {src}; parent = {src: null}// while queue: u = dequeue// for v in adj[u]: if v not visited: visited.add(v); parent[v] = u; enqueue(v)// path: walk parent[] from target back to src, reverse.BFS shortest path with reconstruction#
O(V + E) / O(V)
function bfsPath(g, src, dst) { const parent = new Map([[src, null]]), q = [src]; for (let h = 0; h < q.length; h++) { // head index = O(1) dequeue const u = q[h]; if (u === dst) break; for (const v of g[u]) if (!parent.has(v)) { parent.set(v, u); q.push(v); } } if (!parent.has(dst)) return []; const path = []; for (let x = dst; x !== null; x = parent.get(x)) path.push(x); return path.reverse();}const g1 = buildGraph(6, [[0, 1], [0, 2], [1, 3], [2, 3], [3, 4]]);Verified by 3 asserts
assert.deepEqual(bfsPath(g1, 0, 4), [0, 1, 3, 4]);assert.deepEqual(bfsPath(g1, 0, 5), []); // unreachableassert.deepEqual(bfsPath(g1, 2, 2), [2]);DFS TEMPLATE: recursive dfs(u): visited.add(u); for v in adj[u]: if !visited: dfs(v)#
// iterative stack=[src]; while stack: u=pop; if visited skip; mark; push neighborsDFS recursive + iterative (visit order)#
O(V + E) / O(V)
function dfsRec(g, u, seen = new Set(), out = []) { seen.add(u); out.push(u); for (const v of g[u]) if (!seen.has(v)) dfsRec(g, v, seen, out); return out;}function dfsIter(g, src) { const seen = new Set(), out = [], st = [src]; while (st.length) { const u = st.pop(); if (seen.has(u)) continue; seen.add(u); out.push(u); for (let i = g[u].length - 1; i >= 0; i--) if (!seen.has(g[u][i])) st.push(g[u][i]); // reverse push = same order as recursive } return out;}Verified by 3 asserts
assert.deepEqual(dfsRec(g1, 0), [0, 1, 3, 2, 4]);assert.deepEqual(dfsIter(g1, 0), [0, 1, 3, 2, 4]);assert.deepEqual(dfsRec(buildGraph(1, []), 0), [0]);Connected components (undirected)#
DFS from every unvisited node; count starts
function countComponents(n, edges) { const g = buildGraph(n, edges), seen = new Set(); let count = 0; for (let i = 0; i < n; i++) if (!seen.has(i)) { count++; dfsRec(g, i, seen); } return count;}Verified by 3 asserts
assert.equal(countComponents(5, [[0, 1], [1, 2], [3, 4]]), 2);assert.equal(countComponents(3, []), 3);assert.equal(countComponents(0, []), 0);Number of islands (grid as graph)#
DIRS array; DFS sinks visited land to '0'
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];function numIslands(grid) { const m = grid.length, n = m ? grid[0].length : 0; const sink = (r, c) => { if (r < 0 || c < 0 || r >= m || c >= n || grid[r][c] !== '1') return; grid[r][c] = '0'; for (const [dr, dc] of DIRS) sink(r + dr, c + dc); }; let count = 0; for (let r = 0; r < m; r++) for (let c = 0; c < n; c++) if (grid[r][c] === '1') { count++; sink(r, c); } return count;}Verified by 3 asserts
assert.equal(numIslands([['1', '1', '0'], ['0', '1', '0'], ['1', '0', '1']]), 3);assert.equal(numIslands([['0']]), 0);assert.equal(numIslands([]), 0);Clone graph#
DFS with Map original -> copy (also serves as visited)
function cloneGraph(node, map = new Map()) { if (!node) return null; if (map.has(node)) return map.get(node); const copy = { val: node.val, neighbors: [] }; map.set(node, copy); for (const nb of node.neighbors) copy.neighbors.push(cloneGraph(nb, map)); return copy;}const n1 = { val: 1, neighbors: [] }, n2 = { val: 2, neighbors: [n1] };n1.neighbors.push(n2);const c1 = cloneGraph(n1);Verified by 4 asserts
assert.equal(c1.neighbors[0].val, 2);assert.notEqual(c1.neighbors[0], n2);assert.equal(c1.neighbors[0].neighbors[0], c1); // cycle preservedassert.equal(cloneGraph(null), null);Cycle detection, directed (3-color: WHITE unvisited, GRAY on stack, BLACK done)#
back edge to GRAY = cycle
function hasCycleDirected(n, edges) { const g = buildGraph(n, edges, { directed: true }), color = new Array(n).fill(0); const dfs = (u) => { color[u] = 1; for (const v of g[u]) { if (color[v] === 1) return true; if (color[v] === 0 && dfs(v)) return true; } color[u] = 2; return false; }; for (let i = 0; i < n; i++) if (color[i] === 0 && dfs(i)) return true; return false;}Verified by 3 asserts
assert.equal(hasCycleDirected(3, [[0, 1], [1, 2], [2, 0]]), true);assert.equal(hasCycleDirected(3, [[0, 1], [1, 2]]), false);assert.equal(hasCycleDirected(1, [[0, 0]]), true); // self-loopCycle detection, undirected (parent check)#
visited neighbor that is not the parent = cycle
function hasCycleUndirected(n, edges) { const g = buildGraph(n, edges), seen = new Set(); const dfs = (u, p) => { seen.add(u); for (const v of g[u]) { if (!seen.has(v)) { if (dfs(v, u)) return true; } else if (v !== p) return true; } return false; }; for (let i = 0; i < n; i++) if (!seen.has(i) && dfs(i, -1)) return true; return false;}Verified by 2 asserts
assert.equal(hasCycleUndirected(3, [[0, 1], [1, 2], [2, 0]]), true);assert.equal(hasCycleUndirected(3, [[0, 1], [1, 2]]), false);Topological sort: Kahn's (BFS on in-degree)#
queue nodes with indegree 0; pop, decrement neighbors
// If result.length < n -> cycle. Course Schedule I = "is length n"; II = the order itself.function topoKahn(n, edges) { const g = buildGraph(n, edges, { directed: true }), indeg = new Array(n).fill(0); for (const [, v] of edges) indeg[v]++; const q = []; for (let i = 0; i < n; i++) if (indeg[i] === 0) q.push(i); const order = []; for (let h = 0; h < q.length; h++) { const u = q[h]; order.push(u); for (const v of g[u]) if (--indeg[v] === 0) q.push(v); } return order.length === n ? order : []; // [] => cycle}const canFinish = (n, prereqs) => topoKahn(n, prereqs.map(([a, b]) => [b, a])).length === n; // [course, prereq] -> edge prereq->courseVerified by 4 asserts
assert.deepEqual(topoKahn(4, [[0, 1], [0, 2], [1, 3], [2, 3]]), [0, 1, 2, 3]);assert.deepEqual(topoKahn(2, [[0, 1], [1, 0]]), []);assert.equal(canFinish(2, [[1, 0]]), true);assert.equal(canFinish(2, [[1, 0], [0, 1]]), false);Topological sort: DFS post-order#
push u after all descendants; reverse at the end
function topoDFS(n, edges) { const g = buildGraph(n, edges, { directed: true }), state = new Array(n).fill(0), out = []; const dfs = (u) => { state[u] = 1; for (const v of g[u]) { if (state[v] === 1) throw new Error('cycle'); if (state[v] === 0) dfs(v); } state[u] = 2; out.push(u); }; try { for (let i = 0; i < n; i++) if (state[i] === 0) dfs(i); } catch { return []; } return out.reverse();}const t = topoDFS(4, [[0, 1], [0, 2], [1, 3], [2, 3]]);Verified by 2 asserts
assert.ok(t.indexOf(0) < t.indexOf(1) && t.indexOf(1) < t.indexOf(3) && t.indexOf(2) < t.indexOf(3));assert.deepEqual(topoDFS(2, [[0, 1], [1, 0]]), []);Dijkstra (non-negative weights)#
min-heap of [dist, node]; skip stale entries (d > dist[u])
class MinHeap { // minimal inline heap on [key, ...] tuples a = []; push(x) { const a = this.a; a.push(x); for (let i = a.length - 1, p; i > 0 && a[p = (i - 1) >> 1][0] > a[i][0]; i = p) [a[i], a[p]] = [a[p], a[i]]; } pop() { const a = this.a, top = a[0], 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[i], a[m]] = [a[m], a[i]]; i = m; } } return top; } get size() { return this.a.length; }}function dijkstra(n, edges, src) { const g = buildGraph(n, edges, { directed: true, weighted: true }), dist = new Array(n).fill(Infinity); const hp = new MinHeap(); dist[src] = 0; hp.push([0, src]); while (hp.size) { const [d, u] = hp.pop(); if (d > dist[u]) continue; // stale for (const [v, w] of g[u]) if (d + w < dist[v]) { dist[v] = d + w; hp.push([dist[v], v]); } } return dist;}Verified by 2 asserts
assert.deepEqual(dijkstra(4, [[0, 1, 4], [0, 2, 1], [2, 1, 2], [1, 3, 1]], 0), [0, 3, 1, 4]);assert.deepEqual(dijkstra(2, [], 0), [0, Infinity]);Bellman-Ford sketch#
relax ALL edges V-1 times; a V-th improvement means a negative cycle
// SAY: "handles negative edge weights; Dijkstra does not."function bellmanFord(n, edges, src) { const dist = new Array(n).fill(Infinity); dist[src] = 0; for (let i = 0; i < n - 1; i++) for (const [u, v, w] of edges) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; for (const [u, v, w] of edges) if (dist[u] + w < dist[v]) return null; // negative cycle return dist;}Verified by 2 asserts
assert.deepEqual(bellmanFord(3, [[0, 1, 4], [1, 2, -2], [0, 2, 5]], 0), [0, 4, 2]);assert.equal(bellmanFord(2, [[0, 1, 1], [1, 0, -3]], 0), null);Union-Find / Disjoint Set Union#
find with path compression; union by rank
class UnionFind { constructor(n) { this.parent = Array.from({ length: n }, (_, i) => i); this.rank = new Array(n).fill(0); this.count = n; } find(x) { while (this.parent[x] !== x) { this.parent[x] = this.parent[this.parent[x]]; x = this.parent[x]; } return x; } // path halving union(a, b) { let ra = this.find(a), rb = this.find(b); if (ra === rb) return false; if (this.rank[ra] < this.rank[rb]) [ra, rb] = [rb, ra]; this.parent[rb] = ra; if (this.rank[ra] === this.rank[rb]) this.rank[ra]++; this.count--; return true; } connected(a, b) { return this.find(a) === this.find(b); }}const uf = new UnionFind(5);uf.union(0, 1); uf.union(1, 2); uf.union(3, 4);Verified by 4 asserts
assert.equal(uf.count, 2);assert.equal(uf.connected(0, 2), true);assert.equal(uf.connected(0, 3), false);assert.equal(uf.union(0, 2), false); // already joined => would be a cycle in an undirected graphKruskal's MST#
sort edges by weight; union-find; take edge if it joins two components
// Prim's: grow from one node with a min-heap of crossing edges; O(E log V). Same MST weight.function kruskal(n, edges) { const uf = new UnionFind(n); let total = 0, used = 0; for (const [u, v, w] of [...edges].sort((a, b) => a[2] - b[2])) if (uf.union(u, v)) { total += w; used++; } return used === n - 1 ? total : -1; // -1 = disconnected}Verified by 2 asserts
assert.equal(kruskal(4, [[0, 1, 1], [1, 2, 2], [2, 3, 3], [0, 3, 10], [0, 2, 4]]), 6);assert.equal(kruskal(3, [[0, 1, 1]]), -1);Bipartite check (2-coloring)#
BFS; neighbor same color => not bipartite; handle all components
function isBipartite(g) { const color = new Array(g.length).fill(0); for (let s = 0; s < g.length; s++) { if (color[s]) continue; color[s] = 1; const q = [s]; for (let h = 0; h < q.length; h++) for (const v of g[q[h]]) { if (color[v] === color[q[h]]) return false; if (!color[v]) { color[v] = -color[q[h]]; q.push(v); } } } return true;}Verified by 3 asserts
assert.equal(isBipartite([[1, 3], [0, 2], [1, 3], [0, 2]]), true);assert.equal(isBipartite([[1, 2, 3], [0, 2], [0, 1, 3], [0, 2]]), false);assert.equal(isBipartite([[]]), true);Word ladder (BFS on implicit graph)#
neighbors = words one letter away; Set for dictionary; count levels
function ladderLength(begin, end, wordList) { const dict = new Set(wordList); if (!dict.has(end)) return 0; const q = [[begin, 1]]; dict.delete(begin); for (let h = 0; h < q.length; h++) { const [w, d] = q[h]; if (w === end) return d; for (let i = 0; i < w.length; i++) for (let c = 97; c <= 122; c++) { const nw = w.slice(0, i) + String.fromCharCode(c) + w.slice(i + 1); if (dict.delete(nw)) q.push([nw, d + 1]); // delete = visit } } return 0;}Verified by 3 asserts
assert.equal(ladderLength('hit', 'cog', ['hot', 'dot', 'dog', 'lot', 'log', 'cog']), 5);assert.equal(ladderLength('hit', 'cog', ['hot', 'dot', 'dog', 'lot', 'log']), 0);assert.equal(ladderLength('a', 'c', ['a', 'b', 'c']), 2);See also