../

Graphs

Graphs in TypeScript 7.0 (also checked with 6.0) under strict, with ES2025 built-ins; every example runs on Node 24 LTS, Node 26 and Bun 1.4. Neither language ships a graph, a queue or a heap, so this sheet builds them from arrays, Map, Set, typed arrays and a small Heap class: representations, BFS with a head index instead of shift(), DFS that survives V8's stack, Dijkstra, topological order, union-find, spanning trees, costs, typing, and the idioms graph problems use. The algorithms themselves, with JavaScript and Python beside TypeScript, are in Graph algorithms and Trees & graphs; the Python side is in Python graphs.

Representations

RepresentationTypeScriptBest forWatch out
Adjacency list, nodes 0..n-1Array.from({ length: n }, (): number[] => [])most problems: fast, no hashing (see Costs)Array(n).fill([]) puts one shared array in every slot
Adjacency list, string idsMap<string, string[]>named or sparse nodesget(u) is string[] | undefined; isolated nodes are absent
Adjacency setsadj.map((vs) => new Set(vs)) or Map<string, Set<string>>O(1) edge tests, dropping duplicate edgesiteration follows insertion order, so results stay deterministic
Weighted list[to, w] tuples: adj[u].push([v, w])Dijkstra, Primtype the tuple, or [v, w] widens to number[]
Weighted map of mapsMap<string, Map<string, number>>O(1) weight lookups and updatesa second edge between u and v overwrites the first
Adjacency matrixArray.from({ length: n }, () => new Uint8Array(n))dense graphs, Floyd–WarshallO(n²) memory: 10⁴ nodes is 10⁸ cells, 100 MB even as bytes
Edge listconst edges: [number, number, number][]Kruskal, Bellman–Fordno neighbor lookups
CSR (compressed rows)two Int32Arrays: start (n + 1 offsets) and targetslarge static graphs: the fastest BFS measured hereread-only: adding an edge means rebuilding both arrays
Implicitfunction* neighbors(state)grids, word ladders, puzzle statesnodes appear on demand: track them in a Set of encoded keys
Plain objectRecord<string, string[]>JSON inputkeys become strings, and "constructor" in adj is true before you add anything

An undirected edge is stored twice, once in each direction. A directed edge u → v goes only in adj[u].

const n = 4;
const edges: [number, number][] = [
  [0, 1], [0, 2], [1, 2], [2, 3],
];
 
const adj = Array.from({ length: n }, (): number[] => []);
for (const [u, v] of edges) {
  adj[u].push(v); // undirected: both directions
  adj[v].push(u);
}
adj; // [[1, 2], [0, 2], [0, 1, 3], [2]]
 
const sets = adj.map((vs) => new Set(vs));
sets[0].has(1); // true: an O(1) edge test
 
const matrix = adj.map(() => new Uint8Array(n));
for (const [u, v] of edges) matrix[u][v] = matrix[v][u] = 1;
[...matrix[2]]; // [1, 1, 0, 1]
 
// CSR: u's neighbors sit in targets from start[u] up to
// (not including) start[u + 1]
const start = new Int32Array(n + 1);
adj.forEach((vs, u) => {
  start[u + 1] = start[u] + vs.length;
});
const targets = Int32Array.from(adj.flat());
[...start]; // [0, 2, 4, 7, 8]
[...targets.subarray(start[2], start[3])]; // [0, 1, 3]
 
const roads = new Map([
  ["a", new Map([["b", 4], ["c", 1]])],
  ["c", new Map([["b", 2]])],
]);
roads.get("a")?.get("c"); // 1: the weight of a -> c
0 1 2 3 adj number[][] 0: [1, 2] 1: [0, 2] 2: [0, 1, 3] 3: [2] matrix Uint8Array rows 0 0 1 1 2 2 3 3 0 1 1 0 1 0 1 0 1 1 0 1 0 0 1 0 CSR two Int32Arrays start [0] 0 [1] 2 [2] 4 [3] 7 [4] 8 targets [0] 1 [1] 2 [2] 0 [3] 2 [4] 0 [5] 1 [6] 3 [7] 2 node 2's neighbors: targets[4] to [6] edges = [[0, 1], [0, 2], [1, 2], [2, 3]] edge list: one pair per edge
The graph built by the code above, in each representation

Building graphs

InputBuild
n and 0-indexed edgesArray.from({ length: n }, (): number[] => []), then push
nodes 1..nlength n + 1 and leave index 0 empty, or subtract 1 while reading
string idsMap<string, string[]> with a get-or-create step, or ids to ints: new Map(names.map((s, i) => [s, i]))
prerequisite pairs [a, b] ("b before a")the edge is b → a: check each problem's direction
a grid of charactersimplicit: neighbors computed from (r, c), or ids r * cols + c
a list of wordsimplicit: one-letter changes, or buckets keyed by a pattern like h*t (word ladder)
// n and m, then m edges; node ids start at one
const raw = "4 3\n1 2\n2 3\n3 1";
const [first, ...lines] = raw.split("\n");
const [n, m] = first.split(" ").map(Number);
const adj = Array.from(
  { length: n + 1 },
  (): number[] => [],
);
for (const line of lines.slice(0, m)) {
  const [u, v] = line.split(" ").map(Number);
  adj[u].push(v);
  adj[v].push(u);
}
adj.slice(1);
// [[2, 3], [1, 3], [2, 1], []]: node 4 has no edges
 
function addEdge(
  g: Map<string, string[]>,
  u: string,
  v: string,
): void {
  let list = g.get(u);
  if (!list) g.set(u, (list = [])); // get or create
  list.push(v);
}
const g = new Map<string, string[]>();
addEdge(g, "a", "b");
g.has("b"); // false: b has no entry of its own
g.get("b") ?? []; // []: reads without inserting
 
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
 
function* gridNeighbors(
  grid: readonly string[],
  r: number,
  c: number,
): Generator<[number, number]> {
  for (const [dr, dc] of DIRS) {
    const nr = r + dr;
    const nc = c + dc;
    const inside = nr >= 0 && nr < grid.length &&
      nc >= 0 && nc < grid[0].length;
    if (inside && grid[nr][nc] !== "#") yield [nr, nc];
  }
}
[...gridNeighbors(["..#", ".#."], 0, 1)]; // [[0, 0]]

