DP on Strings

Compare two strings with a table of prefixes: find the longest common subsequence, then the fewest edits that turn one word into another.

18 minLesson 5 of 6
On this page
  1. Prefixes are the subproblems
  2. The five steps
  3. Same table, different question: edit distance
  4. Common mistakes
  5. Check yourself
  6. What to practise next

When you change a file and ask version control what is different, it does not compare line 1 with line 1 and give up after the first insert. It finds the largest set of lines that appear in both versions in the same order, even with other lines between them. It keeps those and reports everything else as added or removed.

Shrink the lines to letters and you get a puzzle you can do by hand. What is the longest sequence of letters that appears, in order, in both ROAD and BOARD? The letters do not have to sit next to each other, only keep their order. The answer is OAD, length 3. This is the longest common subsequence, or LCS.

Prefixes are the subproblems

A word of length 4 has 16 subsequences and one of length 20 has over a million, so listing them is out. Instead, look at the last letter of each word.

  • If they match, like the final D of ROAD and BOARD, use that letter. The rest of the answer is the LCS of what comes before: ROA and BOAR.
  • If they differ, they cannot both be the last letter of a shared sequence. So at least one of them is not used: drop the last letter of one word or of the other, and keep the better result.

Either way the question becomes the same question about shorter prefixes: the first i letters of one word and the first j of the other. That gives a table with one word down the side and the other across the top.

The five steps

  1. State. L[i][j] is the length of the LCS of the first i letters of a and the first j letters of b.
  2. Recurrence. If a[i − 1] == b[j − 1], then L[i][j] = L[i − 1][j − 1] + 1. Otherwise L[i][j] = max(L[i − 1][j], L[i][j − 1]).
  3. Base cases. L[0][j] = 0 and L[i][0] = 0: an empty word shares nothing.
  4. Order. Each cell reads up, left and diagonally up-left, as on a grid. Row by row, left to right.
  5. Answer. L[len(a)][len(b)], the bottom-right cell.

Note the shift: row i is about the letter a[i − 1]. The extra row and column at index 0 stand for the empty prefix, and they are what make the base cases this simple.

Longest common subsequence of ROAD and BOARDUse the arrows or ← → keys
L[i][0] = 0 and L[0][j] = 0for i from 1 to len(a):  for j from 1 to len(b):    if a[i-1] == b[j-1]:      L[i][j] = L[i-1][j-1] + 1    else:      L[i][j] = max(L[i-1][j], L[i][j-1])return L[len(a)][len(b)]
  1. L[i][j]
    ""BOARD
    ""000000
    R0
    O0
    A0
    D0

    1Base cases. If either word is cut down to nothing (the "" row and column), there is nothing in common: length 0. Rows are letters of ROAD, columns letters of BOARD.

  2. L[i][j]
    ""BOARD
    ""000000
    R00
    O0
    A0
    D0
    a prefix
    R
    b prefix
    B

    2R ≠ B, so at least one of them is not used. Drop R (the cell above, 0) or drop B (the cell to the left, 0): both are 0.

  3. L[i][j]
    ""BOARD
    ""000000
    R000
    O0
    A0
    D0
    a prefix
    R
    b prefix
    BO

    3R ≠ O. Drop R (above: 0) or O (left: 0). Keep 0.

  4. L[i][j]
    ""BOARD
    ""000000
    R0000
    O0
    A0
    D0
    a prefix
    R
    b prefix
    BOA

    4R ≠ A. Drop R (above: 0) or A (left: 0). Keep 0.

  5. L[i][j]
    ""BOARD
    ""000000
    R00001
    O0
    A0
    D0
    a prefix
    R
    b prefix
    BOAR

    5R = R. The match extends the best answer for the shorter prefixes, diagonally up-left: 0 + 1 = 1.

  6. L[i][j]
    ""BOARD
    ""000000
    R000011
    O0
    A0
    D0
    a prefix
    R
    b prefix
    BOARD

    6R ≠ D. Drop R (above: 0) or D (left: 1). Keep 1.

  7. L[i][j]
    ""BOARD
    ""000000
    R000011
    O00
    A0
    D0
    a prefix
    RO
    b prefix
    B

    7O ≠ B. Drop O (above: 0) or B (left: 0). Keep 0.

  8. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001
    A0
    D0
    a prefix
    RO
    b prefix
    BO

    8O = O. The match extends the best answer for the shorter prefixes, diagonally up-left: 0 + 1 = 1.

  9. L[i][j]
    ""BOARD
    ""000000
    R000011
    O0011
    A0
    D0
    a prefix
    RO
    b prefix
    BOA

    9O ≠ A. Drop O (above: 0) or A (left: 1). Keep 1.

  10. L[i][j]
    ""BOARD
    ""000000
    R000011
    O00111
    A0
    D0
    a prefix
    RO
    b prefix
    BOAR

    10O ≠ R. Drop O (above: 1) or R (left: 1). Keep 1.

  11. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A0
    D0
    a prefix
    RO
    b prefix
    BOARD

    11O ≠ D. Drop O (above: 1) or D (left: 1). Keep 1.

  12. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A00
    D0
    a prefix
    ROA
    b prefix
    B

    12A ≠ B. Drop A (above: 0) or B (left: 0). Keep 0.

  13. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001
    D0
    a prefix
    ROA
    b prefix
    BO

    13A ≠ O. Drop A (above: 1) or O (left: 0). Keep 1.

  14. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A0012
    D0
    a prefix
    ROA
    b prefix
    BOA

    14A = A. The match extends the best answer for the shorter prefixes, diagonally up-left: 1 + 1 = 2.

  15. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A00122
    D0
    a prefix
    ROA
    b prefix
    BOAR

    15A ≠ R. Drop A (above: 1) or R (left: 2). Keep 2.

  16. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D0
    a prefix
    ROA
    b prefix
    BOARD

    16A ≠ D. Drop A (above: 1) or D (left: 2). Keep 2.

  17. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D00
    a prefix
    ROAD
    b prefix
    B

    17D ≠ B. Drop D (above: 0) or B (left: 0). Keep 0.

  18. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D001
    a prefix
    ROAD
    b prefix
    BO

    18D ≠ O. Drop D (above: 1) or O (left: 0). Keep 1.

  19. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D0012
    a prefix
    ROAD
    b prefix
    BOA

    19D ≠ A. Drop D (above: 2) or A (left: 1). Keep 2.

  20. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D00122
    a prefix
    ROAD
    b prefix
    BOAR

    20D ≠ R. Drop D (above: 2) or R (left: 2). Keep 2.

  21. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D001223
    a prefix
    ROAD
    b prefix
    BOARD

    21D = D. The match extends the best answer for the shorter prefixes, diagonally up-left: 2 + 1 = 3.

  22. L[i][j]
    ""BOARD
    ""000000
    R000011
    O001111
    A001222
    D001223
    answer
    3
    shared
    OAD

    22The answer is L[4][5] = 3. Walk back from the corner: on a match, step diagonally and keep the letter; on a mismatch, step to the bigger of the cells above and to the left. The kept letters spell OAD. 20 cells, each filled with one comparison.

