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
| Representation | TypeScript | Best for | Watch out |
|---|---|---|---|
Adjacency list, nodes 0..n-1 | Array.from({ length: n }, (): number[] => []) | most problems: fast, no hashing (see Costs) | Array(n).fill([]) puts one shared array in every slot |
| Adjacency list, string ids | Map<string, string[]> | named or sparse nodes | get(u) is string[] | undefined; isolated nodes are absent |
| Adjacency sets | adj.map((vs) => new Set(vs)) or Map<string, Set<string>> | O(1) edge tests, dropping duplicate edges | iteration follows insertion order, so results stay deterministic |
| Weighted list | [to, w] tuples: adj[u].push([v, w]) | Dijkstra, Prim | type the tuple, or [v, w] widens to number[] |
| Weighted map of maps | Map<string, Map<string, number>> | O(1) weight lookups and updates | a second edge between u and v overwrites the first |
| Adjacency matrix | Array.from({ length: n }, () => new Uint8Array(n)) | dense graphs, Floyd–Warshall | O(n²) memory: 10⁴ nodes is 10⁸ cells, 100 MB even as bytes |
| Edge list | const edges: [number, number, number][] | Kruskal, Bellman–Ford | no neighbor lookups |
| CSR (compressed rows) | two Int32Arrays: start (n + 1 offsets) and targets | large static graphs: the fastest BFS measured here | read-only: adding an edge means rebuilding both arrays |
| Implicit | function* neighbors(state) | grids, word ladders, puzzle states | nodes appear on demand: track them in a Set of encoded keys |
| Plain object | Record<string, string[]> | JSON input | keys 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 -> cBuilding graphs
| Input | Build |
|---|---|
n and 0-indexed edges | Array.from({ length: n }, (): number[] => []), then push |
nodes 1..n | length n + 1 and leave index 0 empty, or subtract 1 while reading |
| string ids | Map<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 characters | implicit: neighbors computed from (r, c), or ids r * cols + c |
| a list of words | implicit: 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]]1/7
Start: dist[0] = 0, queue = [0] and head = 0. Every other node is -1, not reached yet.
| Variant | Change |
|---|---|
| several sources at once | start with queue = [...sources], every source at distance 0 |
| stop at a target | return when the target is enqueued: with unit weights, its distance is already final |
| the path itself | set parent[v] = u when enqueuing v (an Int32Array filled with -1), then walk back from the target |
| weights 0 or 1 | 0-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 visited | for (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 order1/9
Start: the stack holds the source, 0, which is marked seen. Every cursor next[u] is 0: no neighbor scanned yet.
| Need | Template |
|---|---|
| reachability, components, flood fill | iterative preorder (or BFS) |
| finishing order: topological sort, strongly connected components | the cursor stack in postorder |
| a directed cycle | three colors (white, gray, black): an edge to a gray node is a cycle (recipe) |
| an undirected cycle | remember each node's parent, or use union-find |
| depth known to stay under a few thousand | recursion 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]1/7
Start: dist[0] = 0, every other slot of the Float64Array is Infinity, and the heap array holds [0, 0].
| TypeScript detail | Why |
|---|---|
[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 tuples | arrays compare as strings: [10, 1] < [9, 1] is true, and TypeScript doesn't flag it |
if (d > dist[u]) continue | the 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 early | the first time the target is popped, its distance is final |
| negative weights | Dijkstra 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]]); // null1/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.
| Need | Use |
|---|---|
| an order, or "is there a cycle?" | Kahn: fewer than n nodes in order means a cycle |
| the smallest order lexicographically | take 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 DFS | postorder from every unseen node, reversed |
| the cycle itself | three-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]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); // 8Costs
| Operation | Cost | Notes |
|---|---|---|
| BFS, DFS | O(V + E) | every node once, every edge once (twice when undirected) |
| Dijkstra with a binary heap | O((V + E) log V) | stale entries mean up to E heap items |
| 0-1 BFS | O(V + E) | |
| Bellman–Ford | O(V · E) | |
| Floyd–Warshall | O(V³) | |
| Kahn | O(V + E) | |
| union-find | O(α(n)) amortized per operation | effectively constant with path halving and union by size |
| Kruskal | O(E log E) | the sort dominates |
| Prim, dense array scan | O(V²) | better than a heap when E is near V² |
| adjacency matrix | O(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):
| Representation | Time |
|---|---|
CSR: Int32Array offsets and targets, Int32Array queue | 24 ms |
number[][], Int32Array dist, head-index queue | 81 ms |
Map<number, number[]> | 128 ms |
number[][] with a Set<number> for visited | 228 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).
| Job | TypeScript | Watch out | More |
|---|---|---|---|
| Adjacency from edges | Array.from({ length: n }, (): number[] => []), then push both ways if undirected | Array(n).fill([]) shares one array | Setup |
| Visited | new Int32Array(n).fill(-1) as dist, or new Uint8Array(n), marked when enqueued | marking on dequeue queues a node many times | BFS |
| Queue | an array plus a head index | shift() is O(n) in V8 | Stacks, queues & deques |
| Grid as a graph | DIRS as const, a bounds check, ids r * cols + c | grid[-1] is undefined, and reading from it throws | Grids as graphs |
| Composite keys | numbers 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 |
| Components | BFS or DFS from every unseen node, or DSU.count | loop over 0..n-1: isolated nodes are components too | Components and cycles |
| Undirected cycle | DSU.union returns false, or DFS that skips the parent | the edge back to the parent is not a cycle | Union-find |
| Directed cycle | three colors, or Kahn returning too few nodes | a plain visited set flags harmless diamonds | Recipe |
| Bipartite | new Int8Array(n).fill(-1) colors; neighbors get 1 - color[u] | start from every uncolored node: the graph may be disconnected | Is it bipartite? |
| Topological order | Kahn with indeg in an Int32Array | prerequisite pairs often point the other way | Topological sort |
| Shortest path, unweighted | BFS with dist | stop when the target is enqueued | BFS |
| Shortest path, weights ≥ 0 | Dijkstra: [d, u] entries, skip d > dist[u] | no decrease-key; never compare tuples with < | Dijkstra |
| Weights 0 or 1 | 0-1 BFS with one array per distance | a node may improve after it is queued | Other shortest paths |
| At most k edges | Bellman–Ford with k + 1 rounds, copying dist each round | in-place updates chain several edges in one round | Shortest paths |
| Minimum spanning tree | Kruskal: toSorted by weight, keep edges union accepts; Prim on a matrix | Prim must skip nodes already in the tree | Minimum spanning tree |
| String node ids | Map<string, string[]>, or map names to ints | get returns undefined for unknown nodes: default with ?? [] | Maps & sets |
| Priority queue | the Heap<T> class with a comparator | there is no built-in heap | A typed heap |
| Clone a graph | a Map from old node to new, filled on first sight | create the copy before visiting its neighbors, or cycles never end | Recipe |
| Deep graphs | an explicit stack | about 5,600 frames of recursive DFS in Node | Depth 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); // 2Typing
| Type | Means |
|---|---|
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, Uint8Array | dist, 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:
| Expression | strict | plus noUncheckedIndexedAccess |
|---|---|---|
adj[u] on number[][] | number[] | number[] | undefined |
dist[v] on Int32Array | number | number | undefined |
queue[head] | number | number | undefined |
const [u, v] = edge on readonly [number, number] | number, number | unchanged: tuple slots are known |
for (const u of order) | number | unchanged: no index read |
g.get(u) on a Map | V | undefined | unchanged |
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
| Python | TypeScript |
|---|---|
[[] for _ in range(n)] | Array.from({ length: n }, (): number[] => []) |
[[]] * n: one shared list | Array(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 visited | Set, or a Uint8Array when nodes are 0..n-1 |
math.inf | Infinity, in a Float64Array or a plain array, never an Int32Array |
heapq | no 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.TopologicalSorter | Kahn's algorithm by hand |
dict keys keep their type: 1 and "1" differ | object keys become strings; Map keys keep their type |
| tuples as dict or set keys, compared by value | arrays as Map keys compare by reference: encode them as numbers or strings |
grid[-1] reads the last row | grid[-1] is undefined; grid.at(-1) reads the last row |
1,000 frames by default, sys.setrecursionlimit | about 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 neededOne 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]]); // nullout.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 shapeMap 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 recursionA 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 cycleTaking 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)); // 0Deleting 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
- MDN: Map (opens in a new tab): insertion order, and entries added during iteration are visited
- MDN: Typed arrays (opens in a new tab):
Int32Array,Float64ArrayandUint8Arrayfor dist, parent and seen - MDN: Array.from (opens in a new tab): building an adjacency list without shared rows
- MDN: Map.groupBy (opens in a new tab): grouping by a computed key, such as a union-find root
- TypeScript handbook: Tuple types (opens in a new tab): labeled and readonly tuples for edges
- TSConfig: noUncheckedIndexedAccess (opens in a new tab):
| undefinedon every index read - cp-algorithms: Dijkstra (opens in a new tab), 0-1 BFS (opens in a new tab) and topological sort (opens in a new tab)
- cp-algorithms: Disjoint set union (opens in a new tab): path compression, union by size
- cp-algorithms: Minimum spanning tree, Prim (opens in a new tab): the O(V²) dense version and the heap version