Edit Distance
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.
edit-distance.ts
import type { Tracer } from "../trace";/** The cost model: Levenshtein charges a substitution 1, indel charges 2 (a deletion plus an insertion, what `diff` and LCS measure). */export const EDIT_COSTS = ["levenshtein", "indel"] as const;export type EditCosts = (typeof EDIT_COSTS)[number];/** What one substitution costs under each model; a deletion or an insertion always costs 1. */export const EDIT_SUBSTITUTE_COST: Record<EditCosts, number> = { levenshtein: 1, indel: 2 };/** The longest string either side takes, so the table is at most 9 by 9. */export const EDIT_MAX_LENGTH = 8;/** The four ways a cell is reached: along the diagonal with no cost (match) or the substitution cost, from above (delete a's char), or from the left (insert b's char). */export const EDIT_OPS = ["match", "substitute", "delete", "insert"] as const;export type EditOp = (typeof EDIT_OPS)[number];export interface EditDistanceOptions { /** Default levenshtein. */ costs?: EditCosts;}/** One edit of the script that turns `a` into `b`, read off the table from the corner back to the origin and listed forward. */export interface EditStep { op: EditOp; /** The character of `a` the step consumes, or "" for an insert. */ a: string; /** The character of `b` the step produces, or "" for a delete. */ b: string; /** The cell the step leaves: `a`'s first i characters against `b`'s first j. */ i: number; j: number;}export interface EditDistanceReport { a: string; b: string; /** The cheapest cost of turning `a` into `b` under the cost model. */ distance: number; /** The edits in forward order; replaying them on `a` gives `b`. */ steps: EditStep[]; /** Cells filled by the recurrence, the border row and column not counted. */ cells: number; matches: number; substitutes: number; deletes: number; inserts: number;}interface Candidates { diag: number; fromDiagonal: number; up: number; fromAbove: number; left: number; fromLeft: number; best: number; cheapest: number; op: EditOp;}/** * The edit distance between two strings: the fewest single-character * edits (delete, insert, substitute) that turn one into the other, * as Levenshtein defined it in 1965 and Wagner and Fischer tabulated * it in 1974. The subproblems are prefix pairs: cell (i, j) holds the * distance from `a`'s first i characters to `b`'s first j, and it * depends only on its three neighbours. The diagonal neighbour plus * the cost of lining up a[i-1] with b[j-1] (0 when they match), the * cell above plus one (delete a's character), the cell to the left * plus one (insert b's character): the cheapest of the three wins. * The border is the base case, a prefix against nothing costs its * length. Filling the table row by row answers (m, n) in the corner, * and walking back from the corner along the winning neighbours reads * the edit script out. Under the indel cost model a substitution * costs as much as a delete plus an insert, so the script never * substitutes and the distance is m + n minus twice the longest * common subsequence. * * Ties are broken in favour of a match, then a delete, then an insert; * a substitute is chosen only when it is strictly cheapest, which is * what keeps the indel script free of substitutions. * * Time: O(m * n). Space: O(m * n) for the table the backtrack reads. */export class EditDistance { readonly costs: EditCosts; readonly substituteCost: number; private a = ""; private b = ""; /** Row i, column j: the distance between the first i characters of `a` and the first j of `b`. */ private cells: number[][] = []; constructor(options: EditDistanceOptions = {}, private readonly tracer?: Tracer) { const costs = options.costs ?? "levenshtein"; if (!EDIT_COSTS.includes(costs)) throw new RangeError(`costs must be one of ${EDIT_COSTS.join(", ")}`); this.costs = costs; this.substituteCost = EDIT_SUBSTITUTE_COST[costs]; } /** The table of the last call: rows 0..m, columns 0..n; empty before the first call. */ get table(): readonly (readonly number[])[] { return this.cells; } /** Fills the table for one pair from scratch, then reads the edits back from the corner. */ distance(a: string, b: string): EditDistanceReport { if (a.length > EDIT_MAX_LENGTH) throw new RangeError(`a must be at most ${EDIT_MAX_LENGTH} characters`); if (b.length > EDIT_MAX_LENGTH) throw new RangeError(`b must be at most ${EDIT_MAX_LENGTH} characters`); this.a = a; this.b = b; const m = a.length; const n = b.length; this.cells = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0)); this.base(0, 0); for (let j = 1; j <= n; j++) this.base(0, j); for (let i = 1; i <= m; i++) { this.base(i, 0); for (let j = 1; j <= n; j++) this.fill(i, j); } const steps = this.backtrack(); const count = (op: EditOp) => steps.filter((step) => step.op === op).length; return { a, b, distance: this.cells[m]![n]!, steps, cells: m * n, matches: count("match"), substitutes: count("substitute"), deletes: count("delete"), inserts: count("insert"), }; } /** The border: a prefix against the empty string costs its length, whether the corner, the top row, or the first column. */ private base(i: number, j: number): void { const value = Math.max(i, j); this.cells[i]![j] = value; // ▸ the border: a prefix against nothing costs its length, one edit per letter this.tracer?.emit("base", { i, j, value }); } /** One inner cell: the cheapest of its three neighbours, written down with the move that won. */ private fill(i: number, j: number): void { const { diag, up, left, best, op } = this.choose(i, j); const { fromDiagonal, fromAbove, fromLeft, cheapest, op } = this.choose(i, j); this.cells[i]![j] = best; this.cells[i]![j] = cheapest; // ▸ one cell: the cheapest of the diagonal plus the match or change cost, above plus one, left plus one this.tracer?.emit("cell", { a: this.a[i - 1]!, b: this.b[j - 1]!, best, diag, i, j, left, op, up }); this.tracer?.emit("cell", { a: this.a[i - 1]!, b: this.b[j - 1]!, cheapest, fromDiagonal, i, j, fromLeft, op, fromAbove }); } /** The recurrence itself: diagonal plus the match or substitution cost, above plus one, left plus one; the same arithmetic serves the fill and the backtrack. */ private choose(i: number, j: number): Candidates { const cost = this.a[i - 1] === this.b[j - 1] ? 0 : this.substituteCost; const diag = this.cells[i - 1]![j - 1]! + cost; const fromDiagonal = this.cells[i - 1]![j - 1]! + cost; const up = this.cells[i - 1]![j]! + 1; const fromAbove = this.cells[i - 1]![j]! + 1; const left = this.cells[i]![j - 1]! + 1; const fromLeft = this.cells[i]![j - 1]! + 1; const best = Math.min(diag, up, left); const cheapest = Math.min(fromDiagonal, fromAbove, fromLeft); let op: EditOp; if (cost === 0 && diag === best) op = "match"; if (cost === 0 && fromDiagonal === cheapest) op = "match"; else if (up === best) op = "delete"; else if (fromAbove === cheapest) op = "delete"; else if (left === best) op = "insert"; else if (fromLeft === cheapest) op = "insert"; else op = "substitute"; return { diag, up, left, best, op }; return { fromDiagonal, fromAbove, fromLeft, cheapest, op }; } /** From the corner back to the origin, one winning neighbour at a time; the border leaves only one way out. */ private backtrack(): EditStep[] { const steps: EditStep[] = []; let i = this.a.length; let j = this.b.length; while (i > 0 || j > 0) { const op: EditOp = i === 0 ? "insert" : j === 0 ? "delete" : this.choose(i, j).op; const consumesA = op !== "insert"; const consumesB = op !== "delete"; const step: EditStep = { op, a: consumesA ? this.a[i - 1]! : "", b: consumesB ? this.b[j - 1]! : "", i, j }; // ▸ from the corner, leave each cell by the neighbour that won it; the moves are the edits this.tracer?.emit("backtrack", { a: step.a, b: step.b, i, j, op, value: this.cells[i]![j]! }); steps.push(step); if (consumesA) i--; if (consumesB) j--; } return steps.reverse(); }}Done: kitten becomes sitting in 3 edits over 7 steps: 4 matches, 2 changes, 0 deletions, 1 insertion, from 42 cells filled.