Minimum Spanning Trees: Kruskal and Prim
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.
minimum-spanning-tree.ts
import type { Tracer } from "../trace";import { BinaryHeap } from "./binary-heap";import type { AdjacencyGraph, Edge } from "./graph";import { UnionFind } from "./union-find";export const MST_ALGORITHMS = ["kruskal", "prim"] as const;export type MstAlgorithm = (typeof MST_ALGORITHMS)[number];export interface MstResult { /** The accepted edges in acceptance order; Prim's point from the tree side to the node it joined. */ readonly edges: readonly Edge[]; /** The sum of the accepted weights. */ readonly weight: number; /** How many edges were accepted; `nodeCount - 1` on a spanning tree. */ readonly accepted: number; /** Kruskal's cycle-closing rejects, or Prim's stale heap entries. */ readonly rejected: number; /** Whether every node was reached; false on a forest, or for Prim's start in a smaller component. */ readonly spanning: boolean; readonly algorithm: MstAlgorithm;}/** A heap entry in Prim's run: the frontier edge, read from the tree side. */interface Candidate { from: number; to: number; weight: number;}/** * A minimum spanning tree of an undirected weighted graph, two ways. * * Kruskal sorts every edge by weight (ties by the edge's index, so the * order is the graph's) and takes each one whose ends sit in different * sets of a union-find, rejecting the ones that would close a cycle. * Prim grows one tree from a start node: every edge leaving the tree * waits in a binary heap by weight (ties by the node it reaches), the * cheapest is accepted, and an entry whose node joined the tree in the * meantime is stale and skipped. Both stop after `nodeCount - 1` * accepted edges; a disconnected graph runs out of edges first and the * result says `spanning: false`. Both find the same total weight, * because every accepted edge is a cheapest edge across some cut. * * Kruskal: O(E log E). Prim: O((V + E) log E). Space: O(V + E). */export class MinimumSpanningTree { private edges: Edge[] = []; private total = 0; constructor( readonly graph: AdjacencyGraph, readonly algorithm: MstAlgorithm = "kruskal", private readonly tracer?: Tracer, ) { if (graph.directed) throw new RangeError("a spanning tree needs an undirected graph"); if (!MST_ALGORITHMS.includes(algorithm)) throw new RangeError(`unknown algorithm ${String(algorithm)}`); } /** Builds the tree; `start` is where Prim begins, and Kruskal ignores it. */ run(start = 0): MstResult { const n = this.graph.nodeCount; if (!Number.isInteger(start) || start < 0 || start >= n) throw new RangeError(`start ${start} is not a node`); this.edges = []; this.total = 0; const rejected = this.algorithm === "kruskal" ? this.kruskal() : this.prim(start); const spanning = this.edges.length === n - 1; // ▸ n - 1 edges and the least total weight, or a forest this.tracer?.emit("verdict", { algorithm: this.algorithm, count: this.edges.length, spanning, weight: this.total }); return { edges: this.edges, weight: this.total, accepted: this.edges.length, rejected, spanning, algorithm: this.algorithm, }; } /** Cheapest edges first, each kept unless its ends are already joined; returns the reject count. */ private kruskal(): number { const target = this.graph.nodeCount - 1; const sorted = this.graph.edges .map((edge, index) => ({ edge, index })) .sort((a, b) => a.edge.weight - b.edge.weight || a.index - b.index) .map(({ edge }) => ({ from: edge.from, to: edge.to, weight: edge.weight })); // ▸ every edge, cheapest first; ties keep the graph's order this.tracer?.emit("sort", { edges: sorted.map((edge) => ({ ...edge })) }); const sets = new UnionFind(this.graph.nodeCount); const disjointSets = new UnionFind(this.graph.nodeCount); let rejected = 0; for (const { from, to, weight } of sorted) { if (this.edges.length >= target) break; const rootFrom = sets.find(from); const rootFrom = disjointSets.find(from); const rootTo = sets.find(to); const rootTo = disjointSets.find(to); // ▸ the cheapest edge left: are its ends in one set already? this.tracer?.emit("consider", { from, rootFrom, rootTo, to, weight }); if (rootFrom === rootTo) { rejected++; // ▸ both ends share a root: this edge would close a cycle this.tracer?.emit("reject", { from, root: rootFrom, to, weight }); continue; } sets.union(from, to); disjointSets.union(from, to); this.accept(from, to, weight); } return rejected; } /** One tree from `start`, grown by its cheapest frontier edge; returns the stale count. */ private prim(start: number): number { const target = this.graph.nodeCount - 1; const inTree: boolean[] = Array.from({ length: this.graph.nodeCount }, () => false); const joined: boolean[] = Array.from({ length: this.graph.nodeCount }, () => false); const heap = new BinaryHeap<Candidate>( const frontierHeap = new BinaryHeap<Candidate>( (c) => c.weight, (c) => c.to, ); let stale = 0; inTree[start] = true; joined[start] = true; // ▸ prim begins here: the tree is one node and no edges this.tracer?.emit("root", { node: start }); this.offer(start, inTree, heap); this.offer(start, joined, frontierHeap); while (heap.size > 0 && this.edges.length < target) { while (frontierHeap.size > 0 && this.edges.length < target) { const { from, to, weight } = heap.pop()!; const { from, to, weight } = frontierHeap.pop()!; if (inTree[to]) { if (joined[to]) { // Another edge reached this node first; the entry waited in the heap for nothing. stale++; // ▸ its node joined by a cheaper edge meanwhile: drop the entry this.tracer?.emit("stale", { from, to, weight }); continue; } inTree[to] = true; joined[to] = true; this.accept(from, to, weight); if (this.edges.length < target) this.offer(to, inTree, heap); if (this.edges.length < target) this.offer(to, joined, frontierHeap); } return stale; } /** Pushes every edge from a tree node to a node outside it; an edge back into the tree is skipped. */ private offer(node: number, inTree: readonly boolean[], heap: BinaryHeap<Candidate>): void { private offer(node: number, joined: readonly boolean[], frontierHeap: BinaryHeap<Candidate>): void { for (const { to, weight } of this.graph.neighbors(node)) { if (inTree[to]) { if (joined[to]) { // ▸ the neighbour is in the tree already: nothing to offer this.tracer?.emit("skip", { from: node, to }); continue; } heap.push({ from: node, to, weight }); frontierHeap.push({ from: node, to, weight }); // ▸ a frontier edge waits on the heap, ordered by weight this.tracer?.emit("push", { from: node, heap: heap.toArray().map((c) => ({ ...c })), to, weight }); this.tracer?.emit("push", { from: node, frontierHeap: frontierHeap.toArray().map((c) => ({ ...c })), to, weight }); } } /** The one moment both algorithms share: an edge joins the tree. */ private accept(from: number, to: number, weight: number): void { this.edges.push({ from, to, weight }); this.total += weight; // ▸ the edge joins the tree: one step closer to n - 1 this.tracer?.emit("accept", { count: this.edges.length, from, to, total: this.total, weight }); }}11 edges accepted, 3 dropped, total weight 22.7.