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

Longest Common Subsequence & the Survival Score

Longest Common Subsequence 30 minFill a two-dimensional table to find the longest sequence two versions share, in order
You're building a piece ofQuarry — version history
This piece — similarity(): Measures how much of a document survived from one revision to the next.
Scenario Quarry's history panel shows how much of a document survived each edit. The survival score counts the lines two revisions share, in order, and turns that into a 0-to-1 number.
Your task
Build similarity(old, new). Both are lists of lines. Return {"common": k, "score": s}, where k is the length of the longest common subsequence of the two lists and s = 2 * k / (len(old) + len(new)) rounded to 3 decimal places. Two empty versions are identical and score 1.0. Example: similarity(["title", "intro", "body", "outro"], ["title", "note", "intro", "body", "outro"]) → {"common": 4, "score": 0.889}.

Longest Common Subsequence & the Survival Score

In short: the longest common subsequence (LCS) of two sequences is the longest list of items that appear in both, in the same order, but not necessarily side by side. A two-dimensional table, table[i][j] = the LCS length of the first i items of a and the first j items of b, fills by comparing the last item of each prefix: a match extends the diagonal by one, a miss carries the larger of the cell above or to the left. The bottom-right cell is the answer, and the diagonal steps taken to get there spell out the subsequence itself.

A three-box staircase labelled R, A, E connected by diagonal arrows, with a greyed dashed box off to the side marked C not-equal T showing a miss just carries the larger neighbour

Quarry keeps every revision of every document. When someone opens the history panel, the first question is simple: how much of the old version survived into the new one? A new paragraph in the middle shouldn't count as "everything after it changed", and a moved sentence shouldn't count as nothing changed. The measure that gets this right is the longest common subsequence (LCS): the longest list of lines that appear in both versions in the same order, not necessarily side by side.

This lesson fills the first real two-dimensional DP table in the course. Every diff you have ever read came out of a table like this one.

Why position-by-position fails

The obvious comparison lines the two versions up by index:

What does this print?
old = ["title", "intro", "body", "outro"]
new = ["title", "note", "intro", "body", "outro"]
same = sum(1 for a, b in zip(old, new) if a == b)
print(same)

A subsequence ignores position and keeps order. title, intro, body, outro appears in both lists in that order, so the survival count should be 4.

The recurrence

Let a and b be the two sequences. Define one cell:

table[i][j] = the LCS length of the first i items of a and the first j items of b.

Look at the last item of each prefix, a[i-1] and b[j-1].

  • They match. Then that item can end the common subsequence, and the rest is the best answer for both prefixes without it: table[i-1][j-1] + 1.
  • They don't. Then at least one of the two can't be the last item of the common subsequence. Either drop a's last item or drop b's, and keep whichever is better: max(table[i-1][j], table[i][j-1]).

Row 0 and column 0 compare against an empty prefix, so they are all 0. Every cell depends on the cell above, the cell to the left and the diagonal, so filling row by row, left to right, always has the inputs ready.

python

Letters stand in for lines here, because they fit in a picture. Rows are CRATE, columns are TRACE. Watch the arrows: a diagonal arrow at a match, and a straight arrow from the larger neighbour otherwise.

watch it move · grid

A match copies the diagonal plus one. A miss takes the larger of up and left. The bottom-right cell is the LCS length.

Row 0 and column 0 compare against an empty prefix, so they are all 0 before any work starts. 6 by 6 table. At row 0, column 0 — value 0. 11 of 36 cells filled.

1 / 11

Filling m × n cells at constant work each costs O(m · n) time and, with the full table, O(m · n) space. Brute force would compare each of the 2ᵐ subsequences of a against b.

Step through the fill for two tiny words and watch which branch each cell takes:

step through it
1a, b = "AB", "BA"
2table = [[0] * 3 for _ in range(3)]
3for i in range(1, 3):
4 for j in range(1, 3):
5 if a[i - 1] == b[j - 1]:
6 table[i][j] = table[i - 1][j - 1] + 1
7 else:
8 table[i][j] = max(table[i - 1][j], table[i][j - 1])
9print(table[2][2])

Order matters, even when the items are the same:

What does this print?
def lcs(a, b):
    t = [[0] * (len(b) + 1) for _ 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]:
                t[i][j] = t[i - 1][j - 1] + 1
            else:
                t[i][j] = max(t[i - 1][j], t[i][j - 1])
    return t[-1][-1]

print(lcs("RAT", "TAR"))

Here is a version that gets the miss case wrong:

broken — fix it

A and B survive from AXB into AB, so the answer is 2, but this prints 1. Fix the miss case.