The bounds check is not optional: grid[-1] is undefined, so grid[-1][0] throws a TypeError (Python would quietly read the last row). Node 26 and Bun also have g.getOrInsertComputed(u, () => []).push(v), which TypeScript 6.0 and 7.0 type with lib: esnext; Node 24 lacks it, so the explicit get-or-create is the portable form. More on Map is in Maps & sets.

A plain object as an adjacency map turns every key into a string and inherits keys from Object.prototype:

const obj: Record<string, string[]> = {};
"constructor" in obj; // true: inherited, not a node
Object.hasOwn(obj, "constructor"); // false
const byId: Record<number, string> = { 1: "a" };
byId["1"]; // "a": the key 1 was stored as "1"

BFS

type Adj = readonly (readonly number[])[];
 
function bfs(adj: Adj, src: number): Int32Array {
  const dist = new Int32Array(adj.length).fill(-1);
  dist[src] = 0;
  const queue = [src];
  // a head index instead of queue.shift(), which is O(n)
  for (let head = 0; head < queue.length; head++) {
    const u = queue[head];
    for (const v of adj[u]) {
      if (dist[v] === -1) { // mark when enqueued
        dist[v] = dist[u] + 1;
        queue.push(v);
      }
    }
  }
  return dist;
}
 
function levels(adj: Adj, src: number): number[][] {
  const seen = new Set([src]);
  const out: number[][] = [];
  for (let level = [src]; level.length > 0; ) {
    out.push(level);
    const next: number[] = [];
    for (const u of level) {
      for (const v of adj[u]) {
        if (!seen.has(v)) {
          seen.add(v);
          next.push(v);
        }
      }
    }
    level = next;
  }
  return out;
}
 
const adj: Adj = [
  [1, 2], [0, 3], [0, 3], [1, 2, 4], [3], [],
];
[...bfs(adj, 0)]; // [0, 1, 1, 2, 3, -1]
levels(adj, 0); // [[0], [1, 2], [3], [4]]
no edges 0 1 2 3 4 5 queue [0] 0 head = 0 [0] 0 [1] 1 [2] 2 head = 1 took 0 [0] 0 [1] 1 [2] 2 [3] 3 head = 2 took 1 [0] 0 [1] 1 [2] 2 [3] 3 head = 3 took 2 [0] 0 [1] 1 [2] 2 [3] 3 [4] 4 head = 4 took 3 [0] 0 [1] 1 [2] 2 [3] 3 [4] 4 head = 5 took 4 [0] 0 [1] 1 [2] 2 [3] 3 [4] 4 head = 5 head = length: done dist [0] [1] [2] [3] [4] [5] 0 -1 -1 -1 -1 -1 0 1 1 -1 -1 -1 0 1 1 2 -1 -1 0 1 1 2 -1 -1 0 1 1 2 3 -1 0 1 1 2 3 -1 0 1 1 2 3 -1 Int32Array current queued done

1/7

Start: dist[0] = 0, queue = [0] and head = 0. Every other node is -1, not reached yet.

VariantChange
several sources at oncestart with queue = [...sources], every source at distance 0
stop at a targetreturn when the target is enqueued: with unit weights, its distance is already final
the path itselfset parent[v] = u when enqueuing v (an Int32Array filled with -1), then walk back from the target
weights 0 or 10-1 BFS: weight-0 neighbors join the current level (below)
queue.shift()O(n) per call in V8: about 0.8 s against 4 to 6 ms for a head index on a 100,000-leaf star (Costs)
one Set as queue and visitedfor (const u of seen) visits values added during the loop: seen is the queue too
const adj = [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], []];
const seen = new Set([0]);
for (const u of seen) { // also visits values added below
  for (const v of adj[u]) seen.add(v);
}
[...seen]; // [0, 1, 2, 3, 4]: BFS order
 
type WAdj01 = readonly (readonly [to: number, w: 0 | 1])[][];
 
function zeroOneBfs(adj: WAdj01, src: number): number[] {
  const dist = adj.map(() => Infinity);
  dist[src] = 0;
  let level = [src];
  for (let d = 0; level.length > 0; d++) {
    const next: number[] = [];
    // level grows while it is scanned: 0-edges stay at d
    for (let i = 0; i < level.length; i++) {
      const u = level[i];
      if (dist[u] !== d) continue; // stale: improved since
      for (const [v, w] of adj[u]) {
        if (d + w < dist[v]) {
          dist[v] = d + w;
          (w === 0 ? level : next).push(v);
        }
      }
    }
    level = next;
  }
  return dist;
}
const wadj: WAdj01 = [
  [[1, 1], [2, 0]], [[3, 1]], [[1, 0]], [],
];
zeroOneBfs(wadj, 0); // [0, 0, 0, 1]

Marking on enqueue keeps each node in the queue at most once; marking on dequeue lets one node be queued many times. The 0-1 BFS keeps one array per distance instead of a deque, since JavaScript has none; a ring-buffer deque is in Arrays. The theory is in BFS.

DFS

Recursive DFS is the shortest to write, but V8 gives JavaScript a fixed stack (984 KB by default). The recursive countReachable below, run on a path graph, threw RangeError: Maximum call stack size exceeded past about 5,600 nodes on Node 24 and Node 26, and past about 29,000 on Bun 1.4. node --stack-size=… raises V8's limit, but past the operating system's thread stack (8 MB here) the process dies with a segmentation fault instead of throwing. Inputs with 10⁵ nodes need an explicit stack. Details are in Call stack and depth limits.

type Adj = readonly (readonly number[])[];
 
function countReachable(adj: Adj, src: number): number {
  const seen = new Uint8Array(adj.length);
  let count = 0;
  const dfs = (u: number): void => { // recursive
    seen[u] = 1;
    count++;
    for (const v of adj[u]) if (!seen[v]) dfs(v);
  };
  dfs(src);
  return count;
}
 
function preorder(adj: Adj, src: number): number[] {
  const seen = new Uint8Array(adj.length);
  const order: number[] = [];
  const stack = [src];
  while (stack.length > 0) {
    const u = stack.pop()!;
    if (seen[u]) continue; // pushed twice; done already
    seen[u] = 1; // mark on pop: the order recursion gives
    order.push(u);
    const vs = adj[u];
    // push in reverse so the first neighbor is on top
    for (let i = vs.length - 1; i >= 0; i--) {
      stack.push(vs[i]);
    }
  }
  return order;
}
 
