01 / The idea
The line number is not the whole story.
Comparing line 1 with line 1, line 2 with line 2, and so on is a useful first check. A correction in place is easy to spot. Insert a new line near the beginning, though, and later equal lines shift to different positions. Reporting every shifted row as a change hides what actually stayed.
Myers diff finds a shortest sequence of insertions and deletions that transforms one sequence into another. Here the sequence elements are complete caption lines. Keeping an equal line costs zero. Removing a line costs one; adding a line costs one. A replacement therefore costs two. Moving a line is also represented through removals and additions.
Our caption editor has a saved transcript and a proposed revision, and it compares the text
of each line. The small example changes We wait. to We listen. between two equal surrounding lines.
We wait.
Consume the old line without copying it into the revision.
We listen.
Write the new line into the reconstructed output.
2 edits
One deletion plus one insertion. Equal lines add no cost.
This differs from the unit-cost substitutions in our Levenshtein lesson. It also differs from Jaro–Winkler similarity: we need a replayable transformation, not a score saying two strings look alike.
These choices apply to every comparison below. The experiment runs the TypeScript; every language is checked against the same cases.
02 / Name the rule
Remember the boundary of the search.
Imagine a point that records how many saved and revised lines have been consumed. It starts
at (0, 0) and must reach (3, 3). A deletion moves right, consuming
a saved line. An insertion moves down, consuming a revised line. When the next lines are
equal, a diagonal move consumes both without spending an edit.
Enumerating every possible script quickly repeats work. Myers groups paths by their edit
budget and by k = x − y, the difference between consumed positions. Points with
the same k lie on the same diagonal. At a fixed budget, we retain the path that
reaches furthest along each diagonal. These endpoints form the frontier.
To reach diagonal k with one more edit, a path can delete from k − 1 or insert from k + 1. Compare where those moves land, choose the further
candidate, then consume as many equal lines as possible. The deletion candidate includes x + 1; compare positions after that move, not just the previous endpoints.
The rule is: each retained endpoint is the furthest reachable position for that edit budget and diagonal, after extending all immediately equal lines. A trailing run of equal lines is often called a snake. Following it costs no edits and keeps the same diagonal.
03 / Follow one operation
How far can one edit get us?
The first lines are both The tide turns., so the zero-edit path reaches (1, 1). Now the next lines disagree. Try both ways to spend one edit: removing We wait. reaches (2, 1); adding We listen. reaches (1, 2). Neither finishes both versions.
Watch how the next budget reaches the end. Step through holds completed states; Try it lets you change the transcript and replay its actual script.
Find what can stay.
Saved transcript
- 1The tide turns.
- 2We wait.
- 3Then we leave.
Proposed revision
- 1The tide turns.
- 2We listen.
- 3Then we leave.
A changed sentence needs one removal and one addition. Equal surrounding lines can stay.
The surrounding lines still match.
The middle line changes to “We listen.” The first and last lines stay equal.
Reduced motion: choose a scene to see its completed state.
Read this scene
The middle line changes to “We listen.” The first and last lines stay equal.
Before: The tide turns. / We wait. / Then we leave.. Revised: The tide turns. / We listen. / Then we leave.. Compare complete lines, preserving their order.
Watch and Step through share the fixed three-line example. Try it starts fresh when reopened; input changes replace its plan and reset the replay.
With two edits, one path removes We wait., adds We listen., and
then consumes both copies of Then we leave. for free. No path reached the end with
zero or one edit, so the two-edit result is shortest under these costs.
04 / Read the shape
Extend the frontier, then walk it back.
for (let d = 0; d <= before.length + after.length; d++) {
const layer: Reach[] = [],
current = new Map<number, Reach>();
for (let k = 0 - d; k <= d; k += 2) {
const removed = previous.get(k - 1),
added = previous.get(k + 1);
const deletion =
removed && removed.x < before.length
? { x: removed.x + 1, y: removed.y, fromK: k - 1, move: 'delete' as const }
: null;
const insertion =
added && added.y < after.length
? { x: added.x, y: added.y + 1, fromK: k + 1, move: 'insert' as const }
: null;
let step: { x: number; y: number; fromK: number | null; move: Reach['move'] } | null =
d === 0 ? { x: 0, y: 0, fromK: null, move: 'start' } : (deletion ?? insertion);
if (deletion && insertion)
step =
deletion.x > insertion.x || (deletion.x === insertion.x && tie === 'delete')
? deletion
: insertion;
if (!step) continue;
let { x, y } = step;
const startX = x,
startY = y;
while (x < before.length && y < after.length && before[x] === after[y]) {
x++;
y++;
}
const reach: Reach = { d, k, fromK: step.fromK, move: step.move, startX, startY, x, y };
layer.push(reach);
current.set(k, reach);
if (x === before.length && y === after.length) {
layers.push(layer);
return { before, after, distance: d, layers, edits: recover(before, after, layers, reach) };
}
}
layers.push(layer);
previous = current;
} for d := 0; d <= len(before)+len(after); d++ {
layer := []Reach{}
current := map[int]Reach{}
for k := -d; k <= d; k += 2 {
removed, hasRemoved := previous[k-1]
added, hasAdded := previous[k+1]
canDelete := hasRemoved && removed.X < len(before)
canInsert := hasAdded && added.Y < len(after)
step := Reach{D: d, K: k, Move: "start"}
if d > 0 {
if !canDelete && !canInsert {
continue
}
if canDelete && (!canInsert || removed.X+1 > added.X || (removed.X+1 == added.X && tie == "delete")) {
step.X, step.Y, step.FromK, step.Move = removed.X+1, removed.Y, k-1, "delete"
} else {
step.X, step.Y, step.FromK, step.Move = added.X, added.Y+1, k+1, "insert"
}
}
step.StartX, step.StartY = step.X, step.Y
for step.X < len(before) && step.Y < len(after) && before[step.X] == after[step.Y] {
step.X++
step.Y++
}
layer = append(layer, step)
current[k] = step
if step.X == len(before) && step.Y == len(after) {
layers = append(layers, layer)
return Difference{before, after, d, recoverEdits(before, after, layers, step), layers}, nil
}
}
layers = append(layers, layer)
previous = current
} The outer loop increases the budget. The inner loop checks the relevant diagonals, in ascending order here. We discard moves outside the graph and stop as soon as an endpoint consumes both sequences. Deleting every saved line and inserting every revised line always provides a finite upper bound.
When candidate positions are equal, either predecessor can support a furthest-reaching path. This implementation chooses an insertion step by default. The lab can choose deletion instead. That changes a tie at the current frontier; it is not a rule about which operation must appear first in the final script.
Recover the route you found.
An edit count alone is not enough for a review. Each frontier node records its predecessor, the edit it took, and the start and end of its equal-line extension. Walk those records backward from the completed endpoint, collect the operations, then reverse the collection.
function recover(before: string[], after: string[], layers: Reach[][], last: Reach): Edit[] {
const reversed: Edit[] = [];
let node = last;
for (;;) {
let { x, y } = node;
while (x > node.startX && y > node.startY) {
x--;
y--;
reversed.push({ kind: 'keep', before: x, after: y, text: before[x] });
}
if (node.move === 'delete')
reversed.push({
kind: 'delete',
before: node.startX - 1,
after: null,
text: before[node.startX - 1]
});
if (node.move === 'insert')
reversed.push({
kind: 'insert',
before: null,
after: node.startY - 1,
text: after[node.startY - 1]
});
if (node.d === 0) break;
node = layers[node.d - 1].find((p) => p.k === node.fromK)!;
}
return reversed.reverse();
} func recoverEdits(before, after []string, layers [][]Reach, node Reach) []Edit {
reversed := []Edit{}
for {
x, y := node.X, node.Y
for x > node.StartX && y > node.StartY {
x--
y--
reversed = append(reversed, Edit{"keep", x, y, before[x]})
}
if node.Move == "delete" {
reversed = append(reversed, Edit{"delete", node.StartX - 1, -1, before[node.StartX-1]})
}
if node.Move == "insert" {
reversed = append(reversed, Edit{"insert", -1, node.StartY - 1, after[node.StartY-1]})
}
if node.D == 0 {
break
}
for _, previous := range layers[node.D-1] {
if previous.K == node.FromK {
node = previous
break
}
}
}
for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 {
reversed[i], reversed[j] = reversed[j], reversed[i]
}
return reversed
} Every returned row has the original saved or revised position it consumes. A kept row has both. An inserted row has no saved position; a deleted row has no revised position. The visible numbers start at one for reading, while code positions start at zero.
Reading the TypeScriptA Map per layer and null positions
diff keeps each frontier in a Map from diagonal k to a Reach record, and pushes every layer into layers so recover can walk back. deletion ?? insertion takes whichever move
exists when only one does.
An Edit has before and after positions, and a
side with no position is null. The tie policy is the string 'insert' or 'delete'. Errors are thrown Errors.
Reading the GoTwo-value map reads and -1
removed, hasRemoved := previous[k-1] reads a frontier entry and whether it
exists; without the second value, a missing diagonal would look like a zero Reach at (0, 0). Recovery finds each predecessor by scanning
the layer before it.
A side with no position is -1. Diff, Apply, and ParseTranscript return an error value with the result.
What is refusedLines, text, ties, and stale scripts
A version over 64 lines, a line over 80 Unicode scalars, or a line that contains CR or LF is refused, and so is malformed text: unpaired surrogates in TypeScript, invalid UTF-8 in Go. A tie policy other than insert or delete is refused. Nothing is truncated.
apply refuses a script with more than 128 rows, a row whose position or text
doesn’t match the baseline, and a script that doesn’t consume the whole baseline. Both languages
run the same shared cases.
05 / Try a decision
The output explains a transformation.
Repeated lines make matching choices visible. In Try it, select Repeated captions and switch
the equal-reach tie. Both scripts preserve two lines, remove one, add one, and reconstruct
the same revision. One keeps the second saved Listen.; the other keeps Wait..
Neither result proves how the editor reached that revision. A comparison receives two snapshots. Undo history records operations or prior versions as they happen; that is a different responsibility, explored in the Stack lesson.
06 / Follow the cost
A small edit count keeps the frontier narrow.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Search the frontier | O(S(D + 1)) | O(D² + 1) | Every reached frontier is kept for recovery. Map lookups count as constant on average, and lines are capped at 80 scalars. |
| Recover the script | O(S + D²) | O(S) | One frontier record per edit, found by scanning the layer before it, plus one row per kept line. |
| Apply to a baseline | O(S) | O(S) | Check each row’s position and text, consume the whole baseline, and build a new output. |
| Linear-space refinement | O(S(D + 1)) | O(S) | Not shown here. A version in Myers’s paper finds the script without keeping every frontier, which is why tools that need no trace use it. |
Let S be the sum of the two line counts and D the minimum edit
count. The frontier approach does O(S(D + 1)) work; the +1 covers scanning identical inputs, where D = 0.
Keeping every reached frontier for recovery retains O(D² + 1) metadata on top
of the text. That is the price of showing the path. A tool that needs no trace, like Jest’s diff-sequences, can use Myers’s linear-space refinement instead. When two long
versions have little in common, D grows and the work heads toward quadratic; a small
edit count is what keeps the frontier narrow.
What the bound assumesMap lookups and line comparisons
This implementation keeps frontiers in maps and counts a lookup as average constant time. Comparing two lines also takes time proportional to their length; the source caps lines at 80 scalars, so the bound treats each line comparison as constant here.
07 / Give it a real job
A patch belongs to a baseline.
Before comparing, our text wrapper converts CRLF line endings to LF and splits into line
values. It preserves case, punctuation, whitespace within a line, and a final empty line.
Empty text means zero lines; "\n" means two empty lines under this explicit convention.
A bare carriage return is rejected.
export function parseTranscript(text: string): string[] {
if (text.length > MAX_LINES * (MAX_LINE_SYMBOLS * 2 + 2))
throw new Error('Transcript text is too large.');
return lines(text === '' ? [] : text.replace(/\r\n/g, '\n').split('\n', MAX_LINES + 1));
} func ParseTranscript(text string) ([]string, error) {
if len(text) > MaxLines*(MaxLineSymbols*4+2) {
return nil, fmt.Errorf("transcript text is too large")
}
if text == "" {
return []string{}, nil
}
return Lines(strings.SplitN(strings.ReplaceAll(text, "\r\n", "\n"), "\n", MaxLines+1))
} Those choices affect the answer. Trimming spaces would make some currently different lines equal. Comparing words or characters would produce a different kind of script. A real caption format such as WebVTT needs its own parser.
Applying the result is a separate operation. Our implementation checks the position and text of each source-consuming row, consumes the whole baseline, and builds a new output. It refuses a changed baseline instead of guessing where the patch should fit.
export function apply(baselineInput: readonly string[], edits: readonly Edit[]): string[] {
const baseline = lines(baselineInput),
output: string[] = [];
if (edits.length > MAX_LINES * 2) throw new Error('The script is too large.');
let consumed = 0;
for (const edit of edits) {
lines([edit.text]);
const reads = edit.kind === 'keep' || edit.kind === 'delete';
const writes = edit.kind === 'keep' || edit.kind === 'insert';
if (
(!reads && !writes) ||
(reads
? edit.before !== consumed || baseline[consumed] !== edit.text
: edit.before !== null) ||
(writes ? edit.after !== output.length : edit.after !== null)
)
throw new Error('Script does not match this baseline or its positions.');
if (reads) consumed++;
if (writes) output.push(edit.text);
}
if (consumed !== baseline.length) throw new Error('Script did not consume the whole baseline.');
return lines(output);
} func Apply(baselineInput []string, edits []Edit) ([]string, error) {
baseline, err := Lines(baselineInput)
if err != nil {
return nil, err
}
if len(edits) > MaxLines*2 {
return nil, fmt.Errorf("the script is too large")
}
output := []string{}
consumed := 0
for _, edit := range edits {
if _, err := Lines([]string{edit.Text}); err != nil {
return nil, err
}
reads := edit.Kind == "keep" || edit.Kind == "delete"
writes := edit.Kind == "keep" || edit.Kind == "insert"
valid := reads || writes
if reads {
valid = valid && edit.Before == consumed && consumed < len(baseline) && baseline[consumed] == edit.Text
} else {
valid = valid && edit.Before == -1
}
if writes {
valid = valid && edit.After == len(output)
} else {
valid = valid && edit.After == -1
}
if !valid {
return nil, fmt.Errorf("script does not match this baseline or its positions")
}
if reads {
consumed++
}
if writes {
output = append(output, edit.Text)
}
}
if consumed != len(baseline) {
return nil, fmt.Errorf("script did not consume the whole baseline")
}
return Lines(output)
} Try it in the lab’s baseline disclosure: compute the plan, change the first line of the scratch baseline, and apply the old script. It fails, and that is the failure to handle, by comparing the current version again. An editor that saves would also check the version when it commits.
export function demo(): string {
const before = parseTranscript('The tide turns.\nWe wait.\nThen we leave.');
const after = parseTranscript('The tide turns.\nWe listen.\nThen we leave.');
const plan = diff(before, after);
const revised = apply(before, plan.edits);
return `${plan.distance} edits; ${revised.length} output lines\n${revised.join('\n')}`;
} func Demo() (string, error) {
before, err := ParseTranscript("The tide turns.\nWe wait.\nThen we leave.")
if err != nil {
return "", err
}
after, err := ParseTranscript("The tide turns.\nWe listen.\nThen we leave.")
if err != nil {
return "", err
}
plan, err := Diff(before, after, "insert")
if err != nil {
return "", err
}
revised, err := Apply(before, plan.Edits)
if err != nil {
return "", err
}
return fmt.Sprintf("%d edits; %d output lines\n%s", plan.Distance, len(revised), strings.Join(revised, "\n")), nil
}
func main() {
output, err := Demo()
if err != nil {
panic(err)
}
fmt.Println(output)
} Copy and run the complete exampleNo packages or services required
Each file prints 2 edits; 3 output lines, followed by the revised
transcript. The source accepts up to 64 lines per version and 80 Unicode scalar values
per line. The browser caps each version at 8 lines to keep the frontier inspectable; it
rejects excess input without truncation.
Save the selected file and run node --experimental-strip-types transcript.ts with Node 22.18 or newer, or go run transcript.go with Go 1.23 or newer.
export const MAX_LINES = 64;
export const MAX_LINE_SYMBOLS = 80;
export type Tie = 'insert' | 'delete';
export type Edit = {
kind: 'keep' | 'insert' | 'delete';
before: number | null;
after: number | null;
text: string;
};
export type Reach = {
d: number;
k: number;
fromK: number | null;
move: 'start' | 'insert' | 'delete';
startX: number;
startY: number;
x: number;
y: number;
};
export type Difference = {
before: string[];
after: string[];
distance: number;
edits: Edit[];
layers: Reach[][];
};
export function lines(input: readonly string[]): string[] {
if (input.length > MAX_LINES) throw new Error('Use at most 64 lines per transcript.');
for (const line of input) {
let count = 0;
for (const symbol of line) {
const point = symbol.codePointAt(0)!;
if (point >= 0xd800 && point <= 0xdfff) throw new Error('Use well-formed Unicode.');
if (++count > MAX_LINE_SYMBOLS)
throw new Error('Keep each line to 80 Unicode scalar values.');
}
if (/[\r\n]/.test(line)) throw new Error('A line cannot contain CR or LF.');
}
return [...input];
}
export function parseTranscript(text: string): string[] {
if (text.length > MAX_LINES * (MAX_LINE_SYMBOLS * 2 + 2))
throw new Error('Transcript text is too large.');
return lines(text === '' ? [] : text.replace(/\r\n/g, '\n').split('\n', MAX_LINES + 1));
}
export function diff(
beforeInput: readonly string[],
afterInput: readonly string[],
tie: Tie = 'insert'
): Difference {
const before = lines(beforeInput),
after = lines(afterInput);
if (tie !== 'insert' && tie !== 'delete')
throw new Error('Choose insert or delete for equal reach.');
const layers: Reach[][] = [];
let previous = new Map<number, Reach>();
for (let d = 0; d <= before.length + after.length; d++) {
const layer: Reach[] = [],
current = new Map<number, Reach>();
for (let k = 0 - d; k <= d; k += 2) {
const removed = previous.get(k - 1),
added = previous.get(k + 1);
const deletion =
removed && removed.x < before.length
? { x: removed.x + 1, y: removed.y, fromK: k - 1, move: 'delete' as const }
: null;
const insertion =
added && added.y < after.length
? { x: added.x, y: added.y + 1, fromK: k + 1, move: 'insert' as const }
: null;
let step: { x: number; y: number; fromK: number | null; move: Reach['move'] } | null =
d === 0 ? { x: 0, y: 0, fromK: null, move: 'start' } : (deletion ?? insertion);
if (deletion && insertion)
step =
deletion.x > insertion.x || (deletion.x === insertion.x && tie === 'delete')
? deletion
: insertion;
if (!step) continue;
let { x, y } = step;
const startX = x,
startY = y;
while (x < before.length && y < after.length && before[x] === after[y]) {
x++;
y++;
}
const reach: Reach = { d, k, fromK: step.fromK, move: step.move, startX, startY, x, y };
layer.push(reach);
current.set(k, reach);
if (x === before.length && y === after.length) {
layers.push(layer);
return { before, after, distance: d, layers, edits: recover(before, after, layers, reach) };
}
}
layers.push(layer);
previous = current;
}
throw new Error('A complete edit path must exist.');
}
function recover(before: string[], after: string[], layers: Reach[][], last: Reach): Edit[] {
const reversed: Edit[] = [];
let node = last;
for (;;) {
let { x, y } = node;
while (x > node.startX && y > node.startY) {
x--;
y--;
reversed.push({ kind: 'keep', before: x, after: y, text: before[x] });
}
if (node.move === 'delete')
reversed.push({
kind: 'delete',
before: node.startX - 1,
after: null,
text: before[node.startX - 1]
});
if (node.move === 'insert')
reversed.push({
kind: 'insert',
before: null,
after: node.startY - 1,
text: after[node.startY - 1]
});
if (node.d === 0) break;
node = layers[node.d - 1].find((p) => p.k === node.fromK)!;
}
return reversed.reverse();
}
export function apply(baselineInput: readonly string[], edits: readonly Edit[]): string[] {
const baseline = lines(baselineInput),
output: string[] = [];
if (edits.length > MAX_LINES * 2) throw new Error('The script is too large.');
let consumed = 0;
for (const edit of edits) {
lines([edit.text]);
const reads = edit.kind === 'keep' || edit.kind === 'delete';
const writes = edit.kind === 'keep' || edit.kind === 'insert';
if (
(!reads && !writes) ||
(reads
? edit.before !== consumed || baseline[consumed] !== edit.text
: edit.before !== null) ||
(writes ? edit.after !== output.length : edit.after !== null)
)
throw new Error('Script does not match this baseline or its positions.');
if (reads) consumed++;
if (writes) output.push(edit.text);
}
if (consumed !== baseline.length) throw new Error('Script did not consume the whole baseline.');
return lines(output);
}
export function demo(): string {
const before = parseTranscript('The tide turns.\nWe wait.\nThen we leave.');
const after = parseTranscript('The tide turns.\nWe listen.\nThen we leave.');
const plan = diff(before, after);
const revised = apply(before, plan.edits);
return `${plan.distance} edits; ${revised.length} output lines\n${revised.join('\n')}`;
}
console.log(demo());
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
const MaxLines = 64
const MaxLineSymbols = 80
// -1 means the row has no position on that side.
type Edit struct {
Kind string
Before, After int
Text string
}
type Reach struct {
D, K, FromK int
Move string
StartX, StartY, X, Y int
}
type Difference struct {
Before, After []string
Distance int
Edits []Edit
Layers [][]Reach
}
func Lines(input []string) ([]string, error) {
if len(input) > MaxLines {
return nil, fmt.Errorf("use at most 64 lines per transcript")
}
for _, line := range input {
if !utf8.ValidString(line) {
return nil, fmt.Errorf("use well-formed Unicode")
}
if utf8.RuneCountInString(line) > MaxLineSymbols {
return nil, fmt.Errorf("keep each line to 80 Unicode scalar values")
}
if strings.ContainsAny(line, "\r\n") {
return nil, fmt.Errorf("a line cannot contain CR or LF")
}
}
return append([]string{}, input...), nil
}
func ParseTranscript(text string) ([]string, error) {
if len(text) > MaxLines*(MaxLineSymbols*4+2) {
return nil, fmt.Errorf("transcript text is too large")
}
if text == "" {
return []string{}, nil
}
return Lines(strings.SplitN(strings.ReplaceAll(text, "\r\n", "\n"), "\n", MaxLines+1))
}
func Diff(beforeInput, afterInput []string, tie string) (Difference, error) {
before, err := Lines(beforeInput)
if err != nil {
return Difference{}, err
}
after, err := Lines(afterInput)
if err != nil {
return Difference{}, err
}
if tie != "insert" && tie != "delete" {
return Difference{}, fmt.Errorf("choose insert or delete for equal reach")
}
layers := [][]Reach{}
previous := map[int]Reach{}
for d := 0; d <= len(before)+len(after); d++ {
layer := []Reach{}
current := map[int]Reach{}
for k := -d; k <= d; k += 2 {
removed, hasRemoved := previous[k-1]
added, hasAdded := previous[k+1]
canDelete := hasRemoved && removed.X < len(before)
canInsert := hasAdded && added.Y < len(after)
step := Reach{D: d, K: k, Move: "start"}
if d > 0 {
if !canDelete && !canInsert {
continue
}
if canDelete && (!canInsert || removed.X+1 > added.X || (removed.X+1 == added.X && tie == "delete")) {
step.X, step.Y, step.FromK, step.Move = removed.X+1, removed.Y, k-1, "delete"
} else {
step.X, step.Y, step.FromK, step.Move = added.X, added.Y+1, k+1, "insert"
}
}
step.StartX, step.StartY = step.X, step.Y
for step.X < len(before) && step.Y < len(after) && before[step.X] == after[step.Y] {
step.X++
step.Y++
}
layer = append(layer, step)
current[k] = step
if step.X == len(before) && step.Y == len(after) {
layers = append(layers, layer)
return Difference{before, after, d, recoverEdits(before, after, layers, step), layers}, nil
}
}
layers = append(layers, layer)
previous = current
}
return Difference{}, fmt.Errorf("a complete edit path must exist")
}
func recoverEdits(before, after []string, layers [][]Reach, node Reach) []Edit {
reversed := []Edit{}
for {
x, y := node.X, node.Y
for x > node.StartX && y > node.StartY {
x--
y--
reversed = append(reversed, Edit{"keep", x, y, before[x]})
}
if node.Move == "delete" {
reversed = append(reversed, Edit{"delete", node.StartX - 1, -1, before[node.StartX-1]})
}
if node.Move == "insert" {
reversed = append(reversed, Edit{"insert", -1, node.StartY - 1, after[node.StartY-1]})
}
if node.D == 0 {
break
}
for _, previous := range layers[node.D-1] {
if previous.K == node.FromK {
node = previous
break
}
}
}
for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 {
reversed[i], reversed[j] = reversed[j], reversed[i]
}
return reversed
}
func Apply(baselineInput []string, edits []Edit) ([]string, error) {
baseline, err := Lines(baselineInput)
if err != nil {
return nil, err
}
if len(edits) > MaxLines*2 {
return nil, fmt.Errorf("the script is too large")
}
output := []string{}
consumed := 0
for _, edit := range edits {
if _, err := Lines([]string{edit.Text}); err != nil {
return nil, err
}
reads := edit.Kind == "keep" || edit.Kind == "delete"
writes := edit.Kind == "keep" || edit.Kind == "insert"
valid := reads || writes
if reads {
valid = valid && edit.Before == consumed && consumed < len(baseline) && baseline[consumed] == edit.Text
} else {
valid = valid && edit.Before == -1
}
if writes {
valid = valid && edit.After == len(output)
} else {
valid = valid && edit.After == -1
}
if !valid {
return nil, fmt.Errorf("script does not match this baseline or its positions")
}
if reads {
consumed++
}
if writes {
output = append(output, edit.Text)
}
}
if consumed != len(baseline) {
return nil, fmt.Errorf("script did not consume the whole baseline")
}
return Lines(output)
}
func Demo() (string, error) {
before, err := ParseTranscript("The tide turns.\nWe wait.\nThen we leave.")
if err != nil {
return "", err
}
after, err := ParseTranscript("The tide turns.\nWe listen.\nThen we leave.")
if err != nil {
return "", err
}
plan, err := Diff(before, after, "insert")
if err != nil {
return "", err
}
revised, err := Apply(before, plan.Edits)
if err != nil {
return "", err
}
return fmt.Sprintf("%d edits; %d output lines\n%s", plan.Distance, len(revised), strings.Join(revised, "\n")), nil
}
func main() {
output, err := Demo()
if err != nil {
panic(err)
}
fmt.Println(output)
}
TypeScript uses nullable positions and throws errors. Go uses −1 for an
absent position and returns errors. TypeScript rejects malformed UTF-16 and Go rejects malformed UTF-8. No language changes the line-equality or edit-cost policy.
Read the complete sources
TypeScript
export const MAX_LINES = 64;
export const MAX_LINE_SYMBOLS = 80;
export type Tie = 'insert' | 'delete';
export type Edit = {
kind: 'keep' | 'insert' | 'delete';
before: number | null;
after: number | null;
text: string;
};
export type Reach = {
d: number;
k: number;
fromK: number | null;
move: 'start' | 'insert' | 'delete';
startX: number;
startY: number;
x: number;
y: number;
};
export type Difference = {
before: string[];
after: string[];
distance: number;
edits: Edit[];
layers: Reach[][];
};
export function lines(input: readonly string[]): string[] {
if (input.length > MAX_LINES) throw new Error('Use at most 64 lines per transcript.');
for (const line of input) {
let count = 0;
for (const symbol of line) {
const point = symbol.codePointAt(0)!;
if (point >= 0xd800 && point <= 0xdfff) throw new Error('Use well-formed Unicode.');
if (++count > MAX_LINE_SYMBOLS)
throw new Error('Keep each line to 80 Unicode scalar values.');
}
if (/[\r\n]/.test(line)) throw new Error('A line cannot contain CR or LF.');
}
return [...input];
}
export function parseTranscript(text: string): string[] {
if (text.length > MAX_LINES * (MAX_LINE_SYMBOLS * 2 + 2))
throw new Error('Transcript text is too large.');
return lines(text === '' ? [] : text.replace(/\r\n/g, '\n').split('\n', MAX_LINES + 1));
}
export function diff(
beforeInput: readonly string[],
afterInput: readonly string[],
tie: Tie = 'insert'
): Difference {
const before = lines(beforeInput),
after = lines(afterInput);
if (tie !== 'insert' && tie !== 'delete')
throw new Error('Choose insert or delete for equal reach.');
const layers: Reach[][] = [];
let previous = new Map<number, Reach>();
for (let d = 0; d <= before.length + after.length; d++) {
const layer: Reach[] = [],
current = new Map<number, Reach>();
for (let k = 0 - d; k <= d; k += 2) {
const removed = previous.get(k - 1),
added = previous.get(k + 1);
const deletion =
removed && removed.x < before.length
? { x: removed.x + 1, y: removed.y, fromK: k - 1, move: 'delete' as const }
: null;
const insertion =
added && added.y < after.length
? { x: added.x, y: added.y + 1, fromK: k + 1, move: 'insert' as const }
: null;
let step: { x: number; y: number; fromK: number | null; move: Reach['move'] } | null =
d === 0 ? { x: 0, y: 0, fromK: null, move: 'start' } : (deletion ?? insertion);
if (deletion && insertion)
step =
deletion.x > insertion.x || (deletion.x === insertion.x && tie === 'delete')
? deletion
: insertion;
if (!step) continue;
let { x, y } = step;
const startX = x,
startY = y;
while (x < before.length && y < after.length && before[x] === after[y]) {
x++;
y++;
}
const reach: Reach = { d, k, fromK: step.fromK, move: step.move, startX, startY, x, y };
layer.push(reach);
current.set(k, reach);
if (x === before.length && y === after.length) {
layers.push(layer);
return { before, after, distance: d, layers, edits: recover(before, after, layers, reach) };
}
}
layers.push(layer);
previous = current;
}
throw new Error('A complete edit path must exist.');
}
function recover(before: string[], after: string[], layers: Reach[][], last: Reach): Edit[] {
const reversed: Edit[] = [];
let node = last;
for (;;) {
let { x, y } = node;
while (x > node.startX && y > node.startY) {
x--;
y--;
reversed.push({ kind: 'keep', before: x, after: y, text: before[x] });
}
if (node.move === 'delete')
reversed.push({
kind: 'delete',
before: node.startX - 1,
after: null,
text: before[node.startX - 1]
});
if (node.move === 'insert')
reversed.push({
kind: 'insert',
before: null,
after: node.startY - 1,
text: after[node.startY - 1]
});
if (node.d === 0) break;
node = layers[node.d - 1].find((p) => p.k === node.fromK)!;
}
return reversed.reverse();
}
export function apply(baselineInput: readonly string[], edits: readonly Edit[]): string[] {
const baseline = lines(baselineInput),
output: string[] = [];
if (edits.length > MAX_LINES * 2) throw new Error('The script is too large.');
let consumed = 0;
for (const edit of edits) {
lines([edit.text]);
const reads = edit.kind === 'keep' || edit.kind === 'delete';
const writes = edit.kind === 'keep' || edit.kind === 'insert';
if (
(!reads && !writes) ||
(reads
? edit.before !== consumed || baseline[consumed] !== edit.text
: edit.before !== null) ||
(writes ? edit.after !== output.length : edit.after !== null)
)
throw new Error('Script does not match this baseline or its positions.');
if (reads) consumed++;
if (writes) output.push(edit.text);
}
if (consumed !== baseline.length) throw new Error('Script did not consume the whole baseline.');
return lines(output);
}
export function demo(): string {
const before = parseTranscript('The tide turns.\nWe wait.\nThen we leave.');
const after = parseTranscript('The tide turns.\nWe listen.\nThen we leave.');
const plan = diff(before, after);
const revised = apply(before, plan.edits);
return `${plan.distance} edits; ${revised.length} output lines\n${revised.join('\n')}`;
}
console.log(demo());
Go
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
const MaxLines = 64
const MaxLineSymbols = 80
// -1 means the row has no position on that side.
type Edit struct {
Kind string
Before, After int
Text string
}
type Reach struct {
D, K, FromK int
Move string
StartX, StartY, X, Y int
}
type Difference struct {
Before, After []string
Distance int
Edits []Edit
Layers [][]Reach
}
func Lines(input []string) ([]string, error) {
if len(input) > MaxLines {
return nil, fmt.Errorf("use at most 64 lines per transcript")
}
for _, line := range input {
if !utf8.ValidString(line) {
return nil, fmt.Errorf("use well-formed Unicode")
}
if utf8.RuneCountInString(line) > MaxLineSymbols {
return nil, fmt.Errorf("keep each line to 80 Unicode scalar values")
}
if strings.ContainsAny(line, "\r\n") {
return nil, fmt.Errorf("a line cannot contain CR or LF")
}
}
return append([]string{}, input...), nil
}
func ParseTranscript(text string) ([]string, error) {
if len(text) > MaxLines*(MaxLineSymbols*4+2) {
return nil, fmt.Errorf("transcript text is too large")
}
if text == "" {
return []string{}, nil
}
return Lines(strings.SplitN(strings.ReplaceAll(text, "\r\n", "\n"), "\n", MaxLines+1))
}
func Diff(beforeInput, afterInput []string, tie string) (Difference, error) {
before, err := Lines(beforeInput)
if err != nil {
return Difference{}, err
}
after, err := Lines(afterInput)
if err != nil {
return Difference{}, err
}
if tie != "insert" && tie != "delete" {
return Difference{}, fmt.Errorf("choose insert or delete for equal reach")
}
layers := [][]Reach{}
previous := map[int]Reach{}
for d := 0; d <= len(before)+len(after); d++ {
layer := []Reach{}
current := map[int]Reach{}
for k := -d; k <= d; k += 2 {
removed, hasRemoved := previous[k-1]
added, hasAdded := previous[k+1]
canDelete := hasRemoved && removed.X < len(before)
canInsert := hasAdded && added.Y < len(after)
step := Reach{D: d, K: k, Move: "start"}
if d > 0 {
if !canDelete && !canInsert {
continue
}
if canDelete && (!canInsert || removed.X+1 > added.X || (removed.X+1 == added.X && tie == "delete")) {
step.X, step.Y, step.FromK, step.Move = removed.X+1, removed.Y, k-1, "delete"
} else {
step.X, step.Y, step.FromK, step.Move = added.X, added.Y+1, k+1, "insert"
}
}
step.StartX, step.StartY = step.X, step.Y
for step.X < len(before) && step.Y < len(after) && before[step.X] == after[step.Y] {
step.X++
step.Y++
}
layer = append(layer, step)
current[k] = step
if step.X == len(before) && step.Y == len(after) {
layers = append(layers, layer)
return Difference{before, after, d, recoverEdits(before, after, layers, step), layers}, nil
}
}
layers = append(layers, layer)
previous = current
}
return Difference{}, fmt.Errorf("a complete edit path must exist")
}
func recoverEdits(before, after []string, layers [][]Reach, node Reach) []Edit {
reversed := []Edit{}
for {
x, y := node.X, node.Y
for x > node.StartX && y > node.StartY {
x--
y--
reversed = append(reversed, Edit{"keep", x, y, before[x]})
}
if node.Move == "delete" {
reversed = append(reversed, Edit{"delete", node.StartX - 1, -1, before[node.StartX-1]})
}
if node.Move == "insert" {
reversed = append(reversed, Edit{"insert", -1, node.StartY - 1, after[node.StartY-1]})
}
if node.D == 0 {
break
}
for _, previous := range layers[node.D-1] {
if previous.K == node.FromK {
node = previous
break
}
}
}
for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 {
reversed[i], reversed[j] = reversed[j], reversed[i]
}
return reversed
}
func Apply(baselineInput []string, edits []Edit) ([]string, error) {
baseline, err := Lines(baselineInput)
if err != nil {
return nil, err
}
if len(edits) > MaxLines*2 {
return nil, fmt.Errorf("the script is too large")
}
output := []string{}
consumed := 0
for _, edit := range edits {
if _, err := Lines([]string{edit.Text}); err != nil {
return nil, err
}
reads := edit.Kind == "keep" || edit.Kind == "delete"
writes := edit.Kind == "keep" || edit.Kind == "insert"
valid := reads || writes
if reads {
valid = valid && edit.Before == consumed && consumed < len(baseline) && baseline[consumed] == edit.Text
} else {
valid = valid && edit.Before == -1
}
if writes {
valid = valid && edit.After == len(output)
} else {
valid = valid && edit.After == -1
}
if !valid {
return nil, fmt.Errorf("script does not match this baseline or its positions")
}
if reads {
consumed++
}
if writes {
output = append(output, edit.Text)
}
}
if consumed != len(baseline) {
return nil, fmt.Errorf("script did not consume the whole baseline")
}
return Lines(output)
}
func Demo() (string, error) {
before, err := ParseTranscript("The tide turns.\nWe wait.\nThen we leave.")
if err != nil {
return "", err
}
after, err := ParseTranscript("The tide turns.\nWe listen.\nThen we leave.")
if err != nil {
return "", err
}
plan, err := Diff(before, after, "insert")
if err != nil {
return "", err
}
revised, err := Apply(before, plan.Edits)
if err != nil {
return "", err
}
return fmt.Sprintf("%d edits; %d output lines\n%s", plan.Distance, len(revised), strings.Join(revised, "\n")), nil
}
func main() {
output, err := Demo()
if err != nil {
panic(err)
}
fmt.Println(output)
}
Build UIs?Your test runner prints one of these every day. A compare-versions view is where you own one.
Where it already is in your components
Fail a toEqual on two objects and Vitest prints both values line by line,
then marks the lines that changed; fail it on two strings and it marks the characters.
Both go through Jest’s diff-sequences, which Vitest bundles and which, in its
own words, “implements the linear space variation in An O(ND) Difference Algorithm and Its
Variations by Eugene W. Myers” (source). That is the refinement section 06 sets aside; this lesson keeps the frontier version
so it can show its work.
Your lists are the contrast. When a list changes, neither React nor Svelte computes a shortest edit script. They match items by key. Leave keys out and React uses each item’s index, and an unkeyed Svelte each block updates items in place by position. That is the comparison section 01 started with, line 1 against line 1: insert an item at the top and state such as a half-typed input stays at its position while the data under it shifts.
When you have to own it
A compare-versions view is where it lands on you: this lesson’s caption review panel, or a CMS that shows a draft against the published page. The lesson’s TypeScript runs in the browser as it is; the lab on this page does exactly that, with each version capped at 8 lines so the frontier stays small.
Keys need the identity the repeated captions lacked. Key review rows by their text and the
two Listen. rows collide: React warns Encountered two children with the same key, `Listen.`, and with the default
tie’s rows Svelte 5.57 throws Keyed each block has duplicate key `Listen.` at indexes 0 and 2 in development. Key them by position in the computed script instead, and let that identity reset
whenever the script changes.
08 / Make the call
The smallest count is one objective.
A shortest script minimizes additions and removals under exact line equality. It does not minimize the number of visual groups, infer semantic changes, detect moves as a primitive, or guarantee the easiest patch for a reviewer to read. Grouping context and choosing presentation rules belong above the search, which is exactly where Git puts its indent heuristic.
For this bounded teaching editor, retaining the trace buys an explanation. For larger documents, consider a linear-space implementation, a work limit, cancellation, and review-oriented grouping. If timestamps or stable caption IDs must stay associated with text, make those fields part of the application’s requirements before treating a line diff as the whole solution.
Primary sources and real diff toolingAlgorithm, implementation scope, and presentation policy
Eugene Myers’s 1986 paper develops the edit-graph frontier method and explains recovering a shortest script from saved frontiers. It also presents a linear-space refinement. Our bounded implementation records predecessors explicitly, keeps only in-bounds states, and exposes its equal-reach choice for inspection.
Git’s diff documentation names Myers the default and also offers minimal, patience, and histogram, alongside the on-by-default indent heuristic that shifts hunk boundaries so patches read better: search first, presentation second. Our code follows the search, not Git’s exact output.
Shared native fixtures cover empty sequences, blank lines, final newlines, repeated text, both tie choices, Unicode, and full input limits. An independent insertion/deletion dynamic-programming oracle checks short pairs exhaustively. Replaying a script establishes its effect; matching the oracle establishes its minimum cost for those checked cases. Sources checked 11 September 2026.
09 / Take the idea with you
Explain a diff without saying “Myers.”
“Walk both versions from the top. Equal lines are free, so take them whenever you can. When the lines differ, spend one edit, a removal or an addition, and remember only the furthest point each edit count can reach. The first time a path reaches the end of both versions, no shorter one exists. Then walk back to list what was kept, removed, and added.”
Ask what the sequence elements are, which operations cost one, and what equality means. Then ask whether you need a shortest transformation, an explanation of editing history, or a human-friendly review. Myers supplies the first; the surrounding system has to earn the others.
Next time you read a git diff, notice both halves: the search that kept every
line it could, and the hunk boundaries Git moved so you could read it.
Connections to follow nextRelated lessons
- Levenshtein distance counts edits between names, substitutions included.
- Jaro–Winkler similarity measures how alike two names look instead of recovering a script.
- Stack is where editing history lives, the part a diff of two snapshots can’t see.