← Applied algorithms
Text, names, and changes From two versions to an explainable patch

Myers diff

Find what can stay.

Unless you have changed it, every git diff you read starts here. Git’s documentation names Myers, “the basic greedy diff algorithm”, as its default, and then shifts the hunk boundaries so the patch reads better. First a search finds everything that can stay; then a presentation step makes the result easy on the eyes.

We’ll follow that search through something smaller than a repository: a caption transcript with one corrected line. The review should keep the surrounding text, show only the work needed to reach the new version, and let you see where another equally short explanation exists.

TypeScriptGoOne transcript revision in each language

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.

Before

We wait.

Consume the old line without copying it into the revision.

After

We listen.

Write the new line into the reconstructed output.

Cost

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.

Follow the example in your languages.

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.

Myers diff

Find what can stay.

Saved transcript

  1. 1The tide turns.
  2. 2We wait.
  3. 3Then we leave.

Proposed revision

  1. 1The tide turns.
  2. 2We listen.
  3. 3Then we leave.
Whole lines are the comparison units.

A changed sentence needs one removal and one addition. Equal surrounding lines can stay.

01/ 04
Read the versions

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.

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.

Does a shortest script reveal the editing history?

The saved lines are Listen. / Wait. / Listen.. The revision is Listen. / Listen. / Wait.. Changing the equal-reach tie can keep different occurrences, while both scripts still cost two edits.

06 / Follow the cost

A small edit count keeps the frontier narrow.

Myers diff: time and extra space, for S lines in all and D edits
OperationTimeExtra spaceWhat it assumes
Search the frontierO(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 scriptO(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 baselineO(S)O(S)Check each row’s position and text, consume the whole baseline, and build a new output.
Linear-space refinementO(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.

TypeScriptChoose the comparison units
transcript.ts
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));
}
GoChoose the comparison units
transcript.go
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.

TypeScriptApply only to the matching baseline
transcript.ts
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);
}
GoApply only to the matching baseline
transcript.go
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.

TypeScriptCompare, apply, and observe the result
transcript.ts
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')}`;
}
GoCompare, apply, and observe the result
transcript.go
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.

TypeScriptComplete transcript comparison
transcript.ts
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());
GoComplete transcript comparison
transcript.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)
}

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.

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

Copy the complete example, repeat one caption line in the revised transcript, and predict the edit count and which occurrence the script keeps before you run it.

Back to applied algorithms →