Skip Lists
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.
skip-list.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";/** The smallest and largest key a list holds. */export const SKIP_MIN_KEY = 1;export const SKIP_MAX_KEY = 99;/** The head sentinel's key in trace payloads: it sits before every key on every level. */export const SKIP_HEAD = 0;/** The fewest and most levels a list may be capped at. */export const SKIP_MIN_LEVELS = 2;export const SKIP_MAX_LEVELS = 6;/** One in two or one in four: the odds a tower grows another level at each coin. */export const SKIP_DENOMINATORS = [2, 4] as const;/** Keys one list holds at most, so a stage stays readable. */export const SKIP_MAX_NODES = 32;export interface SkipListOptions { /** Seeds the coin, so the towers follow from the seed and the insertion order alone (default 1). */ seed?: number; /** Levels a tower may reach at most, SKIP_MIN_LEVELS to SKIP_MAX_LEVELS (default 4). */ maxLevel?: number; /** The coin's odds: a tower grows when the generator lands on 0 of this many sides (default 2). */ denominator?: number;}/** One key's tower: how many levels it reaches. */export interface SkipTower { key: number; levels: number; levelsInUse: number;}/** The list's shape at rest: every tower in key order and the totals the stage reads. */export interface SkipListShape { nodes: number; /** Levels any tower reaches, 1 for an empty list (the head's bottom lane). */ levels: number; levelsInUse: number; /** Forward pointers held by the nodes, the head's left out. */ pointers: number; towers: SkipTower[];}export interface SkipSearchResult { found: boolean; /** Pointer hops the search took: moves right along a level plus drops to the level beneath. */ steps: number;}interface SkipNode { key: number; /** One forward pointer per level the tower reaches, bottom lane first. */ next: (SkipNode | null)[];}/** * A sorted linked list with express lanes. Every node has a tower of * forward pointers: level 0 links every node in key order, and each * level above skips over the nodes whose towers stop beneath it. How * tall a tower grows is decided by coin flips at insertion and never * changes, so there are no rotations and no rebalancing, only local * pointer splices. A search starts at the head on the top level in use, * moves right while the next key is still below the target, drops a * level when it is not, and reads the bottom lane's successor at the * end. With a fair coin half the nodes reach level 1, a quarter level 2, * and so on, so about log2 n levels are in use and a search expects * about 2 log2 n hops. * * Time: expected O(log n) per search or insert, O(n) in the worst case, * which no input order can force. Space: expected n / (1 - p) pointers, * 2n at p = 1/2. */export class SkipList { readonly maxLevel: number; readonly denominator: number; private readonly head: SkipNode; private readonly rng: Mulberry32; /** Levels any tower reaches so far; the search starts on the top one. */ private levels = 1; private levelsInUse = 1; private count = 0; constructor(options: SkipListOptions = {}, private readonly tracer?: Tracer) { const maxLevel = options.maxLevel ?? 4; const denominator = options.denominator ?? 2; if (!Number.isInteger(maxLevel) || maxLevel < SKIP_MIN_LEVELS || maxLevel > SKIP_MAX_LEVELS) { throw new RangeError(`maxLevel must be in [${SKIP_MIN_LEVELS}, ${SKIP_MAX_LEVELS}]`); } if (!(SKIP_DENOMINATORS as readonly number[]).includes(denominator)) { throw new RangeError(`denominator must be one of ${SKIP_DENOMINATORS.join(", ")}`); } this.maxLevel = maxLevel; this.denominator = denominator; this.head = { key: SKIP_HEAD, next: Array.from({ length: maxLevel }, () => null) }; this.rng = new Mulberry32(options.seed ?? 1); } /** How many keys the list holds. */ get size(): number { return this.count; } /** Puts a key in, growing its tower by coin flips; returns how many levels the tower reached. */ insert(key: number): number { this.checkKey(key); if (this.count >= SKIP_MAX_NODES) throw new RangeError(`the list holds at most ${SKIP_MAX_NODES} keys`); const { update } = this.descend(key); const { lastNodeBeforeKeyPerLevel } = this.descend(key); if (update[0]!.next[0]?.key === key) throw new RangeError(`key ${key} is already in the list`); if (lastNodeBeforeKeyPerLevel[0]!.next[0]?.key === key) throw new RangeError(`key ${key} is already in the list`); // The tower's height: one level, plus one more for every coin that lands on 0, up to the cap. let height = 1; let towerHeight = 1; while (height < this.maxLevel && this.rng.index(this.denominator) === 0) { while (towerHeight < this.maxLevel && this.rng.index(this.denominator) === 0) { // ▸ the coin lands on 0: the new tower grows one level taller this.tracer?.emit("flip", { key, level: height }); this.tracer?.emit("flip", { key, level: towerHeight }); height++; towerHeight++; } // A tower taller than any so far opens new levels; on those the head is the predecessor. if (height > this.levels) { if (towerHeight > this.levelsInUse) { for (let level = this.levels; level < height; level++) update[level] = this.head; for (let level = this.levelsInUse; level < towerHeight; level++) lastNodeBeforeKeyPerLevel[level] = this.head; this.levels = height; this.levelsInUse = towerHeight; } const node: SkipNode = { key, next: Array.from({ length: height }, () => null) }; const node: SkipNode = { key, next: Array.from({ length: towerHeight }, () => null) }; for (let level = 0; level < height; level++) { for (let level = 0; level < towerHeight; level++) { node.next[level] = update[level]!.next[level] ?? null; node.next[level] = lastNodeBeforeKeyPerLevel[level]!.next[level] ?? null; update[level]!.next[level] = node; lastNodeBeforeKeyPerLevel[level]!.next[level] = node; } this.count++; // ▸ link the new node in behind its predecessor on every level its tower reaches this.tracer?.emit("splice", { key, levels: height, nodes: this.count, listLevels: this.levels }); this.tracer?.emit("splice", { key, levelsInUse: towerHeight, nodes: this.count, listLevels: this.levelsInUse }); return height; return towerHeight; } /** Walks right and down from the head and reads the bottom lane's successor. */ search(key: number): SkipSearchResult { this.checkKey(key); const { update, steps } = this.descend(key); const { lastNodeBeforeKeyPerLevel, steps } = this.descend(key); const below = update[0]!; const below = lastNodeBeforeKeyPerLevel[0]!; const candidate = below.next[0] ?? null; if (candidate !== null && candidate.key === key) { // ▸ the bottom lane's successor is the key: found, in this many hops this.tracer?.emit("found", { key, steps }); return { found: true, steps }; } // ▸ the bottom lane's successor is past the key: it would sit in this gap this.tracer?.emit("missing", { key, steps, below: below.key, above: candidate === null ? null : candidate.key }); return { found: false, steps }; } has(key: number): boolean { let node: SkipNode | null = this.head.next[0] ?? null; while (node !== null && node.key < key) node = node.next[0] ?? null; return node !== null && node.key === key; } /** The keys in order. */ keys(): number[] { return this.shape().towers.map((tower) => tower.key); } shape(): SkipListShape { const towers: SkipTower[] = []; let pointers = 0; for (let node = this.head.next[0] ?? null; node !== null; node = node.next[0] ?? null) { towers.push({ key: node.key, levels: node.next.length }); towers.push({ key: node.key, levelsInUse: node.next.length }); pointers += node.next.length; } return { nodes: this.count, levels: this.levels, pointers, towers }; return { nodes: this.count, levelsInUse: this.levelsInUse, pointers, towers }; } /** * The search path every operation shares: from the head on the top * level in use, move right while the next key is below the target, * drop a level when it is not. Returns the last node passed on each * level, the predecessors an insert splices behind. */ private descend(key: number): { update: SkipNode[]; steps: number } { private descend(key: number): { lastNodeBeforeKeyPerLevel: SkipNode[]; steps: number } { const update: SkipNode[] = Array.from({ length: this.maxLevel }, () => this.head); const lastNodeBeforeKeyPerLevel: SkipNode[] = Array.from({ length: this.maxLevel }, () => this.head); let node = this.head; let steps = 0; for (let level = this.levels - 1; level >= 0; level--) { for (let level = this.levelsInUse - 1; level >= 0; level--) { let next = node.next[level] ?? null; while (next !== null && next.key < key) { // ▸ the next key on this level is still below the target: move right along the express lane this.tracer?.emit("scan", { key, level, from: node.key, to: next.key }); node = next; next = node.next[level] ?? null; steps++; } update[level] = node; lastNodeBeforeKeyPerLevel[level] = node; if (level > 0) { // ▸ the next key is past the target, or there is none: drop a level and keep going from here this.tracer?.emit("drop", { key, level, at: node.key }); steps++; } } return { update, steps }; return { lastNodeBeforeKeyPerLevel, steps }; } private checkKey(key: number): void { if (!Number.isInteger(key) || key < SKIP_MIN_KEY || key > SKIP_MAX_KEY) { throw new RangeError(`key must be an integer in [${SKIP_MIN_KEY}, ${SKIP_MAX_KEY}]`); } }}12 keys in 4 levels of the 4 allowed, 24 pointers in all. The lookup of 30 found it in 6 hops; a fair walk expects about 7.