Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Sequence DP: the Diff Engine  ›  Lesson

Edit Distance & Typo Tolerance

Edit Distance 30 minCount the fewest inserts, deletes and replacements between two strings, and what two rows can and can't tell you
You're building a piece ofQuarry — version history
This piece — suggest(): Suggests the closest known term when a search is misspelt.
Scenario Someone searches Quarry for a field that doesn't exist. Typo tolerance finds the closest known name within a few edits and offers it as a suggestion.
Your task
Build suggest(term, vocabulary, limit). Return the word in vocabulary with the smallest edit distance (fewest single-character inserts, deletes and replacements) to term, as long as that distance is at most limit. If two words tie, the one that appears first in vocabulary wins. If no word is within limit, return None. Example: suggest("shrad", ["shard", "share", "shared"], 2) → "shard".

Edit Distance & Typo Tolerance

In short: edit distance (Levenshtein distance) is the fewest single-character inserts, deletes and replacements that turn one string into another. It fills the same shape of table as LCS, but each cell now has three possible parents instead of two: delete (from above), insert (from the left), and keep-or-replace (the diagonal). On a match the diagonal is free; on a mismatch every option costs one more than its parent, and the cell takes the cheapest.

A cell with three arrows pointing into it, labelled insert, delete, replace, with the cheapest (replace, the diagonal) shown in bold and the formula 1 + min(delete=2, insert=1, replace=0) = 1

Someone types shrad into Quarry's search box. There is no field called shrad, but there is one called shard, and a good engine says did you mean shard? instead of returning nothing. To make that call, Quarry needs a number for how far apart two strings are. The standard answer is the edit distance (also called Levenshtein distance): the fewest single-character inserts, deletes and replacements that turn one string into the other.

It's the LCS table from the last lesson with one more choice per cell. That extra choice changes what the table can tell you, and what it costs to throw the table away.

Three ways to finish

Define one cell:

d[i][j] = the fewest edits that turn the first i characters of a into the first j characters of b.

Think about the last step of the cheapest edit sequence:

  • Keep. If a[i-1] == b[j-1], the last characters already agree, and nothing needs to change: d[i-1][j-1].
  • Replace a[i-1] with b[j-1]: d[i-1][j-1] + 1.
  • Delete a[i-1], then solve the rest: d[i-1][j] + 1.
  • Insert b[j-1] at the end: d[i][j-1] + 1.

On a mismatch, take the smallest of the last three. The edges aren't zeros any more. Turning i characters into nothing takes i deletes, so column 0 is 0, 1, 2, …, and building j characters from nothing takes j inserts, so row 0 is too.

python

Filling (m + 1) × (n + 1) cells at constant work each is O(m · n) time, and the full table costs O(m · n) space to store.

Here's the table for SORT into STORE. Each cell compares its three neighbours, and the arrow shows which one it took:

watch it move · grid

Up is a delete, left is an insert and the diagonal is a keep or a replace. Every cell picks the cheapest of the three.

Row 0 builds STORE's prefixes from nothing, one insert per letter. Column 0 deletes SORT's prefixes, one delete per letter. 5 by 6 table. At row 0, column 5 — value 5. 10 of 30 cells filled.

