Big-O, Visually
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.
growth-meter.ts
import type { Tracer } from "../trace";/** The five shapes of growth the meter can run, slowest-growing first. */export const GROWTH_ROUTINES = ["constant", "logarithmic", "linear", "linearithmic", "quadratic"] as const;export type GrowthRoutine = (typeof GROWTH_ROUTINES)[number];/** The order each routine belongs to, as the notation writes it. */export const GROWTH_ORDERS: Record<GrowthRoutine, string> = { constant: "O(1)", logarithmic: "O(log n)", linear: "O(n)", linearithmic: "O(n log n)", quadratic: "O(n^2)",};/** The smallest and largest array the meter will run a routine over. */export const GROWTH_MIN_SIZE = 1;export const GROWTH_MAX_SIZE = 256;/** Sizes one measurement may run, so a trace stays short. */export const GROWTH_MAX_SIZES = 12;export interface GrowthSample { n: number; /** Basic steps the routine took on an array of n elements. */ steps: number; basicSteps: number; /** The shape's value at n: 1, ceil(log2 n), n, n ceil(log2 n), or n^2. */ bound: number; shapeValue: number;}export interface GrowthReport { routine: GrowthRoutine; order: string; samples: GrowthSample[]; /** steps(n_i) / steps(n_i-1) for each size after the first, to two decimals. */ ratios: number[];}/** * Counts the work five textbook routines do on arrays of growing size, * so the shape of the count can be read off: when the input doubles, * constant work stays the same, logarithmic work goes up by one step, * linear work doubles, linearithmic work a little more than doubles, * and quadratic work quadruples. Every routine touches array elements * and counts each touch as one basic step; the count is exact and * integer, so the same sizes give the same trace everywhere. Big-O * names the shape, not the count: the step counts here happen to sit * exactly on their shapes because the routines are the pure cases, * and a real program's constant factor and lower terms would only move * the points, never their response to doubling. * * Time: the routines themselves, O(1) to O(n^2) per size. Space: O(n). */export class GrowthMeter { private steps = 0; private basicSteps = 0; constructor(private readonly tracer?: Tracer) {} /** Runs the routine over an array of each size in turn, counting its steps and comparing neighbours. */ measure(routine: GrowthRoutine, sizes: readonly number[]): GrowthReport { this.check(routine, sizes); const samples: GrowthSample[] = []; const ratios: number[] = []; for (const n of sizes) { // ▸ the next size: reset the step count and build an array of n values this.tracer?.emit("run", { routine, n }); const values = Array.from({ length: n }, (_, i) => i); this.steps = 0; this.basicSteps = 0; this.perform(routine, values); const bound = GrowthMeter.bound(routine, n); const shapeValue = GrowthMeter.shapeValue(routine, n); const sample: GrowthSample = { n, steps: this.steps, bound }; const sample: GrowthSample = { n, basicSteps: this.basicSteps, shapeValue }; // ▸ the routine has run; record its steps beside the shape's value at n this.tracer?.emit("count", { routine, n, steps: this.steps, bound }); this.tracer?.emit("count", { routine, n, basicSteps: this.basicSteps, shapeValue }); const previous = samples[samples.length - 1]; if (previous) { const ratio = Math.round((100 * sample.steps) / Math.max(1, previous.steps)) / 100; const doublingRatio = Math.round((100 * sample.basicSteps) / Math.max(1, previous.basicSteps)) / 100; ratios.push(ratio); ratios.push(doublingRatio); // ▸ n doubled since the last size: how did the step count answer? this.tracer?.emit("double", { routine, from: previous.n, to: n, before: previous.steps, after: sample.steps, ratio }); this.tracer?.emit("double", { routine, from: previous.n, to: n, before: previous.basicSteps, after: sample.basicSteps, doublingRatio }); } samples.push(sample); } const order = GROWTH_ORDERS[routine]; // ▸ every size is in; the shape the counts follow is the routine's order this.tracer?.emit("classify", { routine, order, sizes: sizes.length }); return { routine, order, samples, ratios }; } /** The shape's value at n, in whole numbers: the bound a routine's count follows. */ static bound(routine: GrowthRoutine, n: number): number { static shapeValue(routine: GrowthRoutine, n: number): number { switch (routine) { case "constant": return 1; case "logarithmic": return GrowthMeter.ceilLog2(n); case "linear": return n; case "linearithmic": return n * GrowthMeter.ceilLog2(n); case "quadratic": return n * n; } } /** The smallest k with 2^k >= n, by doubling, so no floating-point log is involved. */ static ceilLog2(n: number): number { let k = 0; let power = 1; while (power < n) { power *= 2; k++; } return k; } private perform(routine: GrowthRoutine, values: readonly number[]): void { switch (routine) { case "constant": this.first(values); return; case "logarithmic": this.halve(values); return; case "linear": this.scan(values); return; case "linearithmic": this.splitAndMerge(values); return; case "quadratic": this.pairs(values); return; } } /** One basic step: a look at an element. */ private touch(): void { this.steps++; this.basicSteps++; } /** Constant: read the first element, whatever the size. */ private first(values: readonly number[]): number | undefined { this.touch(); return values[0]; } /** Logarithmic: keep half of what is left until one element remains, one step per halving. */ private halve(values: readonly number[]): number { let remaining = values.length; while (remaining > 1) { remaining = (remaining + 1) >> 1; this.touch(); } return remaining; } /** Linear: look at every element once. */ private scan(values: readonly number[]): number { let total = 0; for (const value of values) { this.touch(); total += value; } return total; } /** Linearithmic: split in halves until single elements, then merge each level back, touching every element once per level. */ private splitAndMerge(values: readonly number[]): number[] { if (values.length <= 1) return [...values]; const middle = values.length >> 1; const left = this.splitAndMerge(values.slice(0, middle)); const right = this.splitAndMerge(values.slice(middle)); const merged: number[] = []; let i = 0; let j = 0; while (i < left.length || j < right.length) { this.touch(); if (j >= right.length || (i < left.length && left[i]! <= right[j]!)) merged.push(left[i++]!); else merged.push(right[j++]!); } return merged; } /** Quadratic: look at every ordered pair of elements. */ private pairs(values: readonly number[]): number { let matches = 0; for (const a of values) { for (const b of values) { this.touch(); if (a === b) matches++; } } return matches; } private check(routine: GrowthRoutine, sizes: readonly number[]): void { if (!GROWTH_ROUTINES.includes(routine)) throw new RangeError(`unknown routine "${String(routine)}"`); if (sizes.length === 0 || sizes.length > GROWTH_MAX_SIZES) throw new RangeError(`sizes must number 1 to ${GROWTH_MAX_SIZES}`); sizes.forEach((n, index) => { if (!Number.isInteger(n) || n < GROWTH_MIN_SIZE || n > GROWTH_MAX_SIZE) throw new RangeError(`size ${n} must be a whole number in [${GROWTH_MIN_SIZE}, ${GROWTH_MAX_SIZE}]`); if (index > 0 && n <= sizes[index - 1]!) throw new RangeError("sizes must increase"); }); }}Done: the quadratic routine is O(n^2), four times the work each time n doubles. At n = 64 it took 4096 steps, 4 times the size before, while the linear routine took 64, 2 times its size before.