Persistent Data Structures
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.
persistent-tree.ts
import type { Tracer } from "../trace";export const PERSISTENT_MIN_KEY = 1;export const PERSISTENT_MAX_KEY = 99;/** Keys the tree takes at most, so every version's picture stays readable. */export const PERSISTENT_MAX_KEYS = 12;/** Which child of a node an insert passed through or replaced. */type PersistentSide = "left" | "right";export interface PersistentNode { /** Numbered in creation order; a copy is a new node with a new id, the original keeps its own. */ readonly id: number; readonly key: number; readonly left: number | null; readonly right: number | null; /** The version whose insert created this node. */ readonly version: number;}export interface PersistentReport { /** Ancestors copied over every insert. */ readonly copies: number; /** Keys inserted. */ readonly keys: number; /** Nodes allocated in all: the keys plus the copies. */ readonly nodes: number; /** Untouched children pointed at by a copy, over every insert. */ readonly shared: number; /** Versions readable, version 0 (the empty tree) included. */ readonly versions: number;}/** * A binary search tree that keeps every version it has ever been. * Nothing is overwritten: a node, once created, never changes. An * insert descends from the latest root as any search tree does, hangs a * new leaf, and then walks back up the path it took, copying each * ancestor with one child pointer changed to the node just made below * and the other pointer shared, untouched, with the old tree. The last * copy is the new root, and the old root still describes the old tree * exactly, so every version stays readable forever. The copies cost * one node per level, O(log n) in a balanced tree, and everything off * the path is shared between the versions rather than duplicated. * * Insert only: delete is a non-goal here (it copies the path the same * way, with the usual successor dance below it). Keys arrive in the * order given and the tree is not rebalanced, so a sorted run leans * and copies a longer path each time, which the lesson shows on purpose. * * Time: O(height) per insert, O(size) to read a version in order. * Space: O(height) new nodes per insert, O(n log n) over n inserts into * a balanced tree. */export class PersistentTree { private readonly pool = new Map<number, PersistentNode>(); /** Root id per version; index 0 is the empty tree. */ private readonly roots: (number | null)[] = [null]; private readonly held = new Set<number>(); private readonly keysHeld = new Set<number>(); private copies = 0; private shared = 0; constructor(private readonly tracer?: Tracer) {} /** How many versions exist, version 0 (empty) included. */ get versions(): number { return this.roots.length; } /** The root id of a version, null for the empty tree. */ root(version: number): number | null { this.checkVersion(version); return this.roots[version] ?? null; } /** The nodes a version reaches, in key order; copies. */ nodes(version: number): PersistentNode[] { const out: PersistentNode[] = []; const walk = (id: number | null): void => { if (id === null) return; const node = this.node(id); walk(node.left); out.push({ ...node }); walk(node.right); }; walk(this.root(version)); return out; } /** Every node ever allocated, by id; copies. */ allNodes(): PersistentNode[] { return [...this.pool.values()].map((node) => ({ ...node })); } /** Nodes on the longest root-to-leaf path of a version; 0 for the empty tree. */ height(version: number): number { const depth = (id: number | null): number => { if (id === null) return 0; const node = this.node(id); return 1 + Math.max(depth(node.left), depth(node.right)); }; return depth(this.root(version)); } /** * Inserts into the latest version and returns the number of the version * it makes: descend, attach a leaf, copy the path back up with the * changed child swapped in and the other child shared. */ insert(key: number): number { this.checkKey(key); if (this.held.has(key)) throw new RangeError(`the tree already holds ${key}`); if (this.keysHeld.has(key)) throw new RangeError(`the tree already holds ${key}`); if (this.held.size >= PERSISTENT_MAX_KEYS) throw new RangeError(`the tree takes at most ${PERSISTENT_MAX_KEYS} keys`); if (this.keysHeld.size >= PERSISTENT_MAX_KEYS) throw new RangeError(`the tree takes at most ${PERSISTENT_MAX_KEYS} keys`); const version = this.roots.length; const path: { id: number; side: PersistentSide }[] = []; let at = this.roots[version - 1] ?? null; while (at !== null) { const node = this.node(at); const side: PersistentSide = key < node.key ? "left" : "right"; // ▸ walk down from the latest root as any search tree does: smaller goes left, larger goes right, and nothing on the way is changed this.tracer?.emit("descend", { key, node: at, side, version }); path.push({ id: at, side }); at = side === "left" ? node.left : node.right; } let below = this.allocate(key, null, null, version); this.held.add(key); this.keysHeld.add(key); // ▸ the side the key wanted is empty: a new leaf is made there, a node only the new version will reach this.tracer?.emit("attach", { id: below, key, version }); for (let i = path.length - 1; i >= 0; i--) { const { id, side } = path[i]!; const old = this.node(id); const original = this.node(id); const left = side === "left" ? below : old.left; const left = side === "left" ? below : original.left; const right = side === "right" ? below : old.right; const right = side === "right" ? below : original.right; const copy = this.allocate(old.key, left, right, version); const copy = this.allocate(original.key, left, right, version); this.copies++; // ▸ back up the path, each ancestor is copied with one pointer aimed at the node just made below; the original keeps its old children this.tracer?.emit("copy", { from: id, key: old.key, side, to: copy, version }); this.tracer?.emit("copy", { from: id, key: original.key, side, to: copy, version }); const other = side === "left" ? old.right : old.left; const other = side === "left" ? original.right : original.left; if (other !== null) { this.shared++; // ▸ the copy's other pointer is the original's, untouched: that whole subtree is shared with the old tree, not duplicated this.tracer?.emit("share", { node: other, parent: copy, side: side === "left" ? "right" : "left" }); } below = copy; } this.roots.push(below); // ▸ the last copy is the new version's root; the old root still describes the old tree exactly this.tracer?.emit("version", { copied: path.length, root: below, version }); return version; } /** Inserts the keys in order, one version each, and reports. */ run(keys: readonly number[]): PersistentReport { if (keys.length > PERSISTENT_MAX_KEYS) throw new RangeError(`${keys.length} keys exceeds ${PERSISTENT_MAX_KEYS}`); for (const key of keys) this.insert(key); return this.report(); } report(): PersistentReport { return { copies: this.copies, keys: this.held.size, nodes: this.pool.size, shared: this.shared, versions: this.roots.length }; return { copies: this.copies, keys: this.keysHeld.size, nodes: this.pool.size, shared: this.shared, versions: this.roots.length }; } private allocate(key: number, left: number | null, right: number | null, version: number): number { const id = this.pool.size; this.pool.set(id, { id, key, left, right, version }); return id; } private node(id: number): PersistentNode { const node = this.pool.get(id); if (!node) throw new RangeError(`no node ${id}`); return node; } private checkKey(key: number): void { if (!Number.isInteger(key) || key < PERSISTENT_MIN_KEY || key > PERSISTENT_MAX_KEY) { throw new RangeError(`key must be an integer in [${PERSISTENT_MIN_KEY}, ${PERSISTENT_MAX_KEY}]`); } } private checkVersion(version: number): void { if (!Number.isInteger(version) || version < 0 || version >= this.roots.length) { throw new RangeError(`version must be an integer in [0, ${this.roots.length - 1}]`); } }}8 versions readable from 17 nodes: 10 copies along the paths and 7 subtrees shared rather than duplicated. Tap a version to draw the tree it sees.