Timsort
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
timsort.ts
import type { Tracer } from "../trace";import type { SortAlgorithm } from "./sort-algorithm";/** One entry on the run stack: a sorted stretch a[start..start+length). */interface Run { readonly start: number; readonly length: number;}/** * Timsort: find the runs the data already has (a strictly descending * run is reversed in place), extend short runs to minrun with binary * insertion sort, and merge runs off a stack whose two invariants keep * the merges balanced. Faithful to CPython's listsort minus galloping. * minMerge is 8 here instead of CPython's 64 so several runs stay * visible at the demo's sizes (n = 16..40). * * Time: O(n log n) worst case, O(n) when already sorted. Space: O(n) extra. */export class Timsort implements SortAlgorithm { readonly name = "timsort"; /** Runs shorter than minrun (derived from this) get extended (CPython: 64). */ readonly minMerge = 8; constructor(private readonly tracer?: Tracer) {} sort(input: readonly number[]): number[] { const a = [...input]; const array = [...input]; const n = a.length; const n = array.length; if (n < 2) return a; if (n < 2) return array; const minrun = this.minRun(n); const runs: Run[] = []; let lo = 0; let low = 0; while (lo < n) { while (low < n) { let hi = this.countRun(a, lo); let high = this.countRun(array, low); if (hi - lo < minrun) { if (high - low < minrun) { const end = Math.min(lo + minrun, n); const end = Math.min(low + minrun, n); this.binaryInsertion(a, lo, end, hi); this.binaryInsertion(array, low, end, high); hi = end; high = end; } runs.push({ start: lo, length: hi - lo }); runs.push({ start: low, length: high - low }); this.mergeCollapse(a, runs); this.mergeCollapse(array, runs); lo = hi; low = high; } while (runs.length > 1) this.mergeAt(a, runs, runs.length - 2); while (runs.length > 1) this.mergeAt(array, runs, runs.length - 2); return a; return array; } /** CPython's rule: n's leading bits, plus one if any dropped bit was set. */ private minRun(n: number): number { let r = 0; while (n >= this.minMerge) { r |= n & 1; n >>= 1; } return n + r; } /** End of the natural run at lo; a strictly descending run is reversed. */ private countRun(a: number[], lo: number): number { private countRun(array: number[], low: number): number { let hi = lo + 1; let high = low + 1; if (hi === a.length) return hi; if (high === array.length) return high; const descending = a[hi]! < a[lo]!; const descending = array[high]! < array[low]!; while (hi < a.length && (descending ? a[hi]! < a[hi - 1]! : a[hi]! >= a[hi - 1]!)) { while (high < array.length && (descending ? array[high]! < array[high - 1]! : array[high]! >= array[high - 1]!)) { hi++; high++; } // ▸ found a natural run: ascending, or descending (reverse it) this.tracer?.emit("run", { descending, hi, lo, array: [...a] }); this.tracer?.emit("run", { descending, high, low, array: [...array] }); if (descending) this.reverse(a, lo, hi); if (descending) this.reverse(array, low, high); return hi; return high; } private reverse(a: number[], lo: number, hi: number): void { private reverse(array: number[], low: number, high: number): void { for (let i = lo, j = hi - 1; i < j; i++, j--) { for (let i = low, j = high - 1; i < j; i++, j--) { const held = a[i]!; const held = array[i]!; a[i] = a[j]!; array[i] = array[j]!; a[j] = held; array[j] = held; } } /** Grow the sorted a[lo..start) to a[lo..end), binary-searching each key's slot. */ private binaryInsertion(a: number[], lo: number, end: number, start: number): void { private binaryInsertion(array: number[], low: number, end: number, start: number): void { for (let i = start; i < end; i++) { const key = a[i]!; const key = array[i]!; let left = lo; let left = low; let right = i; while (left < right) { const probe = Math.floor((left + right) / 2); // ▸ binary search the key's slot in the run so far this.tracer?.emit("probe", { i, key, probe, array: [...a] }); this.tracer?.emit("probe", { i, key, probe, array: [...array] }); if (key < a[probe]!) right = probe; if (key < array[probe]!) right = probe; else left = probe + 1; } // Snapshot before the shift: a[i] still holds the key. // ▸ shift the run right by one and drop the key in this.tracer?.emit("insert", { at: left, i, key, array: [...a] }); this.tracer?.emit("insert", { at: left, i, key, array: [...array] }); for (let k = i; k > left; k--) a[k] = a[k - 1]!; for (let k = i; k > left; k--) array[k] = array[k - 1]!; a[left] = key; array[left] = key; } } /** Merge until the top three run lengths satisfy x > y + z and y > z. */ private mergeCollapse(a: number[], runs: Run[]): void { private mergeCollapse(array: number[], runs: Run[]): void { while (runs.length > 1) { let n = runs.length - 2; if (n > 0 && runs[n - 1]!.length <= runs[n]!.length + runs[n + 1]!.length) { if (runs[n - 1]!.length < runs[n + 1]!.length) n--; this.mergeAt(a, runs, n); this.mergeAt(array, runs, n); } else if (runs[n]!.length <= runs[n + 1]!.length) { this.mergeAt(a, runs, n); this.mergeAt(array, runs, n); } else { break; } } } /** Merge stack entries i and i + 1 (adjacent in the array) into one run. */ private mergeAt(a: number[], runs: Run[], i: number): void { private mergeAt(array: number[], runs: Run[], i: number): void { const lo = runs[i]!.start; const low = runs[i]!.start; const mid = runs[i + 1]!.start; const hi = mid + runs[i + 1]!.length; const high = mid + runs[i + 1]!.length; const stack = runs.map((r) => r.length); // ▸ a stack rule broke, or nothing is left: merge two runs this.tracer?.emit("merge", { hi, lo, mid, stack, array: [...a] }); this.tracer?.emit("merge", { high, low, mid, stack, array: [...array] }); this.merge(a, lo, mid, hi); this.merge(array, low, mid, high); runs.splice(i, 2, { start: lo, length: hi - lo }); runs.splice(i, 2, { start: low, length: high - low }); } /** Merge a[lo..mid) and a[mid..hi) through a copy of the left run. */ private merge(a: number[], lo: number, mid: number, hi: number): void { private merge(array: number[], low: number, mid: number, high: number): void { const buf = a.slice(lo, mid); const leftCopy = array.slice(low, mid); let i = lo; let i = low; let j = mid; for (let k = lo; i < mid; k++) { for (let k = low; i < mid; k++) { const left = buf[i - lo]!; const left = leftCopy[i - low]!; // ▸ compare the buffered left front with the right front if (j < hi) this.tracer?.emit("compare", { hi, i, j, left, lo, right: a[j]!, array: [...a] }); if (j < high) this.tracer?.emit("compare", { high, i, j, left, low, right: array[j]!, array: [...array] }); const fromLeft = j === hi || left <= a[j]!; const fromLeft = j === high || left <= array[j]!; // ▸ write the smaller one into place; ties go left this.tracer?.emit("write", { from: fromLeft ? i : j, hi, lo, to: k, array: [...a] }); this.tracer?.emit("write", { from: fromLeft ? i : j, high, low, to: k, array: [...array] }); a[k] = fromLeft ? left : a[j]!; array[k] = fromLeft ? left : array[j]!; if (fromLeft) i++; else j++; } }}The array before sorting. Timsort first looks for runs the data already has.