Binary Search
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.
binary-search.ts
import type { Tracer } from "../trace";/** How the search walks the array: every element from the left, or the middle of what remains. */export const SEARCH_MODES = ["linear", "binary"] as const;export type SearchMode = (typeof SEARCH_MODES)[number];/** Arrays longer than this are refused, so a traced run stays a stage's worth. */export const SEARCH_MAX_LENGTH = 64;/** Values live in [SEARCH_MIN_VALUE, SEARCH_MAX_VALUE], two digits at most, so a tile can show one. */export const SEARCH_MIN_VALUE = 1;export const SEARCH_MAX_VALUE = 99;export interface SearchOptions { /** Default binary. */ mode?: SearchMode;}export interface SearchResult { /** Where the target sits, or null when the array does not hold it. */ index: number | null; /** Where the target belongs: its index when found, otherwise the index it would be inserted at to keep the order. */ insertAt: number; /** Elements read. */ probes: number;}/** * Searching a sorted array. A linear scan reads from the left and stops * at the target or at the first value past it, so it costs up to n * reads. Binary search keeps a range [lo, hi] that must hold the * target if the array does, reads the middle element, and throws away * the half that cannot hold the target: a value smaller than the * target rules out everything at or before it, a larger one everything * at or after it. Each read halves the range, so n elements take at * most ceil(log2(n + 1)) reads, and an empty range proves the target * absent, with lo naming the slot it would occupy. * * Time: O(log n) reads for binary search, O(n) for the scan. Space: * O(1) beyond the array. */export class BinarySearch { readonly mode: SearchMode; constructor(options: SearchOptions = {}, private readonly tracer?: Tracer) { this.mode = options.mode ?? "binary"; } /** Finds target in a strictly increasing array of integers, or where it would go. */ search(values: readonly number[], target: number): SearchResult { BinarySearch.check(values, target); if (this.mode === "linear") return this.scan(values, target); let lo = 0; let lowestCandidate = 0; let hi = values.length - 1; let highestCandidate = values.length - 1; let probes = 0; while (lo <= hi) { while (lowestCandidate <= highestCandidate) { // Invariant: if the target is in the array, it is in values[lo..hi]. // ▸ the target, if it is here at all, sits somewhere in values[lo..hi] this.tracer?.emit("range", { lo, hi, size: hi - lo + 1 }); this.tracer?.emit("range", { lowestCandidate, highestCandidate, size: highestCandidate - lowestCandidate + 1 }); const mid = lo + ((hi - lo) >> 1); const middleIndex = lowestCandidate + ((highestCandidate - lowestCandidate) >> 1); const value = values[mid]!; const value = values[middleIndex]!; probes++; // ▸ read the middle element of the range; the overflow-safe midpoint this.tracer?.emit("probe", { mid, value, target, probes }); this.tracer?.emit("probe", { middleIndex, value, target, probes }); if (value === target) return this.finish(mid, mid, probes); if (value === target) return this.finish(middleIndex, middleIndex, probes); if (target < value) { hi = mid - 1; highestCandidate = middleIndex - 1; // ▸ the middle is too big, so is everything after it: keep the left half this.tracer?.emit("left", { lo, hi }); this.tracer?.emit("left", { lowestCandidate, highestCandidate }); } else { lo = mid + 1; lowestCandidate = middleIndex + 1; // ▸ the middle is too small, so is everything before it: keep the right half this.tracer?.emit("right", { lo, hi }); this.tracer?.emit("right", { lowestCandidate, highestCandidate }); } } // The range closed without a match: lo is the first index whose value exceeds the target. return this.finish(null, lo, probes); return this.finish(null, lowestCandidate, probes); } /** Reads from the left; sorted input lets it stop at the first value past the target. */ private scan(values: readonly number[], target: number): SearchResult { let probes = 0; for (const [index, value] of values.entries()) { probes++; // ▸ a linear scan reads the next element; sorted input lets it stop at the first value past the target this.tracer?.emit("scan", { index, value, probes }); if (value === target) return this.finish(index, index, probes); if (value > target) return this.finish(null, index, probes); } return this.finish(null, values.length, probes); } /** The verdict: the target's index, or the slot it would take. */ private finish(index: number | null, insertAt: number, probes: number): SearchResult { // ▸ the middle is the target if (index !== null) this.tracer?.emit("found", { index, probes }); // ▸ the range closed without a match; lo is the slot the target would take else this.tracer?.emit("missing", { probes, insertAt }); return { index, insertAt, probes }; } /** Reads a scan would take: one per element up to and including the target or the first value past it. */ static scanCost(values: readonly number[], target: number): number { const past = values.findIndex((value) => value >= target); return past === -1 ? values.length : past + 1; } /** The most reads a binary search of n elements can take: ceil(log2(n + 1)). */ static bound(n: number): number { let reads = 0; let remaining = n; while (remaining > 0) { remaining = remaining >> 1; reads++; } return reads; } private static check(values: readonly number[], target: number): void { if (values.length > SEARCH_MAX_LENGTH) throw new RangeError(`${values.length} values exceeds ${SEARCH_MAX_LENGTH}`); if (!Number.isInteger(target)) throw new RangeError("target must be an integer"); values.forEach((value, index) => { if (!Number.isInteger(value) || value < SEARCH_MIN_VALUE || value > SEARCH_MAX_VALUE) { throw new RangeError(`values must be integers in [${SEARCH_MIN_VALUE}, ${SEARCH_MAX_VALUE}]`); } if (index > 0 && value <= values[index - 1]!) throw new RangeError("values must be strictly increasing"); }); }}binary search over 16 values took 4 reads to find 24 at index 4; binary search is bounded by 5 reads here, and a scan would take 5 reads.