function postorder(adj: Adj, src: number): number[] {
  const next = new Int32Array(adj.length); // cursor per node
  const seen = new Uint8Array(adj.length);
  const out: number[] = [];
  const stack = [src];
  seen[src] = 1;
  while (stack.length > 0) {
    const u = stack[stack.length - 1];
    if (next[u] < adj[u].length) {
      const v = adj[u][next[u]++]; // resume u's scan
      if (!seen[v]) {
        seen[v] = 1;
        stack.push(v);
      }
    } else {
      stack.pop(); // no neighbor left: u is finished
      out.push(u);
    }
  }
  return out;
}
 
const dag: Adj = [[1, 2], [3], [3], []];
countReachable(dag, 0); // 4
preorder(dag, 0); // [0, 1, 3, 2]
postorder(dag, 0); // [3, 1, 2, 0]
postorder(dag, 0).reverse(); // [0, 2, 1, 3]: a topo order
start: stack = [0] top 0: read adj[0][0] = 1, push top 1: read adj[1][0] = 3, push top 3: nothing left: finish 3 top 1: next[1] = 1 = length: finish 1 top 0: resume at adj[0][1] = 2, push top 2: read 3, already seen top 2: nothing left: finish 2 top 0: nothing left: finish 0 0 1 2 3 stack top 0 1 0 3 1 0 1 0 0 2 0 2 0 0 [] next [0] [1] [2] [3] 0 0 0 0 1 0 0 0 1 1 0 0 1 1 0 0 1 1 0 0 2 1 0 0 2 1 1 0 2 1 1 0 2 1 1 0 Int32Array cursor per node out [0] [1] [2] [3] 3 3 1 3 1 3 1 3 1 2 3 1 2 0 out = [3, 1, 2, 0]: reversed, [0, 2, 1, 3] is a topological order current on the stack finished

1/9

Start: the stack holds the source, 0, which is marked seen. Every cursor next[u] is 0: no neighbor scanned yet.

NeedTemplate
reachability, components, flood filliterative preorder (or BFS)
finishing order: topological sort, strongly connected componentsthe cursor stack in postorder
a directed cyclethree colors (white, gray, black): an edge to a gray node is a cycle (recipe)
an undirected cycleremember each node's parent, or use union-find
depth known to stay under a few thousandrecursion is fine and clearest

The next cursors do what a recursive call's local loop variable does: each node resumes its neighbor scan exactly where it paused. One Int32Array for the whole traversal replaces the iterator object per stack frame that a version built on adj[u].values() would allocate.

Shortest paths

Dijkstra needs a priority queue. This is the Heap from A typed heap, compacted: a binary min-heap ordered by cmp.

class Heap<T> {
  private a: T[];
  private cmp: (x: T, y: T) => number;
  constructor(
    cmp: (x: T, y: T) => number,
    items: Iterable<T> = [],
  ) {
    this.cmp = cmp;
    this.a = [...items];
    for (let i = (this.a.length >> 1) - 1; i >= 0; i--) {
      this.down(i);
    }
  }
  get size(): number {
    return this.a.length;
  }
  peek(): T | undefined {
    return this.a[0];
  }
  push(x: T): void {
    const a = this.a;
    let i = a.push(x) - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.cmp(a[i], a[p]) >= 0) break;
      [a[i], a[p]] = [a[p], a[i]];
      i = p;
    }
  }
  pop(): T | undefined {
    const a = this.a;
    if (a.length <= 1) return a.pop();
    const top = a[0];
    a[0] = a.pop()!;
    this.down(0);
    return top;
  }
  private down(i: number): void {
    const a = this.a;
    for (;;) {
      const l = 2 * i + 1;
      let m = i;
      if (l < a.length && this.cmp(a[l], a[m]) < 0) m = l;
      const r = l + 1;
      if (r < a.length && this.cmp(a[r], a[m]) < 0) m = r;
      if (m === i) return;
      [a[i], a[m]] = [a[m], a[i]];
      i = m;
    }
  }
}
 
type WAdj = readonly (readonly [to: number, w: number])[][];
 
function dijkstra(adj: WAdj, src: number) {
  const dist = new Float64Array(adj.length).fill(Infinity);
  const parent = new Int32Array(adj.length).fill(-1);
  dist[src] = 0;
  const heap = new Heap<[d: number, u: number]>(
    (x, y) => x[0] - y[0],
    [[0, src]],
  );
  while (heap.size > 0) {
    const [d, u] = heap.pop()!;
    if (d > dist[u]) continue; // stale: u improved later
    for (const [v, w] of adj[u]) {
      if (d + w < dist[v]) {
        dist[v] = d + w;
        parent[v] = u;
        heap.push([d + w, v]);
      }
    }
  }
  return { dist, parent };
}
 
function pathTo(parent: Int32Array, t: number): number[] {
  const out: number[] = [];
  for (; t !== -1; t = parent[t]) out.push(t);
  return out.reverse();
}
 
const adj: WAdj = [
  [[1, 4], [2, 1]],
  [[3, 1]],
  [[1, 2], [3, 5]],
  [],
];
const { dist, parent } = dijkstra(adj, 0);
[...dist]; // [0, 3, 1, 4]
pathTo(parent, 3); // [0, 2, 1, 3]
4 1 2 1 5 0 1 2 3 heap array [d, u], root at [0] [0] [0, 0] [0] [1, 2] [1] [4, 1] [0] [3, 1] [1] [4, 1] stale [2] [6, 3] [0] [4, 1] stale [1] [6, 3] stale [2] [4, 3] [0] [4, 3] [1] [6, 3] stale [0] [6, 3] stale [] (empty) start: heap = [[0, 0]] pop [0, 0] dist[1] = 4, dist[2] = 1: push both pop [1, 2] dist[1] = 3 via 2; dist[3] = 6 pop [3, 1] dist[3] = 4: push [4, 3] pop [4, 1]: 4 > dist[1] = 3 stale: skip pop [4, 3] 3 has no edges pop [6, 3]: 6 > dist[3] = 4 stale: skip; the heap is empty dist [0] [1] [2] [3] 0 Infinity Infinity Infinity 0 4 1 Infinity 0 3 1 6 0 3 1 4 0 3 1 4 0 3 1 4 0 3 1 4 Float64Array path to 3: [0, 2, 1, 3] current stale final

