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.
On this page
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
- State.
L[i][j]is the length of the LCS of the firstiletters ofaand the firstjletters ofb. - Recurrence. If
a[i − 1] == b[j − 1], thenL[i][j] = L[i − 1][j − 1] + 1. OtherwiseL[i][j] = max(L[i − 1][j], L[i][j − 1]). - Base cases.
L[0][j] = 0andL[i][0] = 0: an empty word shares nothing. - Order. Each cell reads up, left and diagonally up-left, as on a grid. Row by row, left to right.
- 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.
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)]- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 O 0 A 0 D 0 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. - L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 O 0 A 0 D 0 - 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.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 O 0 A 0 D 0 - a prefix
- R
- b prefix
- BO
3R ≠ O. Drop R (above: 0) or O (left: 0). Keep 0.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 O 0 A 0 D 0 - a prefix
- R
- b prefix
- BOA
4R ≠ A. Drop R (above: 0) or A (left: 0). Keep 0.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 O 0 A 0 D 0 - a prefix
- R
- b prefix
- BOAR
5R = R. The match extends the best answer for the shorter prefixes, diagonally up-left: 0 + 1 = 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 A 0 D 0 - a prefix
- R
- b prefix
- BOARD
6R ≠ D. Drop R (above: 0) or D (left: 1). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 A 0 D 0 - a prefix
- RO
- b prefix
- B
7O ≠ B. Drop O (above: 0) or B (left: 0). Keep 0.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 A 0 D 0 - a prefix
- RO
- b prefix
- BO
8O = O. The match extends the best answer for the shorter prefixes, diagonally up-left: 0 + 1 = 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 A 0 D 0 - a prefix
- RO
- b prefix
- BOA
9O ≠ A. Drop O (above: 0) or A (left: 1). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 A 0 D 0 - a prefix
- RO
- b prefix
- BOAR
10O ≠ R. Drop O (above: 1) or R (left: 1). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 D 0 - a prefix
- RO
- b prefix
- BOARD
11O ≠ D. Drop O (above: 1) or D (left: 1). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 D 0 - a prefix
- ROA
- b prefix
- B
12A ≠ B. Drop A (above: 0) or B (left: 0). Keep 0.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 D 0 - a prefix
- ROA
- b prefix
- BO
13A ≠ O. Drop A (above: 1) or O (left: 0). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 D 0 - a prefix
- ROA
- b prefix
- BOA
14A = A. The match extends the best answer for the shorter prefixes, diagonally up-left: 1 + 1 = 2.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 D 0 - a prefix
- ROA
- b prefix
- BOAR
15A ≠ R. Drop A (above: 1) or R (left: 2). Keep 2.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 - a prefix
- ROA
- b prefix
- BOARD
16A ≠ D. Drop A (above: 1) or D (left: 2). Keep 2.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 - a prefix
- ROAD
- b prefix
- B
17D ≠ B. Drop D (above: 0) or B (left: 0). Keep 0.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 1 - a prefix
- ROAD
- b prefix
- BO
18D ≠ O. Drop D (above: 1) or O (left: 0). Keep 1.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 1 2 - a prefix
- ROAD
- b prefix
- BOA
19D ≠ A. Drop D (above: 2) or A (left: 1). Keep 2.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 1 2 2 - a prefix
- ROAD
- b prefix
- BOAR
20D ≠ R. Drop D (above: 2) or R (left: 2). Keep 2.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 1 2 2 3 - a prefix
- ROAD
- b prefix
- BOARD
21D = D. The match extends the best answer for the shorter prefixes, diagonally up-left: 2 + 1 = 3.
- L[i][j]
"" B O A R D "" 0 0 0 0 0 0 R 0 0 0 0 1 1 O 0 0 1 1 1 1 A 0 0 1 2 2 2 D 0 0 1 2 2 3 - 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.
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"); // 3def 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") # 3Same 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.
- State.
E[i][j]is the fewest edits that turn the firstiletters ofainto the firstjletters ofb. - Recurrence. If the letters match,
E[i][j] = E[i − 1][j − 1]. OtherwiseE[i][j] = 1 + min(E[i − 1][j − 1], E[i − 1][j], E[i][j − 1]): replace, delete or insert. - Base cases.
E[i][0] = i, since turningiletters into nothing takesideletes.E[0][j] = j: buildingjletters from nothing takesjinserts. - Order and answer: the same as for LCS.
| "" | B | O | A | R | D | |
|---|---|---|---|---|---|---|
| "" | 0 | 1 | 2 | 3 | 4 | 5 |
| R | 1 | 1 | 2 | 3 | 3 | 4 |
| O | 2 | 2 | 1 | 2 | 3 | 4 |
| A | 3 | 3 | 2 | 1 | 2 | 3 |
| D | 4 | 4 | 3 | 2 | 2 | 2 |
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.
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"); // 2def 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| Approach | Time | Space | Why |
|---|---|---|---|
| LCS table | O(m × n) | O(m × n) | One cell per pair of prefixes, constant work per cell. |
| Edit distance table | O(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
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.