1 / 11
What does this print?
def edit_distance(a, b):
    d = [[max(i, j) if i * j == 0 else 0 for j in range(len(b) + 1)] for i in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                d[i][j] = d[i - 1][j - 1]
            else:
                d[i][j] = 1 + min(d[i - 1][j], d[i][j - 1], d[i - 1][j - 1])
    return d[-1][-1]

print(edit_distance("index", "indent"))

Replace is what LCS was missing

If only inserts and deletes are allowed, a changed character costs 2, one delete and one insert. That version is exactly the LCS in disguise: every character not in the common subsequence is deleted from a or inserted from b, so the distance is len(a) + len(b) − 2 · LCS. Replacement lets one edit do the work of two:

What does this print?
a, b = "cat", "cut"
lcs = 2                                   # c, t
indel = len(a) + len(b) - 2 * lcs
lev = 1                                   # replace a with u
print(lev, indel)

Two rows are enough, for the number

Each row reads only the row above and the cell to its left. Keep two rows, prev and row, and the memory falls from O(m · n) to O(n):

step through it
1a, b = "ab", "b"
2prev = [0, 1]
3for i in range(1, len(a) + 1):
4 row = [i, 0]
5 if a[i - 1] == b[0]:
6 row[1] = prev[0]
7 else:
8 row[1] = 1 + min(prev[1], row[0], prev[0])
9 prev = row
10print(prev[-1])

Watch what that does to the picture. The same fill, in two rows:

watch it move · grid

With two rolling rows the distance is still right, but every older row is overwritten. The arrows had nowhere to live, so the edit sequence can't be recovered.

prev starts as row 0 of the full table: 0 to 5 inserts. 2 by 6 table. At row 0, column 0 — value 0. 6 of 12 cells filled.

1 / 6

That's the whole trade. Suggesting a spelling needs only the number, so two rows are fine. Showing the user what changed needs the path, and the path needs the rows. Lesson 4 shows how to get the path back in linear space anyway.

The two-row version has one easy mistake to make. Column 0 of every row is not 0:

broken — fix it

SHARD becomes HARD by deleting S, so the answer is 1, but this prints 0. Fix how each new row starts.

❓ Cross-question: "Is it a knapsack?" It has the same shape: every cell is a choice between options, each built on a smaller answer, and you keep the best one. Knapsack chooses take or skip under a budget. You built that in section 6 as the best-playlist packer, and it returns in Planning DP. Edit distance chooses between keep, replace, delete and insert. Recognising "a choice per cell, the best of smaller answers" is how you spot a DP.

Your own fill, drawn

Change the two words and run it. Each cell says which edit it chose:

your code, drawn · grid

Your own table. The first row and column are the words, and every cell shows the cheapest way to finish.

How big can the table get?

Two documents of n characters each: the full table has (n + 1)² cells, and two rows have 2(n + 1).

n full table two rows
1,000 1,002,001 2,002
10,000 100,020,001 20,002
20,000 400,040,001 40,002

At 10,000 characters, which is a few pages of text, the full table has about 100 million cells, several hundred megabytes in Python. The distance is cheap to compute; the full table is what's expensive.


Idioms & real-world patterns

Early exit with a limit

Quarry only suggests a word within limit edits. If every cell in a row is already above limit, no later row can come back down, because distances never decrease along a path. So you can stop early. The main task doesn't require it, but it's the first optimisation a real engine adds.

One edit apart, in linear time

"Are these two strings at most one edit apart?" doesn't need a table at all. Walk both with two pointers until the first mismatch, spend your one edit there, and check that the rest matches. That's the build exercise below, and a classic interview question.

⚡ Advanced: other price lists

Nothing forces every edit to cost 1. A keyboard-aware checker makes replacing a key with its neighbour cheap. A DNA aligner charges gaps differently from mismatches. Change the + 1s to prices and the same table works, which is the boss exercise. Adding "swap two neighbouring letters" as a fourth move, as in typing shrad for shard, is the capstone.


Quick practice

What does this print?
def edit_distance(a, b):
    m, n = len(a), len(b)
    d = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        d[i][0] = i
    for j in range(n + 1):
        d[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                d[i][j] = d[i - 1][j - 1]
            else:
                d[i][j] = 1 + min(d[i - 1][j], d[i][j - 1], d[i - 1][j - 1])
    return d[m][n]

print(edit_distance("kitten", "sitting"))
broken — fix it

Turning an empty string into "abc" takes 3 inserts, but this prints 0. Fix the missing edge initialisation.

🎯 Your turn

Write suggest(term, vocabulary, limit). Return the word in vocabulary with the smallest edit distance to term, as long as that distance is at most limit. If two words tie, the one that appears first in vocabulary wins. If no word is close enough, return None.

  • suggest("serch", ["search", "select", "set"], 2) → "search"
  • suggest("shrad", ["shard", "share", "shared"], 2) → "shard" (2 edits; shared is also 2, but comes later)
  • suggest("xyz", ["shard"], 2) → None

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green means this piece of the app is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upInserts and deletes only

Write indel_distance(a, b): the fewest single-character inserts and deletes (no replacements) that turn a into b. Fill the table yourself. indel_distance("cat", "cut") → 2.

dptabulation
DrillThe full distance

Write edit_distance(a, b): the fewest single-character inserts, deletes and replacements that turn a into b. edit_distance("index", "indent") → 2.

dpstrings
BuildAt most one edit

Quarry dedupes near-identical tags. Write one_edit_apart(a, b): True if a can become b with at most one insert, delete or replacement. Do it in one pass, with no table. one_edit_apart("shard", "shards") → True, one_edit_apart("shard", "sharp!") → False.

two-pointersstrings
BossA price for every edit

Quarry's query rewriter charges different prices: inserting a character costs insert_cost, deleting costs delete_cost and replacing costs replace_cost. Write priced_distance(a, b, insert_cost, delete_cost, replace_cost) returning the cheapest total. priced_distance("cat", "cut", 1, 1, 3) → 2 (delete a, insert u beats one replacement).

dynamic-programmingstrings
CapstoneFat fingers

Typing shrad for shard is one slip, not two. Write swap_aware_distance(a, b): the edit distance where swapping two neighbouring characters also counts as one edit (each character may be involved in at most one swap, and swapped characters aren't edited again). swap_aware_distance("shrad", "shard") → 1.

dynamic-programmingstrings
ExplainThe number without the path

Quarry's typo checker computes edit distance with two rows. A product manager asks for it to also show which letters were changed. Explain why the two-row version can't do that, and what it would take.

suggest.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
suggest(term, vocabulary, limit) → str | NoneThe closest known word within limit edits, or None.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.