1/7

Start: dist[0] = 0, every other slot of the Float64Array is Infinity, and the heap array holds [0, 0].

TypeScript detailWhy
[d, u] entries with (x, y) => x[0] - y[0]the comparator reads numbers; ties between equal distances pop in any order, which Dijkstra doesn't mind
never x < y on tuplesarrays compare as strings: [10, 1] < [9, 1] is true, and TypeScript doesn't flag it
if (d > dist[u]) continuethe heap has no decrease-key: push the better distance and skip the stale entry when it surfaces
new Float64Array(n).fill(Infinity)an Int32Array can't hold Infinity: .fill(Infinity) stores 0
stop earlythe first time the target is popped, its distance is final
negative weightsDijkstra gives wrong answers; use Bellman–Ford
[10, 1] < [9, 1]; // true: compares "10,1" with "9,1"
new Int32Array(2).fill(Infinity); // Int32Array [0, 0]
 
type WEdge = readonly [u: number, v: number, w: number];
 
function bellmanFord(
  n: number,
  edges: readonly WEdge[],
  src: number,
  rounds: number,
): Float64Array {
  let dist = new Float64Array(n).fill(Infinity);
  dist[src] = 0;
  for (let r = 0; r < rounds; r++) {
    const next = dist.slice(); // read last round's values
    for (const [u, v, w] of edges) {
      if (dist[u] + w < next[v]) next[v] = dist[u] + w;
    }
    dist = next;
  }
  return dist;
}
 
const flights: WEdge[] = [
  [0, 1, 100], [1, 2, 100], [0, 2, 500],
];
bellmanFord(3, flights, 0, 3 - 1)[2]; // 200
bellmanFord(3, flights, 0, 1)[2]; // 500: one edge at most
 
const N = 3;
const d = Array.from({ length: N }, (): number[] =>
  Array(N).fill(Infinity),
);
for (let i = 0; i < N; i++) d[i][i] = 0;
const wEdges: WEdge[] = [[0, 1, 4], [1, 2, -2], [0, 2, 5]];
for (const [u, v, w] of wEdges) {
  d[u][v] = Math.min(d[u][v], w);
}
for (let k = 0; k < N; k++) { // Floyd–Warshall: k outermost
  for (let i = 0; i < N; i++) {
    for (let j = 0; j < N; j++) {
      const via = d[i][k] + d[k][j];
      if (via < d[i][j]) d[i][j] = via;
    }
  }
}
d[0]; // [0, 4, 2]

Copying dist each round limits a path to rounds edges, which answers "cheapest flight with at most k stops" with rounds = k + 1; updating dist in place would let one round chain several edges. When to pick which algorithm is in Other shortest paths and Dijkstra.

Topological sort

There is no graphlib in JavaScript: Kahn's algorithm is the tool. Count each node's incoming edges, start from the nodes with none, and free a node when its count reaches zero. The order array doubles as the queue.

type Edge = readonly [u: number, v: number]; // u before v
 
function kahn(
  n: number,
  edges: readonly Edge[],
): number[] | null {
  const adj = Array.from({ length: n }, (): number[] => []);
  const indeg = new Int32Array(n);
  for (const [u, v] of edges) {
    adj[u].push(v);
    indeg[v]++;
  }
  const order: number[] = [];
  for (let u = 0; u < n; u++) {
    if (indeg[u] === 0) order.push(u);
  }
  for (let head = 0; head < order.length; head++) {
    for (const v of adj[order[head]]) {
      if (--indeg[v] === 0) order.push(v);
    }
  }
  return order.length === n ? order : null; // short: a cycle
}
 
const deps: Edge[] = [
  [4, 2], [5, 2], [5, 0], [2, 3], [0, 3], [3, 1],
];
kahn(6, deps); // [4, 5, 2, 0, 3, 1]
kahn(2, [[0, 1], [1, 0]]); // null
4 5 2 0 3 1 indeg [0] [1] [2] [3] [4] [5] 1 1 2 2 0 0 1 1 1 2 0 0 0 1 0 2 0 0 0 1 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 Int32Array order 4 5 head = 0 4 5 head = 1 4 5 2 0 head = 2 4 5 2 0 head = 3 4 5 2 0 3 head = 4 4 5 2 0 3 1 head = 5 4 5 2 0 3 1 head = n: done indeg 0 at the start: 4 and 5 take 4: indeg[2] drops to 1 take 5: 2 and 0 reach 0, push both take 2: indeg[3] drops to 1 take 0: indeg[3] reaches 0, push 3 take 3: indeg[1] reaches 0, push 1 take 1: order.length = 6 = n, no cycle taken queued done

1/7

Counting incoming edges gives indeg = [1, 1, 2, 2, 0, 0]. Nodes 4 and 5 have none, so they start the order array, which is also the queue; head = 0.

NeedUse
an order, or "is there a cycle?"Kahn: fewer than n nodes in order means a cycle
the smallest order lexicographicallytake the smallest ready node each time: a heap, or a sort for a small alphabet (alien dictionary)
rounds of parallel work (semesters, build waves)Kahn one level at a time (course schedule in rounds)
an order from DFSpostorder from every unseen node, reversed
the cycle itselfthree-color DFS, keeping the stack: the cycle is the stack from the gray node up

Union-find

class DSU {
  readonly parent: Int32Array;
  readonly size: Int32Array;
  count: number; // number of components
 
  constructor(n: number) {
    this.parent = new Int32Array(n).map((_, i) => i);
    this.size = new Int32Array(n).fill(1);
    this.count = n;
  }
 
  find(x: number): number {
    const p = this.parent;
    while (p[x] !== x) {
      p[x] = p[p[x]]; // path halving
      x = p[x];
    }
    return x;
  }
 
  union(a: number, b: number): boolean {
    let ra = this.find(a);
    let rb = this.find(b);
    if (ra === rb) return false; // same set: a cycle edge
    if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
    this.parent[rb] = ra; // hang the smaller tree
    this.size[ra] += this.size[rb];
    this.count--;
    return true;
  }
}
 