1 / 22
Longest common subsequence
function lcsLength(a, b) {
  const L = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      if (a[i - 1] === b[j - 1]) {
        L[i][j] = L[i - 1][j - 1] + 1; // use the matching letter
      } else {
        L[i][j] = Math.max(L[i - 1][j], L[i][j - 1]); // drop one letter
      }
    }
  }
  return L[a.length][b.length];
}
 
lcsLength("ROAD", "BOARD"); // 3
def lcs_length(a, b):
    L = [[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]:
                L[i][j] = L[i - 1][j - 1] + 1  # use the matching letter
            else:
                L[i][j] = max(L[i - 1][j], L[i][j - 1])  # drop one letter
    return L[len(a)][len(b)]
 
lcs_length("ROAD", "BOARD")  # 3

Same table, different question: edit distance

A spell checker sees the typo ROAD and wonders if you meant BOARD. It measures how close two words are by the fewest single-letter edits that turn one into the other, where an edit is inserting a letter, deleting a letter or replacing one letter with another. This is the edit distance.

The subproblems are prefixes again. Think about the last letters. If they match, they cost nothing: keep them and solve the shorter prefixes. If not, the last edit was one of three things, and each one points at a neighbouring cell.

[i−1][j−1] [i−1][j] [i][j−1] [i][j] replace or match delete a[i−1] insert b[j−1]
Each edit leads to one neighbouring cell. Matching letters cost nothing and take the diagonal for free.
  1. State. E[i][j] is the fewest edits that turn the first i letters of a into the first j letters of b.
  2. Recurrence. If the letters match, E[i][j] = E[i − 1][j − 1]. Otherwise E[i][j] = 1 + min(E[i − 1][j − 1], E[i − 1][j], E[i][j − 1]): replace, delete or insert.
  3. Base cases. E[i][0] = i, since turning i letters into nothing takes i deletes. E[0][j] = j: building j letters from nothing takes j inserts.
  4. Order and answer: the same as for LCS.
""BOARD
""012345
R112334
O221234
A332123
D443222

The corner says 2: replace R with B, then insert R before the D. Trace one cell to check yourself. Row O, column O is a match, so it copies the diagonal, row R and column B, which holds 1.

Edit distance
function editDistance(a, b) {
  const E = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
  for (let i = 0; i <= a.length; i++) E[i][0] = i; // delete everything
  for (let j = 0; j <= b.length; j++) E[0][j] = j; // insert everything
  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      if (a[i - 1] === b[j - 1]) {
        E[i][j] = E[i - 1][j - 1]; // free match
      } else {
        E[i][j] = 1 + Math.min(E[i - 1][j - 1], E[i - 1][j], E[i][j - 1]);
      }
    }
  }
  return E[a.length][b.length];
}
 
editDistance("ROAD", "BOARD"); // 2
def edit_distance(a, b):
    E = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(len(a) + 1):
        E[i][0] = i  # delete everything
    for j in range(len(b) + 1):
        E[0][j] = j  # insert everything
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i - 1] == b[j - 1]:
                E[i][j] = E[i - 1][j - 1]  # free match
            else:
                E[i][j] = 1 + min(E[i - 1][j - 1], E[i - 1][j], E[i][j - 1])
    return E[len(a)][len(b)]
 
edit_distance("ROAD", "BOARD")  # 2
ApproachTimeSpaceWhy
LCS tableO(m × n)O(m × n)One cell per pair of prefixes, constant work per cell.
Edit distance tableO(m × n)O(m × n)Same table size. Keep only two rows for O(n) space if you need just the number.

Common mistakes

Check yourself

Quick check

What is the length of the longest common subsequence of "TEAM" and "MEAT"?

In the LCS table, the letters for cell [i][j] differ. L[i − 1][j] = 2 and L[i][j − 1] = 3. What is L[i][j]?

What is the edit distance from "CAT" to "CUT"?

What to practise next

The last lesson of the module, Longest Increasing Subsequence, asks for a subsequence again, this time inside a single list of numbers. It starts as a one-row table and then gets much faster with binary search. To practise this lesson, try Shared Dance Moves for LCS and Spelling Test Fixes for edit distance.