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

Reconstructing the Answer & the Edit Script

Reconstructing the Answer 30 minWalk a finished table backwards to recover the answer itself, and do it in linear space
You're building a piece ofQuarry — version history
This piece — edit_script(): Turns the finished table into the exact list of keeps, deletes and inserts.
Scenario The history panel has to show what changed, not just how much. The edit script walks the finished LCS table back from its corner and lists every kept, deleted and inserted line.
Your task
Build edit_script(old, new). Both are lists of lines. Return a list of [op, line] pairs, where op is "=" (kept), "-" (deleted from old) or "+" (inserted from new), keeping as many lines as possible. Fill the full LCS table, then walk back from the bottom-right: on a match go diagonal and emit "="; otherwise go left and emit "+" when table[i][j-1] >= table[i-1][j], else go up and emit "-". When one index reaches 0, the rest are all inserts or all deletes. Reverse at the end. Example: edit_script(["a", "x", "c"], ["a", "y", "c"]) → [["=", "a"], ["-", "x"], ["+", "y"], ["=", "c"]].

Reconstructing the Answer & the Edit Script

In short: a DP table's final cell only gives a number. To get the actual answer — which items were kept, inserted or deleted — walk back from that cell to (0, 0), replaying at each step which neighbour it was built from: a diagonal match is kept, otherwise the walk goes left (insert) or up (delete) depending on which neighbour holds the table's value. The walk runs backwards, so reverse the emitted list at the end. Hirschberg's trick gets the same path in O(n) space by never keeping the whole table at all.

Seven boxes in a staircase, each labelled with an emitted operation (=E, +C, -T, =A, =R, +T, -C) connected by arrows pointing from the corner back to the start

The survival score says 4 of 5 lines survived, but not which 4, and a history panel that can't show what changed isn't much use. Quarry needs an edit script: an ordered list of keep, delete and insert that turns the old version into the new one.

Every number in a DP table was chosen from a neighbour. Walk those choices backwards from the final cell and you recover the answer itself, not just its size, and with one more idea you can do it without storing the table.

Walking back from the corner

Take the LCS table from lesson 2, for CRATE against TRACE. Start at the bottom-right cell (i, j) = (5, 5) and repeat until you reach (0, 0):

  • If a[i-1] == b[j-1], that item is kept. Emit = and step diagonally.
  • Otherwise, if moving left keeps the same value (table[i][j-1] >= table[i-1][j]), then b[j-1] was inserted. Emit + and step left.
  • Otherwise a[i-1] was deleted. Emit - and step up.
  • Once i or j reaches 0, only one direction is left: the remaining items of b are inserts, or the remaining items of a are deletes.

The walk emits the script backwards, so reverse it at the end.

watch it move · grid

Walking back from the corner replays every choice the fill made. Diagonals are kept items, left steps are inserts and up steps are deletes.

Start in the corner, which holds 3. E equals E, so E was kept. Emit = E and step diagonally. 6 by 6 table. At row 5, column 5 — value 3. 36 of 36 cells filled.

1 / 8
python

The walk visits at most m + n cells, so it costs O(m + n) on top of the fill. Filling the table is the same O(m · n) time as lesson 2, and keeping the whole table to walk back over it costs O(m · n) space — the price this lesson pays for the extra arrows, until Hirschberg's trick below. The expensive part is keeping the table.

Step through a tiny walk and watch i and j fall:

step through it
1old, new = ["a", "b"], ["b", "c"]
2t = [[0, 0, 0], [0, 0, 0], [0, 1, 1]]
3script, i, j = [], 2, 2
4while i > 0 or j > 0:
5 if i > 0 and j > 0 and old[i - 1] == new[j - 1]:
6 script.append("=" + old[i - 1])
7 i, j = i - 1, j - 1
8 elif j > 0 and (i == 0 or t[i][j - 1] >= t[i - 1][j]):
9 script.append("+" + new[j - 1])
10 j -= 1
11 else:
12 script.append("-" + old[i - 1])
13 i -= 1
14print(script[::-1])
What does this print?
old, new = ["a", "b"], ["b", "c"]
t = [[0, 0, 0], [0, 0, 0], [0, 1, 1]]
script, i, j = [], 2, 2
while i > 0 or j > 0:
    if i > 0 and j > 0 and old[i - 1] == new[j - 1]:
        script.append("=" + old[i - 1])
        i, j = i - 1, j - 1
    elif j > 0 and (i == 0 or t[i][j - 1] >= t[i - 1][j]):
        script.append("+" + new[j - 1])
        j -= 1
    else:
        script.append("-" + old[i - 1])
        i -= 1