const dsu = new DSU(5);
[dsu.union(0, 1), dsu.union(1, 2), dsu.union(0, 2)];
// [true, true, false]: the third edge closes a cycle
dsu.count; // 3: {0, 1, 2}, {3}, {4}
 
const d6 = new DSU(6);
d6.union(0, 1);
d6.union(2, 3);
d6.union(1, 3); // true: root 2 hangs under 0
d6.union(4, 5);
d6.find(3); // 0, and the path is halved: parent[3] = 0
d6.union(3, 5); // true: root 4 hangs under 0
d6.union(1, 4); // false: already one set
[...d6.parent]; // [0, 0, 0, 0, 0, 4]
parent [0] [1] [2] [3] [4] [5] Int32Array 0 1 2 3 4 5 0 1 2 3 4 5 new DSU(6) count = 6 six one-node trees: parent[i] = i 1 0 2 3 4 5 0 0 2 3 4 5 union(0, 1) → true count = 5 equal sizes: 1 hangs under 0 1 0 3 2 4 5 0 0 2 2 4 5 union(2, 3) → true count = 4 2 becomes the root of {2, 3} 1 3 2 0 4 5 0 0 0 2 4 5 union(1, 3) → true count = 3 roots 0 and 2, sizes 2 and 2: 2 hangs under 0 1 3 2 0 5 4 0 0 0 2 4 4 union(4, 5) → true count = 2 5 hangs under 4 1 2 3 0 5 4 0 0 0 0 4 4 find(3) → 0 count = 2 path halving: parent[3] = parent[2] = 0 1 2 3 5 4 0 0 0 0 0 0 4 union(3, 5) → true count = 1 size 4 beats size 2: root 4 hangs under 0 1 2 3 5 4 0 0 0 0 0 0 4 union(1, 4) → false count = 1 both find root 0: an edge inside one set arrows point to parent root

1/8

new DSU(6): six one-node trees. parent[i] = i marks every node as a root, and count = 6.

For string ids or nodes that appear on the fly, the same structure fits in a Map, generic in the node type:

class UnionFind<T> {
  private parent = new Map<T, T>();
 
  find(x: T): T {
    const parent = this.parent;
    if (!parent.has(x)) parent.set(x, x); // first sight
    let p = parent.get(x)!;
    while (p !== x) {
      const grand = parent.get(p)!;
      parent.set(x, grand); // path halving
      x = grand;
      p = parent.get(x)!;
    }
    return x;
  }
 
  union(a: T, b: T): boolean {
    const ra = this.find(a);
    const rb = this.find(b);
    if (ra === rb) return false;
    this.parent.set(ra, rb);
    return true;
  }
}
 
const people = new UnionFind<string>();
people.union("ann", "bo");
[people.find("ann") === people.find("bo"),
  people.find("cy") === people.find("bo")];
// [true, false]

The iterative find never recurses, so deep chains can't overflow the stack. Theory and uses are in Union-find.

Minimum spanning trees

Kruskal sorts the edges and keeps each one that joins two different components. Prim grows one tree from a start node; on a dense graph stored as a matrix, the O(V²) array scan below beats a heap.

type WEdge = readonly [u: number, v: number, w: number];
 
function kruskal(n: number, edges: readonly WEdge[]) {
  const parent = new Int32Array(n).map((_, i) => i);
  const find = (x: number): number => {
    // path halving: x jumps to its grandparent
    while (parent[x] !== x) {
      x = parent[x] = parent[parent[x]];
    }
    return x;
  };
  const tree: WEdge[] = [];
  let total = 0;
  for (const e of edges.toSorted((a, b) => a[2] - b[2])) {
    const ru = find(e[0]);
    const rv = find(e[1]);
    if (ru === rv) continue; // would close a cycle
    parent[ru] = rv;
    tree.push(e);
    total += e[2];
  }
  // fewer than n - 1 edges: the graph is disconnected
  return { total, tree };
}
 
type Matrix = readonly (readonly number[])[];
 
function primDense(w: Matrix): number {
  const n = w.length;
  // best[v]: the cheapest edge from the tree to v
  const best = new Float64Array(n).fill(Infinity);
  const inTree = new Uint8Array(n);
  best[0] = 0;
  let total = 0;
  for (let k = 0; k < n; k++) {
    let u = -1;
    for (let v = 0; v < n; v++) {
      if (inTree[v]) continue;
      if (u === -1 || best[v] < best[u]) u = v;
    }
    inTree[u] = 1;
    total += best[u]; // Infinity: the graph is disconnected
    for (let v = 0; v < n; v++) {
      if (!inTree[v] && w[u][v] < best[v]) best[v] = w[u][v];
    }
  }
  return total;
}
 
const edges: WEdge[] = [
  [0, 1, 4], [0, 2, 1], [1, 2, 2], [1, 3, 5], [2, 3, 8],
];
kruskal(4, edges);
// { total: 8, tree: [[0, 2, 1], [1, 2, 2], [1, 3, 5]] }
 
const I = Infinity;
const w = [
  [I, 4, 1, I],
  [4, I, 2, 5],
  [1, 2, I, 8],
  [I, 5, 8, I],
];
primDense(w); // 8

Costs

OperationCostNotes
BFS, DFSO(V + E)every node once, every edge once (twice when undirected)
Dijkstra with a binary heapO((V + E) log V)stale entries mean up to E heap items
0-1 BFSO(V + E)
Bellman–FordO(V · E)
Floyd–WarshallO(V³)
KahnO(V + E)
union-findO(α(n)) amortized per operationeffectively constant with path halving and union by size
KruskalO(E log E)the sort dominates
Prim, dense array scanO(V²)better than a heap when E is near V²
adjacency matrixO(V²) memory

Measured on Node 24 with one BFS over a random undirected graph of 200,000 nodes and 1,000,000 edges (the median of 7 runs):

RepresentationTime
CSR: Int32Array offsets and targets, Int32Array queue24 ms
number[][], Int32Array dist, head-index queue81 ms
Map<number, number[]>128 ms
number[][] with a Set<number> for visited228 ms
Map<string, string[]> with a Set<string>0.6 to 0.8 s across runs

Arrays win because indexing needs no hashing, and typed arrays win again because they hold raw 32-bit integers. The queue matters more: a BFS over a star with 100,000 leaves took about 0.8 s on Node 24 with queue.shift(), which moves the whole array on every call, and 4 to 6 ms with a head index. Bun's shift() was much cheaper on the same test (11 to 21 ms), but code that relies on that is fast in one runtime only.

