Skip to content

String DP - Intuition Guide

The Mental Model: Filling a Grid

Imagine you have two strings written along the edges of a grid: - First string s along the rows (top to bottom) - Second string t along the columns (left to right)

Each cell (i, j) represents the answer for comparing s[0:i] with t[0:j].

        ""  a   b   c
    β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
""  β”‚ 0 β”‚ 0 β”‚ 0 β”‚ 0 β”‚
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
a   β”‚ 0 β”‚ 1 β”‚ 1 β”‚ 1 β”‚
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
c   β”‚ 0 β”‚ 1 β”‚ 1 β”‚ 2 β”‚
    β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜

You fill this grid row by row, and each cell only needs its neighbors: - Diagonal (top-left): both strings shrink - Up: first string shrinks - Left: second string shrinks

Why String DP?

String DP works because string comparison has optimal substructure: - The best answer for long strings depends only on answers for shorter strings - We can systematically build from empty strings to full strings

Core Insight

The transition depends on whether current characters match:

Match:      s[i-1] == t[j-1]
            β†’ Look diagonal (both consumed)

Mismatch:   s[i-1] != t[j-1]
            β†’ Look up, left, or diagonal (try all options)

Pattern 1: LCS (Longest Common Subsequence)

The insight: If characters match, include them. If not, try skipping either character.

s = "ace", t = "abcde"

        ""  a   b   c   d   e
    β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
""  β”‚ 0 β”‚ 0 β”‚ 0 β”‚ 0 β”‚ 0 β”‚ 0 β”‚
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
a   β”‚ 0 β”‚ 1 β”‚ 1 β”‚ 1 β”‚ 1 β”‚ 1 β”‚  a=a: +1
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
c   β”‚ 0 β”‚ 1 β”‚ 1 β”‚ 2 β”‚ 2 β”‚ 2 β”‚  c=c: +1
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
e   β”‚ 0 β”‚ 1 β”‚ 1 β”‚ 2 β”‚ 2 β”‚ 3 β”‚  e=e: +1
    β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜
                        └── Answer: 3

The code:

if s[i-1] == t[j-1]:
    dp[i][j] = dp[i-1][j-1] + 1  # Include this character
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])  # Skip one char

Pattern 2: Edit Distance

The insight: Each cell is the minimum edits to transform. Three operations mean three choices.

s = "horse", t = "ros"

        ""  r   o   s
    β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
""  β”‚ 0 β”‚ 1 β”‚ 2 β”‚ 3 β”‚  Insert all
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
h   │ 1 │ 1 │ 2 │ 3 │  h≠r: min(replace, delete, insert)
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
o   β”‚ 2 β”‚ 2 β”‚ 1 β”‚ 2 β”‚  o=o: diagonal (no cost)
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
r   │ 3 │ 2 │ 2 │ 2 │  r≠s: +1
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
s   β”‚ 4 β”‚ 3 β”‚ 3 β”‚ 2 β”‚  s=s: diagonal
    β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
e   β”‚ 5 β”‚ 4 β”‚ 4 β”‚ 3 β”‚  Answer: 3
    β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜

The code:

if s[i-1] == t[j-1]:
    dp[i][j] = dp[i-1][j-1]  # No operation needed
else:
    dp[i][j] = 1 + min(
        dp[i-1][j-1],  # Replace s[i-1] with t[j-1]
        dp[i-1][j],    # Delete s[i-1]
        dp[i][j-1]     # Insert t[j-1]
    )

Pattern 3: Palindrome Subsequence

The insight: A palindrome reads the same forwards and backwards.

The LPS of string s = LCS of s and reverse(s).

s = "bbbab"
t = "babbb" (reversed)

LCS = "bbbb" (length 4)

Why does this work? Any common subsequence between s and its reverse must appear in both directions, making it a palindrome.

Pattern 4: Regex Matching

The insight: Handle * by considering "use it" vs "skip it".

s = "aab", p = "c*a*b"

c*  β†’ 0 or more 'c' (we use 0)
a*  β†’ 0 or more 'a' (we use 2)
b   β†’ exactly 'b' (we use 1)

Result: MATCH

The tricky part is *:

if p[j-1] == '*':
    # Option 1: Use zero of p[j-2]
    dp[i][j] = dp[i][j-2]

    # Option 2: Use one or more of p[j-2]
    if p[j-2] == '.' or p[j-2] == s[i-1]:
        dp[i][j] = dp[i][j] or dp[i-1][j]

Common Mistakes

Mistake 1: Off-by-one indexing

# ❌ Wrong: dp indices and string indices are off by 1
if s[i] == t[j]:

# βœ… Right: dp[i][j] corresponds to s[0:i], so character is s[i-1]
if s[i-1] == t[j-1]:

Mistake 2: Wrong base cases for Edit Distance

# ❌ Wrong: Forgetting base cases
dp = [[0] * (n+1) for _ in range(m+1)]

# βœ… Right: Empty string requires i deletions or j insertions
for i in range(m+1):
    dp[i][0] = i
for j in range(n+1):
    dp[0][j] = j

Mistake 3: Regex * looks at wrong index

# ❌ Wrong: Looking at j-1 for the character before *
if p[j-1] == s[i-1]:

# βœ… Right: * is at j-1, so the character is at j-2
if p[j-2] == s[i-1]:

Quick Pattern Recognition

Clue Pattern
"longest common subsequence" LCS
"minimum operations to convert" Edit Distance
"palindrome subsequence" LCS with reverse
"match pattern with . or *" Regex DP
"delete operation for two strings" LCS-based

Visual Summary

String DP: Compare s[0:i] with t[0:j]

           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚ dp[i-1][j-1]│───► Match: usually this
           β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚             β”‚             β”‚
    β–Ό             β–Ό             β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚dp[i-1] β”‚  β”‚  dp[i][j]   β”‚  β”‚dp[i]   β”‚
β”‚  [j]   β”‚  β”‚  (current)  β”‚  β”‚ [j-1]  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Delete s[i]                  Insert t[j]
    β”‚                             β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚
               β–Ό
         Mismatch: combine
         these options