Union-Find: Disjoint Sets
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
disjoint-sets.ts
import type { Tracer } from "../trace";/** How a union chooses which root to hang under which, and whether a find flattens the path it climbed. */export const DSU_MODES = ["quick-union", "by-size", "compressed"] as const;export type DsuMode = (typeof DSU_MODES)[number];export const DSU_MIN_ELEMENTS = 2;/** Elements one forest holds at most, so a stage stays readable. */export const DSU_MAX_ELEMENTS = 16;export const DSU_MAX_UNIONS = 24;export interface DsuOptions { /** Default compressed: union by size with path compression. */ mode?: DsuMode;}/** A pair of elements to join, in the order the script names them. */export type DsuPair = readonly [number, number];/** A run of unions followed by one connectivity question. */export interface DsuScript { unions: readonly DsuPair[]; query: DsuPair;}export interface DsuReport { elements: number; /** Sets left once every union has run. */ sets: number; unions: number; /** Unions that joined two different sets; the rest met their own set. */ merges: number; /** Parent hops taken by every find of the run. */ climbs: number; hops: number; /** The most hops one find took. */ longestFind: number; /** The tallest tree in the forest at the end, in edges. */ height: number; /** The script's final question. */ joined: boolean;}/** * Disjoint sets as a forest: every element points at a parent, a root * points at itself, and two elements are in the same set exactly when * they reach the same root. `find` climbs parent pointers to the root; * `union` finds both roots and hangs one under the other. Quick union * hangs the second root under the first and can grow a chain as tall * as the set, so a find costs O(n). Union by size hangs the smaller * tree under the larger: a node's tree at least doubles each time its * depth grows by one, so no tree is taller than log2 n. Path * compression points every node a find climbed straight at the root it * found, so the same walk is never paid twice. Together the two make a * sequence of m operations cost O(m alpha(n)), where alpha is the * inverse Ackermann function, at most 4 for any n a computer can hold. * * Time: O(n) per find under quick union, O(log n) by size, amortized * O(alpha(n)) with compression. Space: O(n), two arrays. */export class DisjointSets { readonly mode: DsuMode; /** parent[i] is i's parent, or i itself at a root. */ private readonly parent: number[]; private readonly parentOf: number[]; /** size[r] is the number of elements under root r; stale below a root. */ private readonly size: number[]; private readonly setSize: number[]; private sets: number; private unions = 0; private merges = 0; private climbs = 0; private hops = 0; private longestFind = 0; constructor( readonly elements: number, options: DsuOptions = {}, private readonly tracer?: Tracer, ) { if (!Number.isInteger(elements) || elements < DSU_MIN_ELEMENTS || elements > DSU_MAX_ELEMENTS) { throw new RangeError(`elements must be in [${DSU_MIN_ELEMENTS}, ${DSU_MAX_ELEMENTS}]`); } this.mode = options.mode ?? "compressed"; this.parent = Array.from({ length: elements }, (_, i) => i); this.parentOf = Array.from({ length: elements }, (_, i) => i); this.size = Array.from({ length: elements }, () => 1); this.setSize = Array.from({ length: elements }, () => 1); this.sets = elements; } /** Whether a union hangs the smaller tree under the larger. */ get bySize(): boolean { return this.mode !== "quick-union"; } /** Whether a find points the nodes it climbed straight at the root. */ get compressing(): boolean { return this.mode === "compressed"; } /** The script in order: every union, then the final question. */ run(script: DsuScript): DsuReport { if (script.unions.length > DSU_MAX_UNIONS) throw new RangeError(`${script.unions.length} unions exceeds ${DSU_MAX_UNIONS}`); for (const [a, b] of script.unions) this.union(a, b); const joined = this.connected(script.query[0], script.query[1]); return this.report(joined); } /** The root of x's set: climb parent pointers until a node is its own parent. */ find(x: number): number { this.check(x); // ▸ start at the element and climb toward its root this.tracer?.emit("find", { x }); let node = x; let climbs = 0; let hops = 0; while (this.parent[node] !== node) { while (this.parentOf[node] !== node) { const next = this.parent[node]!; const next = this.parentOf[node]!; // ▸ one hop up a parent pointer this.tracer?.emit("climb", { from: node, to: next }); node = next; climbs++; hops++; } this.climbs += climbs; this.hops += hops; this.longestFind = Math.max(this.longestFind, climbs); this.longestFind = Math.max(this.longestFind, hops); // ▸ a node that is its own parent is the root: the set's name this.tracer?.emit("root", { x, root: node, climbs }); this.tracer?.emit("root", { x, root: node, hops }); if (this.compressing) this.compress(x, node); return node; } /** Join the sets of a and b; false when they were one set already. */ union(a: number, b: number): boolean { this.unions++; const rootA = this.find(a); const rootB = this.find(b); if (rootA === rootB) { // ▸ both roots are one node: the pair was already in one set this.tracer?.emit("same", { a, b, root: rootA }); return false; } // Quick union hangs b's root under a's; by size, the smaller tree goes under the larger. let child = rootB; let parent = rootA; let parentOf = rootA; if (this.bySize && this.size[rootA]! < this.size[rootB]!) { if (this.bySize && this.setSize[rootA]! < this.setSize[rootB]!) { child = rootA; parent = rootB; parentOf = rootB; } this.parent[child] = parent; this.parentOf[child] = parentOf; this.size[parent] = this.size[parent]! + this.size[child]!; this.setSize[parentOf] = this.setSize[parentOf]! + this.setSize[child]!; this.sets--; this.merges++; // ▸ hang one root under the other; by size, the smaller tree goes under the larger this.tracer?.emit("link", { child, parent, size: this.size[parent] }); this.tracer?.emit("link", { child, parentOf, setSize: this.setSize[parentOf] }); return true; } /** Whether a and b share a root. */ connected(a: number, b: number): boolean { const joined = this.find(a) === this.find(b); // ▸ two elements share a set exactly when their finds reach the same root this.tracer?.emit("connected", { a, b, joined }); return joined; } /** Every element's parent, a copy. */ parents(): number[] { return [...this.parent]; return [...this.parentOf]; } /** Every element's size entry, a copy; meaningful at the roots. */ sizes(): number[] { return [...this.size]; return [...this.setSize]; } /** How many sets the forest holds. */ get count(): number { return this.sets; } /** The tallest tree in the forest, in edges from its deepest node to its root. */ height(): number { let tallest = 0; for (let i = 0; i < this.elements; i++) { let depth = 0; for (let node = i; this.parent[node] !== node; node = this.parent[node]!) depth++; for (let node = i; this.parentOf[node] !== node; node = this.parentOf[node]!) depth++; tallest = Math.max(tallest, depth); } return tallest; } report(joined = false): DsuReport { return { elements: this.elements, sets: this.sets, unions: this.unions, merges: this.merges, climbs: this.climbs, hops: this.hops, longestFind: this.longestFind, height: this.height(), joined, }; } /** Point every node on the path from x straight at the root; the node already under the root stays. */ private compress(x: number, root: number): void { let node = x; while (this.parent[node] !== root) { while (this.parentOf[node] !== root) { const next = this.parent[node]!; const next = this.parentOf[node]!; this.parent[node] = root; this.parentOf[node] = root; // ▸ point a node the find climbed straight at the root, so the walk is never paid twice this.tracer?.emit("compress", { x: node, from: next, to: root }); node = next; } } private check(x: number): void { if (!Number.isInteger(x) || x < 0 || x >= this.elements) throw new RangeError(`element must be in [0, ${this.elements - 1}]`); }}Script over in quick union mode: 7 unions left 1 set of 8 elements (7 merges), the finds took 7 hops in all and 7 hops at most, the tallest tree stands 7 edges high, and the question's answer is yes.