❓ Cross-question: "Isn't this the longest common substring?" No. A substring must be contiguous, and one inserted line breaks it. For "title, intro, body" against "title, note, intro, body", the longest common run of adjacent lines is 2 (intro, body), while the LCS is 3. The substring version has its own table, where a miss resets the cell to 0 instead of carrying the best neighbour.

Your own fill, drawn

Change the two strings and run it. The table redraws from your own loop, with the letters of a down the side and b across the top. Try two identical words, a word against its reverse, and a word against an empty string.

your code, drawn · grid

Your own table, cell by cell. The first row and column are the two words; the corner is the LCS length.

Why brute force was never an option

Brute force checks all 2ᵏ subsequences of a k-line document, while the table has about k² cells:

lines k subsequences (2ᵏ) table cells (k²)
10 1,024 100
20 1,048,576 400
30 1,073,741,824 900

Twenty lines already pass a million subsequences. A real file has thousands of lines, so brute force is out of reach and the table is the only option.


Idioms & real-world patterns

A similarity score

LCS length alone depends on document size. Dividing by the average length turns it into a score between 0 and 1: 2 · common / (len(old) + len(new)). Two identical versions score 1, and two versions with nothing in common score 0. That's the survival score you're about to build, and Python's own difflib reports a ratio of the same shape.

Sequences of anything

Nothing in the recurrence cares what the items are. Letters, lines, words, tokens and records all work, as long as == means "the same item". Quarry compares lines. A spelling checker compares letters. A version-control merge compares lines from three files, and the capstone exercise below adds that third dimension.

⚡ Advanced: one row is enough for the length

Each row reads only the row above it. Keep two rows and the memory drops from O(m · n) to O(n). The price is that the table is gone, and with it every arrow you'd need to recover which lines are common. Lesson 3 makes that trade explicit, and lesson 4 gets the lines back without paying for the whole table.

⚡ Advanced: shortest common supersequence

The shortest sequence that contains both a and b as subsequences writes every shared item once: len(a) + len(b) − LCS. The build exercise is that formula, and it's a common interview disguise for LCS.


Quick practice

What does this print?
def lcs_len(a, b):
    t = [[0] * (len(b) + 1) for _ 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]:
                t[i][j] = t[i - 1][j - 1] + 1
            else:
                t[i][j] = max(t[i - 1][j], t[i][j - 1])
    return t[-1][-1]

print(lcs_len("AGCAT", "GAC"))
broken — fix itIndexError

This crashes with IndexError before it can print anything. The LCS of "cat" and "cot" is "ct", length 2. Fix the two indices.

🎯 Your turn

Write similarity(old, new). Both are lists of lines. Return {"common": k, "score": s}, where k is the LCS length and s = 2 · k / (len(old) + len(new)), rounded to 3 decimal places. Two empty versions are identical, so they score 1.0.

  • similarity(["title", "intro", "body", "outro"], ["title", "note", "intro", "body", "outro"]) → {"common": 4, "score": 0.889}
  • similarity([], []) → {"common": 0, "score": 1.0}

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-upThe whole table

Write lcs_table(a, b) returning the entire LCS table as a list of lists: (len(a) + 1) rows of (len(b) + 1) numbers, where table[i][j] is the LCS length of a[:i] and b[:j]. lcs_table("AB", "B") → [[0, 0], [0, 0], [0, 1]].

dptabulation
DrillJust the length, two rows

Write lcs_length(a, b): the LCS length of two strings or lists, keeping only two rows of the table at any time. lcs_length("RECORD", "CORNER") → 3.

dpspace-complexity
BuildOne string that holds both

Two editors each wrote a tag, and Quarry wants the shortest string that contains both tags as subsequences. Write merge_length(a, b) returning its length. merge_length("shard", "hard") → 5 ("shard" already contains "hard").

dpstrings
BossThe mirrored core

Quarry flags record keys that read the same both ways. Write mirror_length(s): the length of the longest subsequence of s that is a palindrome. mirror_length("quarry") → 2 ("rr"), mirror_length("character") → 5 ("carac").

dynamic-programmingstrings
CapstoneThe shared core of three revisions

A three-way merge starts from what all three versions share. Write common_to_three(a, b, c): the length of the longest subsequence common to all three lists (or strings). common_to_three("quarry", "query", "quay") → 3 ("quy").

dynamic-programmingnested-loops
ExplainWhy up or left, never the diagonal

On a mismatch the LCS cell takes max(table[i-1][j], table[i][j-1]). A classmate asks why it doesn't take the diagonal table[i-1][j-1] instead. Explain why the diagonal would be wrong and why the two straight neighbours are enough.

similarity.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
similarity(old, new) → dictHow many lines survived between two revisions, and a 0-to-1 score.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.