1. Introduction & Motivation
Every problem in this lesson is a member of the same family: 2D DP over two strings/sequences, where the state is (position in string A, position in string B). You fill an $(m+1) \times (n+1)$ table, one axis per string, and each cell answers "what is the best value considering only the prefixes ending here?" The transition compares one character from each string and decides how to combine the answers to smaller prefixes.
Longest Common Subsequence (LCS) is the simplest member of this family: it only asks "how long is the longest sequence of characters that appears in both strings, in order, not necessarily contiguous?" Every variant in this lesson keeps that same two-pointer state design and only changes the question asked of each cell, or bolts a small addition onto the recurrence. Once you see the shared skeleton, the "new" algorithms stop feeling new.
2. Prerequisites
This lesson assumes you have already worked through Part 15: Core DP Patterns – Longest Common Subsequence. That lesson builds the LCS state design and 2D table-filling technique from scratch, with a full three-language implementation. It is not repeated here — every section below either extends that recurrence directly or reuses its table. If the phrase "$dp[i][j]$ = LCS length of $text1[0..i-1]$ and $text2[0..j-1]$" is unfamiliar, read that section first.
Note that a different page in this series, dsa-interview-pattern-recognition.html, has a section titled "2D DP: Edit Distance" — but its code sample is actually LCS again, not real Edit Distance. Section 4 of this lesson implements the genuine Levenshtein recurrence, which is a different problem with a different recurrence and a different answer on the same inputs.
3. Longest Common Substring
LCS allows gaps: any subsequence that preserves relative order counts, so "abcde" and "ace" share the subsequence "ace" even though the letters are scattered through the first string. A substring is a much stricter ask — the matched characters must be contiguous in both strings, with no gaps allowed.
dp[i][j] = length of the common substring ending exactly at s1[i-1] and s2[j-1]. The moment characters mismatch, the run breaks — so dp[i][j] resets to 0 instead of falling back to max(dp[i-1][j], dp[i][j-1]) the way LCS does. Since the best substring can end anywhere in the table, not necessarily at the last characters of both strings, you must track the running maximum (and where it occurred) separately as you fill the table.
Trace on "abab" vs "baba"
Build the 5×5 table (rows = s1 = "abab", columns = s2 = "baba"). Row/column 0 are all zero.
| ∅ | b | a | b | a | |
|---|---|---|---|---|---|
| ∅ | 0 | 0 | 0 | 0 | 0 |
| a | 0 | 0 | 1 | 0 | 1 |
| b | 0 | 1 | 0 | 2 | 0 |
| a | 0 | 0 | 2 | 0 | 3 |
| b | 0 | 1 | 0 | 3 | 0 |
The maximum value 3 first appears at dp[3][4] (row for s1[0..2]="aba", column for s2[0..3]="baba" ending in "a"), giving substring "aba". It appears again at dp[4][3]=3, corresponding to substring "bab" — both are valid length-3 answers. Notice the bottom-right corner dp[4][4] is 0: if you had only read off the final cell, as LCS does, you would have missed the answer entirely. That is exactly why the running maximum must be tracked separately from the table itself.
4. Edit Distance (Levenshtein Distance)
Edit Distance asks a different question again: not "how much do two strings share," but "how many single-character operations does it take to transform one string into the other?" The three allowed operations are insertion, deletion, and replacement (substitution), each costing one edit.
dp[i][j] = minimum operations to convert s1[0..i) into s2[0..j). Base cases dp[i][0] = i and dp[0][j] = j represent deleting everything or inserting everything from scratch. If the current characters match, that character costs nothing and the answer is whatever it cost to convert the shorter prefixes: dp[i][j] = dp[i-1][j-1]. On a mismatch, one edit is spent, and you take the cheapest of the three neighboring cells — delete from s1 (dp[i-1][j]), insert into s1 (dp[i][j-1]), or replace (dp[i-1][j-1]): $$dp[i][j] = 1 + \min\big(dp[i-1][j],\, dp[i][j-1],\, dp[i-1][j-1]\big)$$
This is genuinely different code from LCS — the mismatch branch adds 1 and looks at three neighbors including the diagonal, where LCS's mismatch branch adds nothing and looks at only two neighbors (never the diagonal). Confusing the two recurrences is precisely the mistake in the site's mislabeled "2D DP: Edit Distance" section; the implementation below is the real one.
Trace "horse" → "ros": replace 'h' with 'r' ("rorse"), delete 'r' ("rose"), delete 'e' ("ros") — three operations, matching dp[5][3] = 3.
5. Damerau-Levenshtein Distance
Real-world typos are often two adjacent characters swapped ("hte" for "the"). Under plain Edit Distance that swap costs two operations (two replacements, or a delete plus an insert). Damerau-Levenshtein adds exactly one new operation: swapping two adjacent characters counts as a single edit, not two.
dp[i][j], check whether the last two characters of each prefix are a swapped pair — s1[i-1] == s2[j-2] and s1[i-2] == s2[j-1]. If so, the table already knows the cost of fixing everything before that pair at dp[i-2][j-2], so the transposition lets you skip both mismatched characters for the price of one edit: $$dp[i][j] = \min\big(dp[i][j],\, dp[i-2][j-2] + 1\big)$$ This table-based extension is what most tutorials teach under the name Damerau-Levenshtein; it is restricted to transposing adjacent, not-yet-edited characters (formally the "Optimal String Alignment" distance), which is sufficient for the vast majority of typo-correction use cases.
Trace "CA" → "AC": plain Edit Distance needs 2 (replace both characters). With the transposition case, dp[2][2] checks s1[1]='A' == s2[0]='A' and s1[0]='C' == s2[1]='C' — both hold, so dp[2][2] = min(2, dp[0][0] + 1) = 1: a single adjacent swap.
6. Shortest Common Supersequence
The Shortest Common Supersequence (SCS) is the shortest string that contains both s1 and s2 as subsequences (not substrings — order matters, gaps are fine).
The length formula only answers "how long," not "what string." To actually reconstruct the supersequence, walk the same LCS dp table backward from (m, n): whenever the characters match, they are shared — emit the character once and step both pointers diagonally. Otherwise, step toward whichever neighbor holds the table's current value (the side that was not discounted by the match), emitting that side's character, since that character is not part of the shared LCS and must be preserved. Once one pointer reaches 0, drain the remaining characters from the other string as-is.
Trace "abac" and "cab": LCS length is 2 (e.g. "ab"), so the formula predicts $4 + 3 - 2 = 5$. Walking the table backward from (4,3) produces "cabac" — length 5, and it does contain both "abac" and "cab" as subsequences (positions 1,2,3,4 spell "abac"; positions 0,1,2 spell "cab").
7. Longest Palindromic Subsequence
The Longest Palindromic Subsequence (LPS) of a string s is the longest subsequence of s that reads the same forwards and backwards.
s read forward and s read backward must, by definition, read identically in both directions — and a string that reads the same forward and backward is exactly a palindrome. So instead of inventing a new recurrence, run the LCS table-filling technique on s against its own reverse, and the resulting length is the longest palindromic subsequence length.
Each implementation below is self-contained: it builds its own reverse of s and fills the same LCS-shaped table inline, rather than calling a separate LCS function.
8. Complexity Comparison Table
| Technique | State Definition | Recurrence (brief) | Time | Space |
|---|---|---|---|---|
| Longest Common Substring | dp[i][j] = length of common substring ending at s1[i-1], s2[j-1] | match: dp[i-1][j-1]+1; else 0 (track max separately) | $O(mn)$ | $O(mn)$ |
| Edit Distance | dp[i][j] = min ops to turn s1[0..i) into s2[0..j) | match: dp[i-1][j-1]; else 1+min(del, ins, replace) | $O(mn)$ | $O(mn)$ |
| Damerau-Levenshtein | same as Edit Distance | as above, plus dp[i-2][j-2]+1 when adjacent chars are swapped | $O(mn)$ | $O(mn)$ |
| Shortest Common Supersequence | reuses LCS's dp[i][j]; string reconstructed by backward walk | length = len(s1)+len(s2)-LCS(s1,s2); walk emits non-shared chars from whichever side matches the current cell | $O(mn)$ | $O(mn)$ |
| Longest Palindromic Subsequence | dp[i][j] = LCS length of s[0..i) and reverse(s)[0..j) | match: dp[i-1][j-1]+1; else max(dp[i-1][j], dp[i][j-1]) | $O(n^2)$ | $O(n^2)$ |
9. Applications & Alternatives
Real systems built on these recurrences
- Spell-checkers and autocorrect: Edit Distance and Damerau-Levenshtein rank candidate corrections by how many typo-style operations separate them from the typed word.
- DNA sequence alignment: bioinformatics tools use Edit-Distance-style scoring (often with custom gap and mismatch penalties) to align genetic sequences, and Longest Common Substring to find shared genetic motifs.
- Diff tools: line-based and character-based diff utilities are built on LCS and Edit-Distance-style alignment to find the minimal set of insertions/deletions between two file versions.
- Autocomplete and fuzzy search: ranking suggestions by bounded Edit Distance lets a search box tolerate small typos while still matching the intended term.
Alternatives exist when the exact DP tables above are too slow for very long strings: bounded Edit Distance search (only compute cells within a diagonal band) trades generality for speed when you only care whether the distance is below a small threshold, and suffix automata or suffix arrays can find Longest Common Substrings in $O(m+n)$ instead of $O(mn)$ when the inputs are large. Choose the exact DP when strings are short or the exact edit script is needed; choose the specialized structure when only a yes/no or a length answer is required at scale.
10. Exercises
Easy
- Find the Longest Common Substring of
"programming"and"gaming", and state its length.
Medium
- Compute the Edit Distance between
"sunday"and"saturday"under custom operation costs: replace costs 2, insert and delete each cost 1. (Hint: the recurrence's mismatch branch becomesmin(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2).)
Hard
- Extend the standard Edit Distance implementation to reconstruct the actual sequence of operations (not just the count) by walking the completed
dptable backward from(m, n): at each cell, check which of match, replace, delete, or insert produced the stored value, emit that operation, and step to the corresponding neighbor.
Review rhythm: tomorrow, re-derive all five recurrences from the shared LCS skeleton without looking them up; next week, implement Edit Distance and its operation-reconstruction variant from memory; several weeks later, recognize which member of this family a new interview problem belongs to before writing any code.
11. Summary Cheat Sheet
- All five techniques share the LCS state design: a 2D table indexed by (position in string A, position in string B).
- Longest Common Substring resets to 0 on mismatch (contiguity) and needs a separately tracked running maximum.
- Edit Distance's mismatch branch costs 1 and checks all three neighbors, including the diagonal — unlike LCS.
- Damerau-Levenshtein adds one extra case to Edit Distance: an adjacent-transposition check against
dp[i-2][j-2]. - Shortest Common Supersequence length is
len(s1) + len(s2) - LCS(s1, s2); the string itself requires backtracking through the LCS table. - Longest Palindromic Subsequence is just LCS of a string against its own reverse.
- All five run in $O(mn)$ time and space (or $O(n^2)$ for the single-string LPS case).