DS&A idioms

The graph moves problem solutions are built from, and the trap in each. The patterns are in the DS&A sheets (linked in the last column).

JobTypeScriptWatch outMore
Adjacency from edgesArray.from({ length: n }, (): number[] => []), then push both ways if undirectedArray(n).fill([]) shares one arraySetup
Visitednew Int32Array(n).fill(-1) as dist, or new Uint8Array(n), marked when enqueuedmarking on dequeue queues a node many timesBFS
Queuean array plus a head indexshift() is O(n) in V8Stacks, queues & deques
Grid as a graphDIRS as const, a bounds check, ids r * cols + cgrid[-1] is undefined, and reading from it throwsGrids as graphs
Composite keysnumbers r * cols + c, or strings `${r},${c}`arrays as Set or Map keys compare by reference: [1, 2] never matches a new [1, 2]Maps & sets
ComponentsBFS or DFS from every unseen node, or DSU.countloop over 0..n-1: isolated nodes are components tooComponents and cycles
Undirected cycleDSU.union returns false, or DFS that skips the parentthe edge back to the parent is not a cycleUnion-find
Directed cyclethree colors, or Kahn returning too few nodesa plain visited set flags harmless diamondsRecipe
Bipartitenew Int8Array(n).fill(-1) colors; neighbors get 1 - color[u]start from every uncolored node: the graph may be disconnectedIs it bipartite?
Topological orderKahn with indeg in an Int32Arrayprerequisite pairs often point the other wayTopological sort
Shortest path, unweightedBFS with diststop when the target is enqueuedBFS
Shortest path, weights ≥ 0Dijkstra: [d, u] entries, skip d > dist[u]no decrease-key; never compare tuples with <Dijkstra
Weights 0 or 10-1 BFS with one array per distancea node may improve after it is queuedOther shortest paths
At most k edgesBellman–Ford with k + 1 rounds, copying dist each roundin-place updates chain several edges in one roundShortest paths
Minimum spanning treeKruskal: toSorted by weight, keep edges union accepts; Prim on a matrixPrim must skip nodes already in the treeMinimum spanning tree
String node idsMap<string, string[]>, or map names to intsget returns undefined for unknown nodes: default with ?? []Maps & sets
Priority queuethe Heap<T> class with a comparatorthere is no built-in heapA typed heap
Clone a grapha Map from old node to new, filled on first sightcreate the copy before visiting its neighbors, or cycles never endRecipe
Deep graphsan explicit stackabout 5,600 frames of recursive DFS in NodeDepth limits
type Adj = readonly (readonly number[])[];
 
function allPaths(adj: Adj, target: number): number[][] {
  const out: number[][] = [];
  const path = [0];
  const go = (u: number): void => {
    if (u === target) {
      out.push([...path]); // copy: path keeps changing
      return;
    }
    for (const v of adj[u]) {
      path.push(v);
      go(v);
      path.pop();
    }
  };
  go(0);
  return out;
}
 
function countPaths(adj: Adj, target: number): number {
  const memo = new Map<number, number>(); // a DAG: no cycles
  const ways = (u: number): number => {
    if (u === target) return 1;
    let total = memo.get(u);
    if (total === undefined) {
      total = 0;
      for (const v of adj[u]) total += ways(v);
      memo.set(u, total);
    }
    return total;
  };
  return ways(0);
}
 
const dag: Adj = [[1, 2], [3], [3], []];
allPaths(dag, 3); // [[0, 1, 3], [0, 2, 3]]
countPaths(dag, 3); // 2

Typing

TypeMeans
number[][]adjacency list over nodes 0..n-1
readonly (readonly number[])[]the same, read-only: a function taking it promises not to edit the graph
readonly [to: number, w: number]a weighted neighbor; the labels show up in editor hovers
Map<string, string[]>, ReadonlyMap<string, readonly string[]>adjacency over string ids
Map<string, Map<string, number>>weighted map of maps
ReadonlyMap<T, Iterable<T>>any read-only graph a generic function accepts
Int32Array, Float64Array, Uint8Arraydist, parent and seen arrays: fixed length, numbers only
function bfsOrder<T>(
  g: ReadonlyMap<T, Iterable<T>>,
  start: T,
): T[] {
  const seen = new Set([start]);
  const order = [start];
  for (const u of order) { // also visits pushed items
    for (const v of g.get(u) ?? []) {
      if (!seen.has(v)) {
        seen.add(v);
        order.push(v);
      }
    }
  }
  return order;
}
 
const g = new Map([["a", ["b", "c"]], ["b", ["d"]]]);
bfsOrder(g, "a"); // ["a", "b", "c", "d"]
const adj = [[1], [0, 2], []];
bfsOrder(new Map(adj.entries()), 0); // [0, 1, 2]
 
type Neighbor = readonly [to: number, w: number];
const wadj: Neighbor[][] = [[[1, 5]], []];
// inferred as plain nested number arrays:
const loose = [[[1, 5]], []];
const [to, w] = wadj[0][0]; // to: number, w: number
// @ts-expect-error: plain arrays lack the tuple shape
const strict: Neighbor[][] = loose;

With noUncheckedIndexedAccess (opens in a new tab), every index read gains | undefined, typed arrays included:

Expressionstrictplus noUncheckedIndexedAccess
adj[u] on number[][]number[]number[] | undefined
dist[v] on Int32Arraynumbernumber | undefined
queue[head]numbernumber | undefined
const [u, v] = edge on readonly [number, number]number, numberunchanged: tuple slots are known
for (const u of order)numberunchanged: no index read
g.get(u) on a MapV | undefinedunchanged

The flag catches real bugs (adj[n] past the end) but turns graph code into a stream of !. The idioms that read cleanly either way: iterate with for...of instead of indexes, default missing lists with adj[u] ?? [], use tuples for edges, and write dist[v]! only where the index was just checked.

Coming from Python

