Sparse 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.
sparse-set.ts
import type { Tracer } from "../trace";export const SPARSE_MIN_CAPACITY = 2;/** Values one set may hold at most, so two stage rows stay readable. */export const SPARSE_MAX_CAPACITY = 16;/** Operations one script may hold. */export const SPARSE_MAX_OPERATIONS = 32;/** One step of a script: add a value, remove it, or ask whether it is in. */export interface SparseOp { readonly kind: "add" | "remove" | "has"; readonly value: number;}export interface SparseReport { /** Adds that grew the set; adding a member counts as a hit only. */ readonly adds: number; readonly capacity: number; /** Probes that found their value in the set. */ readonly hits: number; /** Probes that did not. */ readonly misses: number; /** Removes that shrank the set; removing a non-member counts as a miss only. */ readonly removes: number; readonly size: number; /** Removes that moved the last dense value into the hole. */ readonly swaps: number; /** The live members, in dense order. */ readonly values: readonly number[];}/** * A sparse set: a set over the integers [0, capacity) made of two * arrays and a count. `dense` packs the members into its first n slots * in insertion order; `sparse[v]` says which dense slot holds v. A value * is a member exactly when `sparse[v]` points inside the live prefix and * the slot there holds v, so a stale pointer left behind by a remove is * harmless and nothing ever needs clearing: `clear` is `n = 0`. Adding * appends to dense and records the slot in sparse. Removing swaps the * last dense value into the hole (so the prefix stays packed), repoints * its sparse entry, and pops the count. Every operation touches a fixed * number of cells, and the live members are already a contiguous array * ready to iterate, which is why entity systems and compilers reach for * it over a bit set or a hash set. * * Time: O(1) for add, remove, has, and clear; O(n) to iterate. Space: * O(capacity), two arrays. */export class SparseSet { /** The members in the first n slots; stale values beyond them. */ private readonly denseArray: number[]; /** sparse[v] is v's dense slot while v is a member; stale or -1 otherwise. */ private readonly sparseArray: number[]; /** How many dense slots are live. */ private n = 0; private liveCount = 0; private adds = 0; private removes = 0; private hits = 0; private misses = 0; private swaps = 0; constructor( readonly capacity: number, private readonly tracer?: Tracer, ) { if (!Number.isInteger(capacity) || capacity < SPARSE_MIN_CAPACITY || capacity > SPARSE_MAX_CAPACITY) { throw new RangeError(`capacity must be in [${SPARSE_MIN_CAPACITY}, ${SPARSE_MAX_CAPACITY}]`); } this.denseArray = Array.from({ length: capacity }, () => -1); this.sparseArray = Array.from({ length: capacity }, () => -1); } /** How many members the set holds. */ get size(): number { return this.n; return this.liveCount; } /** The script in order: each add, remove, or has on this one set. */ run(script: readonly SparseOp[]): SparseReport { if (script.length > SPARSE_MAX_OPERATIONS) throw new RangeError(`${script.length} operations exceeds ${SPARSE_MAX_OPERATIONS}`); for (const op of script) { if (op.kind === "add") this.add(op.value); else if (op.kind === "remove") this.remove(op.value); else this.has(op.value); } return this.report(); } report(): SparseReport { return { adds: this.adds, capacity: this.capacity, hits: this.hits, misses: this.misses, removes: this.removes, size: this.n, size: this.liveCount, swaps: this.swaps, values: this.values(), }; } /** Append the value to the dense prefix and point its sparse entry at the slot; false when it was already in. */ add(value: number): boolean { if (this.probe(value)) return false; const index = this.n; const index = this.liveCount; this.denseArray[index] = value; this.sparseArray[value] = index; this.n++; this.liveCount++; this.adds++; // ▸ append v to the dense prefix and record its slot in sparse this.tracer?.emit("add", { index, size: this.n, value }); this.tracer?.emit("add", { index, size: this.liveCount, value }); return true; } /** Move the last dense value into the hole, repoint it, and pop the count; false when the value was not in. */ remove(value: number): boolean { if (!this.probe(value)) return false; const index = this.sparseArray[value]!; const last = this.n - 1; const last = this.liveCount - 1; if (index !== last) { const moved = this.denseArray[last]!; const lastValue = this.denseArray[last]!; this.denseArray[index] = moved; this.denseArray[index] = lastValue; this.sparseArray[moved] = index; this.sparseArray[lastValue] = index; this.swaps++; // ▸ the last live value moves down into the hole and its sparse entry follows it this.tracer?.emit("swap", { index, last, value: moved }); this.tracer?.emit("swap", { index, last, value: lastValue }); } // The popped slot keeps its stale value, and sparse[value] its stale pointer: neither can pass the probe again. this.n--; this.liveCount--; this.removes++; // ▸ the count drops by one; the popped slot and the stale pointer are left as they are this.tracer?.emit("remove", { size: this.n, value }); this.tracer?.emit("remove", { size: this.liveCount, value }); return true; } /** Whether the value is a member: one probe, no writes. */ has(value: number): boolean { return this.probe(value); } /** The live members in dense order, a copy. */ values(): number[] { return this.denseArray.slice(0, this.n); return this.denseArray.slice(0, this.liveCount); } /** The whole dense array, stale slots beyond the live prefix included, a copy. */ dense(): number[] { return [...this.denseArray]; } /** The whole sparse array, stale pointers and -1 included, a copy. */ sparse(): number[] { return [...this.sparseArray]; } /** * The membership test both arrays exist for: v is in exactly when * sparse[v] lands inside the live prefix and the dense slot there holds * v. A stale pointer fails one of the two checks, so it never lies. */ private probe(value: number): boolean { this.check(value); const slot = this.sparseArray[value]!; if (slot >= 0 && slot < this.n && this.denseArray[slot] === value) { if (slot >= 0 && slot < this.liveCount && this.denseArray[slot] === value) { this.hits++; // ▸ sparse[v] points inside the live prefix and the dense slot there holds v: v is in this.tracer?.emit("hit", { index: slot, value }); return true; } this.misses++; // ▸ the pointer is -1, past the live count, or its slot holds another value: v is out this.tracer?.emit("miss", { slot, value }); return false; } private check(value: number): void { if (!Number.isInteger(value) || value < 0 || value >= this.capacity) throw new RangeError(`value must be in [0, ${this.capacity - 1}]`); }}Script over: 2 members in the dense prefix after 5 dense writes, and the final question's answer is yes.