print(script[0])

Why ties go left

A replaced line is a delete plus an insert, and both orders are equally short. Which one you get depends only on how you break the tie. Walking backwards and preferring the insert means the insert is emitted first, so after the reverse it comes second. That gives the order every diff reader expects: the old line, then its replacement.

What does this print?
old, new = ["shards=2"], ["shards=4"]
t = [[0, 0], [0, 0]]
out, i, j = [], 1, 1
while i > 0 or j > 0:
    if i > 0 and j > 0 and old[i - 1] == new[j - 1]:
        out.append("=" + old[i - 1]); i -= 1; j -= 1
    elif j > 0 and (i == 0 or t[i][j - 1] >= t[i - 1][j]):
        out.append("+" + new[j - 1]); j -= 1
    else:
        out.append("-" + old[i - 1]); i -= 1
print(out[::-1])

Here's a walk with the wrong loop condition:

broken — fix it

Turning [a, b] into [b] deletes a, but the script leaves that out because the loop stops as soon as one index hits 0. Make the walk run until both are 0.

Parent pointers: the same idea in one dimension

Lesson 1's table works the same way. Store, for each position, the earlier position its best run extended (a parent pointer), then follow parents back from the largest best. That's the warmup exercise: the tails row gives the length, and parent pointers give the run.

Hirschberg: the path without the table

Two rows are enough for the length, but the walk needs every row, and two 100,000-line files make a table of 10¹⁰ cells. Hirschberg's algorithm recovers the path in linear space by divide and conquer:

  1. Split a at its middle row, mid = len(a) // 2.
  2. Run the two-row LCS forward over a[:mid] against b. Its last row says, for each j, how much of b[:j] the top half can match.
  3. Run it backward over a[mid:] against b, both reversed. That says how much of b[j:] the bottom half can match.
  4. The best path crosses the middle row at the j where the two add up to the most. Take the smallest such j.
  5. Recurse on the top-left and bottom-right pieces. Neither piece can use the other two quadrants, so they are thrown away.
watch it move · grid

Hirschberg only ever holds two rows. It finds where the answer path crosses the middle row, discards two quadrants the path can't enter, and recurses on the other two.

Split CRATE after its middle: CR on top, ATE below. The only question is where the path crosses the line between row 2 and row 3. 6 by 6 table. 6 of 36 cells filled.

1 / 5
python

The two pieces together have half the cells of the rectangle they came from, so each level costs half the one above. Total time stays O(m · n), about twice a single fill, while memory drops to O(n) plus the recursion stack. The total is 1 + ½ + ¼ + …: 1.5 fills after two levels, 1.98 after seven, 1.99 after eight, and never 2. However deep the recursion goes, Hirschberg does at most twice the plain table's work, using two rows of memory.

Your own walk, drawn

Change a and b and run it. Your loop fills the table, then lights up each cell of the walk back as it emits an operation:

your code, drawn · grid

Your own walk, from the corner back to the start. Every lit cell is one operation of the edit script.


Idioms & real-world patterns

Alignments

The same walk over an edit-distance table gives an alignment: both strings one above the other with - in the gaps, the way spell checkers and gene comparisons show a difference. The build exercise produces one.

⚡ Advanced: tie rules are part of the contract

Most pairs of versions have several shortest scripts, and tie-breaking alone picks which one you print. Write the rule down and test it, or two implementations of the "same" diff will disagree.


Quick practice