PythonTypeScript
[[] for _ in range(n)]Array.from({ length: n }, (): number[] => [])
[[]] * n: one shared listArray(n).fill([]): the same trap
defaultdict(list)Map<string, string[]> plus get-or-create, or getOrInsertComputed (Node 26, Bun)
deque.popleft(), O(1)an array and a head index; shift() is O(n) in V8
set() for visitedSet, or a Uint8Array when nodes are 0..n-1
math.infInfinity, in a Float64Array or a plain array, never an Int32Array
heapqno built-in heap: a Heap<T> class (Heaps)
(dist, node) tuples compare element by element[dist, node] arrays need a comparator; < compares them as strings
graphlib.TopologicalSorterKahn's algorithm by hand
dict keys keep their type: 1 and "1" differobject keys become strings; Map keys keep their type
tuples as dict or set keys, compared by valuearrays as Map keys compare by reference: encode them as numbers or strings
grid[-1] reads the last rowgrid[-1] is undefined; grid.at(-1) reads the last row
1,000 frames by default, sys.setrecursionlimitabout 5,600 frames of recursive DFS in Node, a fixed V8 stack

Recipes

Number of islands

When you count connected regions of a grid and the regions can be too big to recurse through.

const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;
 
function numIslands(grid: readonly string[][]): number {
  const rows = grid.length;
  const cols = grid[0]?.length ?? 0;
  // one id per cell: r * cols + c
  const seen = new Uint8Array(rows * cols);
  let islands = 0;
  for (let r0 = 0; r0 < rows; r0++) {
    for (let c0 = 0; c0 < cols; c0++) {
      const id0 = r0 * cols + c0;
      if (seen[id0] || grid[r0][c0] !== "1") continue;
      islands++;
      seen[id0] = 1;
      const stack = [id0];
      while (stack.length > 0) {
        const id = stack.pop()!;
        const r = Math.floor(id / cols);
        const c = id % cols;
        for (const [dr, dc] of DIRS) {
          const nr = r + dr;
          const nc = c + dc;
          if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) {
            continue;
          }
          const nid = nr * cols + nc;
          if (!seen[nid] && grid[nr][nc] === "1") {
            seen[nid] = 1;
            stack.push(nid);
          }
        }
      }
    }
  }
  return islands;
}
 
const sea = ["11000", "11000", "00100", "00011"];
numIslands(sea.map((row) => [...row])); // 3
const strip = (n: number) => ["1".repeat(n).split("")];
numIslands(strip(100_000)); // 1: no recursion needed

One number per cell keeps the visited set a flat Uint8Array; a Set of `${r},${c}` strings works too but builds a string per cell.

Course schedule in rounds

When tasks with prerequisites run in rounds (semesters, build waves) and you need the rounds.

type Prereq = readonly [course: number, before: number];
 
function rounds(
  n: number,
  prereqs: readonly Prereq[],
): number[][] | null {
  const adj = Array.from({ length: n }, (): number[] => []);
  const indeg = new Int32Array(n);
  for (const [course, before] of prereqs) {
    adj[before].push(course); // before -> course
    indeg[course]++;
  }
  const out: number[][] = [];
  let ready: number[] = [];
  for (let u = 0; u < n; u++) {
    if (indeg[u] === 0) ready.push(u);
  }
  let done = 0;
  while (ready.length > 0) {
    out.push(ready);
    done += ready.length;
    const next: number[] = [];
    for (const u of ready) {
      for (const v of adj[u]) {
        if (--indeg[v] === 0) next.push(v);
      }
    }
    ready = next;
  }
  return done === n ? out : null; // null: a cycle
}
 
rounds(4, [[1, 0], [2, 0], [3, 1], [3, 2]]);
// [[0], [1, 2], [3]]: three semesters
rounds(2, [[1, 0], [0, 1]]); // null

out.length is the fewest rounds; out.flat() is one valid order.

Clone a graph

When you must deep-copy a graph of node objects, cycles included.

class GraphNode {
  val: number;
  neighbors: GraphNode[] = [];
  constructor(val: number) {
    this.val = val;
  }
}
 
function cloneGraph(
  start: GraphNode | null,
): GraphNode | null {
  if (!start) return null;
  const copies = new Map<GraphNode, GraphNode>();
  copies.set(start, new GraphNode(start.val));
  const stack = [start];
  while (stack.length > 0) {
    const old = stack.pop()!;
    const copy = copies.get(old)!;
    for (const nb of old.neighbors) {
      let c = copies.get(nb);
      if (!c) { // create on first sight
        c = new GraphNode(nb.val);
        copies.set(nb, c);
        stack.push(nb);
      }
      copy.neighbors.push(c);
    }
  }
  return copies.get(start)!;
}
 
const [a, b, c] = [1, 2, 3].map((v) => new GraphNode(v));
a.neighbors = [b, c];
b.neighbors = [a, c];
c.neighbors = [a, b];
const a2 = cloneGraph(a)!;
[a2.val, a2.neighbors.map((nb) => nb.val)]; // [1, [2, 3]]
[a2 !== a, a2.neighbors[0].neighbors[0] === a2];
// [true, true]: a new graph with the same shape

Map keys compare objects by reference, so it maps each original node to its copy even when two nodes hold the same value.

Accounts merge

When records that share any key (an email) belong together: union-find over strings.

function accountsMerge(accounts: string[][]): string[][] {
  const parent = new Map<string, string>();
  const find = (x: string): string => {
    if (!parent.has(x)) parent.set(x, x);
    let p = parent.get(x)!;
    while (p !== x) {
      const grand = parent.get(p)!;
      parent.set(x, grand);
      x = grand;
      p = parent.get(x)!;
    }
    return x;
  };
  const owner = new Map<string, string>();
  for (const [name, first, ...rest] of accounts) {
    owner.set(first, name);
    find(first); // register one-email accounts too
    for (const email of rest) {
      owner.set(email, name);
      parent.set(find(email), find(first));
    }
  }
  const groups = Map.groupBy(owner.keys(), (e) => find(e));
  return [...groups.values()].map((emails) => [
    owner.get(emails[0])!,
    ...emails.sort(),
  ]);
}
 
const accounts = [
  ["ann", "a@x", "b@x"],
  ["ann", "a@x", "c@x"],
  ["bo", "d@x"],
  ["ann", "e@x"],
];
for (const row of accountsMerge(accounts)) console.log(row);
// ["ann", "a@x", "b@x", "c@x"]
// ["bo", "d@x"]
// ["ann", "e@x"]

Two people with the same name stay separate unless they share an email; the name never joins groups. Map.groupBy (ES2024) keeps the groups in first-seen order.

