String DP Patterns — Edit Distance, LCS, Interleaving, and Regex Matching

Sanjeev SharmaSanjeev Sharma
11 min read

Advertisement

Problem Statement

This guide covers the four canonical string DP problems and the unified pattern that links them:

  1. Edit Distance (Levenshtein) (LC 72) — minimum insertions, deletions, substitutions to transform s1 into s2.
  2. Longest Common Subsequence (LC 1143) — length of the longest sequence appearing in s1 and s2 in order, not necessarily contiguous.
  3. Interleaving Strings (LC 97) — does s3 interleave s1 and s2 while preserving each one's order?
  4. Regular Expression Matching (LC 10) — does pattern p (with . and *) match string s exactly?

Each defines a 2D table dp[i][j] indexed by prefixes of two strings. Each fills the table in O(nm) time and O(nm) or O(min(n, m)) space.

Why These Patterns Matter

String DP problems appear in roughly one in three FAANG interviews. Edit distance powers spell checkers, fuzzy search, DNA alignment, and version-control diffing. LCS underlies Unix diff, plagiarism detection, and merge tools. Interleaving validates parser ambiguity. Regex matching is the heart of every grep, sed, awk, lex, and editor find-and-replace.

Interviewers at Google, Meta, and Amazon use these problems to test three things at once: can you derive a recurrence, identify states, and optimise space? Each problem has a clean 2D recurrence and a well-known space-to-row optimisation. Botching the recurrence is fatal; muddling the space optimisation is forgivable but flagged.

The strategic value is leverage. Once you internalise the 2D-on-prefixes pattern, dozens of variants — distinct subsequences, longest palindromic subsequence, scramble strings, wildcard matching — become trivially adaptable. Each is a small tweak to one of the canonical four.

In production systems, edit distance is everywhere: typo correction in search, autocompletion, OCR confidence scoring, network protocol parsing. Knowing the algorithm separates "I know how to use Python's difflib" from "I can implement a domain-specific aligner."

The Core Insight

The unifying pattern: define dp[i][j] based on the first i characters of s1 and first j characters of s2 (or pattern). Walk the table from (0, 0) to (m, n) and the answer falls out of dp[m][n].

Edit Distance. dp[i][j] is the minimum operations to convert s1[0..i-1] to s2[0..j-1]. The recurrence:

dp[i][j] = dp[i-1][j-1]                 if s1[i-1] == s2[j-1]   (no op)
         = 1 + min(dp[i-1][j],          (delete s1[i-1])
                   dp[i][j-1],          (insert s2[j-1])
                   dp[i-1][j-1])        (substitute)

Base cases: dp[i][0] = i (delete all i chars), dp[0][j] = j (insert all j chars).

LCS. dp[i][j] is the length of the longest common subsequence of s1[0..i-1] and s2[0..j-1]:

dp[i][j] = dp[i-1][j-1] + 1            if s1[i-1] == s2[j-1]
         = max(dp[i-1][j], dp[i][j-1]) otherwise

The "max from neighbours" branch is what allows non-contiguous matches.

Interleaving Strings. dp[i][j] is true if s3[0..i+j-1] is an interleaving of s1[0..i-1] and s2[0..j-1]:

dp[i][j] = (dp[i-1][j] and s1[i-1] == s3[i+j-1])
        or (dp[i][j-1] and s2[j-1] == s3[i+j-1])

Base case: dp[0][0] = True. The recurrence asks "did the last character come from s1 or s2?" and only allows transitions where the source character matches.

Regex Matching. dp[i][j] is true if s[0..i-1] matches p[0..j-1]:

If p[j-1] == '*':
  dp[i][j] = dp[i][j-2]                          (zero occurrences)
          or (dp[i-1][j] and (s[i-1] == p[j-2] or p[j-2] == '.'))
                                                  (one or more)
Else:
  dp[i][j] = dp[i-1][j-1] and (s[i-1] == p[j-1] or p[j-1] == '.')

The * case is the trickiest because * modifies the previous character; you must look two cells back.

The structural unity across the four: each dp[i][j] depends on dp[i-1][j], dp[i][j-1], and dp[i-1][j-1]. This means a row-by-row pass with two 1D arrays of size n+1 is enough — O(n) extra space instead of O(n*m).

Visual Dry Run — Edit Distance