What does this print?
def edit_script(old, new):
    m, n = len(old), len(new)
    t = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if old[i - 1] == new[j - 1]:
                t[i][j] = t[i - 1][j - 1] + 1
            else:
                t[i][j] = max(t[i - 1][j], t[i][j - 1])
    script, i, j = [], m, n
    while i > 0 or j > 0:
        if i > 0 and j > 0 and old[i - 1] == new[j - 1]:
            script.append(["=", old[i - 1]])
            i, j = i - 1, j - 1
        elif j > 0 and (i == 0 or t[i][j - 1] >= t[i - 1][j]):
            script.append(["+", new[j - 1]])
            j -= 1
        else:
            script.append(["-", old[i - 1]])
            i -= 1
    return script[::-1]

print(edit_script(["p", "q"], ["q", "p"]))
broken — fix it

x should become y, but this inserts another x instead. Fix the insert branch to read from new.

🎯 Your turn

Write edit_script(old, new). Both are lists of lines. Return the edit script as a list of [op, line] pairs, where op is "=" (kept), "-" (deleted from old) or "+" (inserted from new). Keep as many lines as possible (an LCS). Build the full LCS table and walk back from the corner with the rule from this lesson: a match goes diagonal; otherwise go left (insert) when table[i][j-1] >= table[i-1][j], else up (delete). Reverse at the end.

  • edit_script(["a", "x", "c"], ["a", "y", "c"]) → [["=", "a"], ["-", "x"], ["+", "y"], ["=", "c"]]
  • edit_script([], ["new"]) → [["+", "new"]]

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 run itself

Lesson 1 found the length of the longest strictly increasing subsequence. Write lis_sequence(nums) returning the run itself. Use the O(n²) table and store parent[i], the index each run extended. Tie rules: best[i] only changes on a strictly longer run (so the earliest j wins), and the run ends at the earliest index with the largest best. lis_sequence([4, 1, 6, 2, 7, 3, 8, 5]) → [4, 6, 7, 8].

dptabulation
DrillWhich letters survived

Write lcs_text(a, b) returning an actual longest common subsequence of two strings. Fill the full table and walk back from the corner: a match emits the letter and goes diagonal; otherwise go left when t[i][j-1] >= t[i-1][j], else up. lcs_text("CRATE", "TRACE") → "RAE".

dpstrings
BuildShow where they differ

Write align(a, b) returning an alignment: two strings of equal length, a and b with - inserted as gaps, such that the columns where the letters differ number exactly the edit distance. Fill the edit-distance table and walk back from the corner, preferring the diagonal (keep or replace) when it explains the cell, then up (a letter of a against a gap), then left (a gap against a letter of b). align("shard", "hard") → ["shard", "-hard"].

dpstrings
BossWhere the path crosses

Write middle_split(a, b), the heart of Hirschberg's algorithm. Let mid = len(a) // 2. Using only two rows at a time, compute forward[j] = LCS of a[:mid] and b[:j] for every j, and backward[k] = LCS of a[mid:] and the last k items of b. Return the smallest j that maximises forward[j] + backward[len(b) - j]. middle_split("CRATE", "TRACE") → 2.

dynamic-programmingspace-complexity
CapstoneThe answer in linear space

Write linear_lcs(a, b): Hirschberg's algorithm, returning an LCS string without ever storing the full table. Rules, so there is one right answer: if a is empty return ""; if a has one character return it when it occurs in b, else ""; otherwise split with mid = len(a) // 2 and the j from middle_split (smallest best j), and return linear_lcs(a[:mid], b[:j]) + linear_lcs(a[mid:], b[j:]). linear_lcs("CRATE", "TRACE") → "RAE".

dynamic-programmingrecursion
ExplainWhy the walk starts at the end

A teammate wants to reconstruct the edit script by walking forwards from cell (0, 0) of the LCS table instead of backwards from the corner. Explain why the backward walk is the natural one, and what the forward walk would need.

edit_script.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
edit_script(old, new) → listThe keeps, deletes and inserts that turn old into new.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.