Directed cycle with an explicit stack

When a directed graph may be deep enough to break recursion and you need to know whether it has a cycle.

const WHITE = 0; // unseen
const GRAY = 1; // on the current path
const BLACK = 2; // finished
 
type Adj = readonly (readonly number[])[];
 
function hasCycle(adj: Adj): boolean {
  const color = new Uint8Array(adj.length);
  const next = new Int32Array(adj.length);
  for (let s = 0; s < adj.length; s++) {
    if (color[s] !== WHITE) continue;
    color[s] = GRAY;
    const stack = [s];
    while (stack.length > 0) {
      const u = stack[stack.length - 1];
      if (next[u] < adj[u].length) {
        const v = adj[u][next[u]++];
        if (color[v] === GRAY) return true; // back edge
        if (color[v] === WHITE) {
          color[v] = GRAY;
          stack.push(v);
        }
      } else {
        color[u] = BLACK; // every neighbor finished
        stack.pop();
      }
    }
  }
  return false;
}
 
hasCycle([[1], [2], [0]]); // true
hasCycle([[1, 2], [3], [3], []]); // false: a diamond
const chain = (n: number) =>
  Array.from({ length: n }, (_, i) =>
    i + 1 < n ? [i + 1] : [],
  );
hasCycle(chain(100_000)); // false, with no recursion

A black node was fully explored without finding a cycle, so reaching it again (the diamond's second path to node 3) is harmless; only gray means "still on the current path".

Evaluate division

When equations like a / b = 2 form a weighted graph and queries ask for products along a path.

type Pair = readonly [string, string];
 
function calcEquation(
  eqs: readonly Pair[],
  vals: readonly number[],
  queries: readonly Pair[],
): number[] {
  const g = new Map<string, Map<string, number>>();
  const link = (a: string, b: string, k: number) => {
    let m = g.get(a);
    if (!m) g.set(a, (m = new Map()));
    m.set(b, k);
  };
  eqs.forEach(([a, b], i) => {
    link(a, b, vals[i]); // a / b = k
    link(b, a, 1 / vals[i]);
  });
  return queries.map(([src, dst]) => {
    if (!g.has(src) || !g.has(dst)) return -1;
    const ratio = new Map([[src, 1]]); // src / node
    for (const [u, r] of ratio) { // sees entries added below
      for (const [v, k] of g.get(u)!) {
        if (!ratio.has(v)) ratio.set(v, r * k);
      }
    }
    return ratio.get(dst) ?? -1;
  });
}
 
const eqs: Pair[] = [["a", "b"], ["b", "c"]];
const qs: Pair[] = [
  ["a", "c"], ["b", "a"], ["a", "e"], ["x", "x"],
];
calcEquation(eqs, [2, 3], qs); // [6, 0.5, -1, -1]

A Map iterates its entries in insertion order and visits entries added during the loop, so the ratio map is the BFS queue and the visited set at once.

Alien dictionary: smallest valid order

When letters must be ordered from a sorted word list and ties should go to the smallest letter.

function alienOrder(words: readonly string[]): string {
  const chars = [...new Set(words.join(""))];
  const adj = new Map<string, Set<string>>();
  const indeg = new Map<string, number>();
  for (const ch of chars) {
    adj.set(ch, new Set());
    indeg.set(ch, 0);
  }
  for (let i = 0; i + 1 < words.length; i++) {
    const a = words[i];
    const b = words[i + 1];
    const len = Math.min(a.length, b.length);
    let j = 0;
    while (j < len && a[j] === b[j]) j++;
    if (j === len) {
      // a prefix must come first: "ab" before "abc"
      if (a.length > b.length) return "";
      continue;
    }
    const out = adj.get(a[j])!; // a[j] comes before b[j]
    if (!out.has(b[j])) {
      out.add(b[j]);
      indeg.set(b[j], indeg.get(b[j])! + 1);
    }
  }
  const ready = chars.filter((ch) => indeg.get(ch) === 0);
  let order = "";
  while (ready.length > 0) {
    // 26 letters at most: a sort is cheaper than a heap
    ready.sort((x, y) => (x < y ? 1 : -1)); // smallest last
    const ch = ready.pop()!;
    order += ch;
    for (const nb of adj.get(ch)!) {
      indeg.set(nb, indeg.get(nb)! - 1);
      if (indeg.get(nb) === 0) ready.push(nb);
    }
  }
  return order.length === chars.length ? order : "";
}
 
alienOrder(["wrt", "wrf", "er", "ett", "rftt"]); // "wertf"
alienOrder(["ca", "cb"]); // "abc": c waits its turn
alienOrder(["z", "x", "z"]); // "": a cycle

Taking the smallest ready letter instead of the oldest is the whole change from Kahn's algorithm. With a large alphabet, use a Heap<string> instead of sorting each round.

Word ladder

When the graph is words one letter apart and you want the length of the shortest chain.

function ladderLength(
  begin: string,
  end: string,
  words: readonly string[],
): number {
  if (!words.includes(end)) return 0;
  // "h*t" -> every word that matches it
  const buckets = new Map<string, string[]>();
  const keys = (w: string): string[] => {
    const out: string[] = [];
    for (let i = 0; i < w.length; i++) {
      out.push(`${w.slice(0, i)}*${w.slice(i + 1)}`);
    }
    return out;
  };
  for (const w of [begin, ...words]) {
    for (const key of keys(w)) {
      let list = buckets.get(key);
      if (!list) buckets.set(key, (list = []));
      list.push(w);
    }
  }
  const dist = new Map([[begin, 1]]);
  // BFS: the loop also visits entries added inside it
  for (const [w, d] of dist) {
    if (w === end) return d;
    for (const key of keys(w)) {
      for (const nb of buckets.get(key) ?? []) {
        if (!dist.has(nb)) dist.set(nb, d + 1);
      }
      buckets.delete(key); // each bucket is used once
    }
  }
  return 0;
}
 
const dict = ["hot", "dot", "dog", "lot", "log", "cog"];
ladderLength("hit", "cog", dict); // 5: hit hot dot dog cog
ladderLength("hit", "cog", dict.slice(0, 5)); // 0

Deleting a bucket after its first use keeps the BFS O(n · L²) for n words of length L: without it, every word in a big bucket would rescan the whole bucket.

References