s1 = "horse", s2 = "ros".

       ''  r  o  s
   ''   0  1  2  3
    h   1  1  2  3
    o   2  2  1  2
    r   3  2  2  2
    s   4  3  3  2
    e   5  4  4  3

Trace dp[5][3]: minimum cost to convert "horse" to "ros" is 3.

  • dp[1][1]: convert "h" to "r" — substitute, cost 1.
  • dp[2][2]: convert "ho" to "ro" — substitute h to r at position 0, no-op for o. Cost 1.
  • dp[3][3]: convert "hor" to "ros" — delete h, no-op or, substitute r->s. Cost 2.
  • dp[5][3]: convert "horse" to "ros" — delete h, no-op o, no-op r, delete s, delete e? Or h->r, delete o, no-op... Multiple paths give cost 3.

Reading off operations is more involved than reading the cost. Backtrack from (m, n) along the chosen branch to recover the edit script.

Solution (Optimal)

Python — Four Canonical String DPs

def edit_distance(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = list(range(n + 1))
    for i in range(1, m + 1):
        prev_diag = dp[0]
        dp[0] = i
        for j in range(1, n + 1):
            tmp = dp[j]
            if s1[i - 1] == s2[j - 1]:
                dp[j] = prev_diag
            else:
                dp[j] = 1 + min(dp[j], dp[j - 1], prev_diag)
            prev_diag = tmp
    return dp[n]
 
 
def lcs(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = [0] * (n + 1)
    for i in range(1, m + 1):
        prev_diag = 0
        for j in range(1, n + 1):
            tmp = dp[j]
            if s1[i - 1] == s2[j - 1]:
                dp[j] = prev_diag + 1
            else:
                dp[j] = max(dp[j], dp[j - 1])
            prev_diag = tmp
    return dp[n]
 
 
def is_interleave(s1: str, s2: str, s3: str) -> bool:
    m, n = len(s1), len(s2)
    if m + n != len(s3):
        return False
    dp = [False] * (n + 1)
    dp[0] = True
    for j in range(1, n + 1):
        dp[j] = dp[j - 1] and s2[j - 1] == s3[j - 1]
    for i in range(1, m + 1):
        dp[0] = dp[0] and s1[i - 1] == s3[i - 1]
        for j in range(1, n + 1):
            dp[j] = ((dp[j] and s1[i - 1] == s3[i + j - 1])
                     or (dp[j - 1] and s2[j - 1] == s3[i + j - 1]))
    return dp[n]
 
 
def is_match(s: str, p: str) -> bool:
    m, n = len(s), len(p)
    dp = [[False] * (n + 1) for _ in range(m + 1)]
    dp[0][0] = True
    for j in range(1, n + 1):
        if p[j - 1] == '*':
            dp[0][j] = dp[0][j - 2]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if p[j - 1] == '*':
                dp[i][j] = dp[i][j - 2]
                if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
                    dp[i][j] = dp[i][j] or dp[i - 1][j]
            else:
                dp[i][j] = (dp[i - 1][j - 1] and
                            (p[j - 1] == '.' or p[j - 1] == s[i - 1]))
    return dp[m][n]

JavaScript — Edit Distance and LCS

function editDistance(s1, s2) {
  const m = s1.length, n = s2.length;
  let dp = Array.from({ length: n + 1 }, (_, j) => j);
  for (let i = 1; i <= m; i++) {
    let prevDiag = dp[0];
    dp[0] = i;
    for (let j = 1; j <= n; j++) {
      const tmp = dp[j];
      if (s1[i - 1] === s2[j - 1]) {
        dp[j] = prevDiag;
      } else {
        dp[j] = 1 + Math.min(dp[j], dp[j - 1], prevDiag);
      }
      prevDiag = tmp;
    }
  }
  return dp[n];
}
 
function lcs(s1, s2) {
  const m = s1.length, n = s2.length;
  const dp = new Array(n + 1).fill(0);
  for (let i = 1; i <= m; i++) {
    let prevDiag = 0;
    for (let j = 1; j <= n; j++) {
      const tmp = dp[j];
      if (s1[i - 1] === s2[j - 1]) dp[j] = prevDiag + 1;
      else dp[j] = Math.max(dp[j], dp[j - 1]);
      prevDiag = tmp;
    }
  }
  return dp[n];
}

Complexity: O(n*m) time, O(min(n, m)) space when collapsed to a single rolling row plus a prev_diag scalar.

Common Mistakes

Mishandling base cases. dp[0][j] = j for edit distance, dp[0][j] = 0 for LCS. The wrong base seeds incorrect values throughout. Always pencil out the first row and column before coding.

Using the wrong recurrence for substring vs subsequence. Edit distance and LCS look similar but differ in the mismatch branch. Substring resets to zero (covered in part 7); subsequence takes max from neighbours.

Forgetting dp[i][j-2] in regex *. The * modifies the previous pattern character, so zero occurrences means jumping back two cells, not one. Easy to write dp[i][j-1] and pass small tests but fail on s = "aab", p = "c*a*b".

Not handling the regex empty pattern row correctly. When s = "", only patterns of the form x*y*z*... match. Initialise the empty-string row with the explicit dp[0][j] = dp[0][j-2] when p[j-1] == '*'.

Premature space optimisation. Collapsing to one row before the recurrence is solid causes hours of debugging. Get the 2D version right first, then optimise.

Off-by-one with prev_diag. When using a single rolling row, the diagonal predecessor is the value of dp[j] before it was updated this iteration. Save it in tmp before the assignment.

Interview Tips

State the state and recurrence before writing code. "Let dp[i][j] be ... and the recurrence is ..." is the universal opening that earns trust.

Draw the small table by hand. Interviewers love watching candidates fill in the first few cells; it reveals whether you understand the recurrence or are pattern-matching.

After the 2D version works, mention space optimisation. Say "We can collapse to two 1D arrays of size n+1 because each cell only depends on the previous row and current row." For credit, implement the optimisation; for partial credit, just state it.

For follow-up questions about reconstructing the edit script, mention that you store the table and backtrack from (m, n). Each cell records which branch was taken; the path from (m, n) to (0, 0) enumerates the operations in reverse.

For wildcard matching (LC 44 with ? and * where * matches any sequence), the recurrence is similar but simpler: * extends by either consuming a character (dp[i-1][j]) or skipping itself (dp[i][j-1]). Mention this as a sister problem.

For the LCS variant that returns the actual subsequence, build the table O(n*m) and backtrack from (m, n) taking the diagonal when characters match and the larger of up/left otherwise.

Follow-up Questions

Q: How do you reduce edit distance space to O(min(n, m))? A: Iterate over the smaller dimension. Maintain one rolling row of size min(n, m) + 1 plus a scalar prev_diag. Each cell update overwrites in place, with the diagonal predecessor saved before the overwrite.

Q: What is the Hunt-Szymanski algorithm and why does it matter for LCS? A: It is an O((n + r) log n) algorithm where r is the number of matching position pairs. For long files with few common lines (typical in diff), r is much less than n*m, giving near-linear performance. Used in production diff tools.

Q: How do you support transpositions in edit distance? A: The Damerau-Levenshtein distance adds a fourth case: dp[i][j] = min(..., dp[i-2][j-2] + 1) if s1[i-1] == s2[j-2] and s1[i-2] == s2[j-1]. Useful for typo detection where adjacent transpositions are common.

Q: How does the Hirschberg algorithm reduce LCS to linear space? A: Divide and conquer: split s1 in half, find the optimal split point in s2 using two forward passes (O(m) space), then recurse on the two halves. Total space O(min(n, m)), time still O(n*m).

Q: How do you adapt regex matching to support backreferences? A: Backreferences (\1, \2) make the language context-sensitive — pure regex DP no longer suffices. The standard approach is backtracking with memoisation, exponential worst case. This is why production regex engines like RE2 deliberately exclude backreferences.

Key Takeaways

  • The four canonical string DP problems all index by prefixes: dp[i][j] over s1[0..i-1] and s2[0..j-1] (or p[0..j-1]).
  • Edit distance uses substitution, insertion, deletion costs; LCS uses match-or-skip; interleaving uses source-of-last-character; regex uses pattern-character-with-special-cases.
  • All four collapse to O(n*m) time and O(min(n, m)) space using a rolling row and a prev_diag scalar.
  • Regex matching's * case requires looking two cells back because * modifies the previous character; this is the most common source of bugs.
  • Mastering this family of recurrences unlocks dozens of variants — distinct subsequences, palindromic subsequences, scramble strings, wildcard matching — with minor tweaks.
  • Interview signal: stating the recurrence cleanly, walking a small table by hand, and optimising space demonstrates the full DP toolkit FAANG values.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading