Interval and String DP

advanced dynamic-programming interval-DP palindrome

Interval DP is a pattern where dp[i][j] represents the answer for the substring or subarray from index i to j. We build up from small intervals to large ones.

In simple language, we start by solving tiny substrings (length 1, length 2), then use those answers to solve bigger and bigger substrings. Think of it like building a pyramid from the bottom up.

These are interview favorites, especially the palindrome family.

Longest Palindromic Subsequence

Problem: Find the length of the longest subsequence that reads the same forwards and backwards. We can skip characters (it’s a subsequence, not a substring).

Example: "bbbab" has LPS "bbbb" (length 4).

State: dp[i][j] = length of longest palindromic subsequence in s[i..j]

Recurrence:

  • If s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2 (both ends match, shrink inward)
  • Else: dp[i][j] = max(dp[i+1][j], dp[i][j-1]) (try dropping one end)

Base case: Every single character is a palindrome of length 1.

Interval DP: How We Fill the Table
Length 1
dp[i][i] = 1 (single chars)
Length 2
dp[i][i+1] = 1 or 2 (pairs)
Length 3
uses answers from length 1 and 2
↓ ... ↓
Length n
dp[0][n-1] = our answer!
function longestPalindromeSubseq(s) {
  const n = s.length;
  const dp = Array.from({ length: n }, () => new Array(n).fill(0));
  for (let i = 0; i < n; i++) dp[i][i] = 1;  // single char = palindrome
  for (let len = 2; len <= n; len++) {         // grow the interval
    for (let i = 0; i <= n - len; i++) {
      const j = i + len - 1;
      if (s[i] === s[j]) {
        dp[i][j] = dp[i + 1][j - 1] + 2;     // both ends match
      } else {
        dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); // skip one
      }
    }
  }
  return dp[0][n - 1];
}
// Time: O(n²), Space: O(n²)
def longest_palindrome_subseq(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n):
        dp[i][i] = 1                           # single char = palindrome
    for length in range(2, n + 1):             # grow the interval
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2     # both ends match
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])  # skip one
    return dp[0][n - 1]
# Time: O(n²), Space: O(n²)
static int longestPalindromeSubseq(String s) {
    int n = s.length();
    int[][] dp = new int[n][n];
    for (int i = 0; i < n; i++) dp[i][i] = 1;
    for (int len = 2; len <= n; len++) {       // grow the interval
        for (int i = 0; i <= n - len; i++) {
            int j = i + len - 1;
            if (s.charAt(i) == s.charAt(j)) {
                dp[i][j] = dp[i + 1][j - 1] + 2;
            } else {
                dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[0][n - 1];
}
// Time: O(n²), Space: O(n²)

Fun fact: LPS is equivalent to finding the LCS of s and reverse(s). Two ways to solve the same problem.

Longest Palindromic Substring

Problem: Find the longest substring (contiguous!) that’s a palindrome.

This one is slightly different from the subsequence version. Instead of tracking length, we track whether s[i..j] is a palindrome.

State: dp[i][j] = true if s[i..j] is a palindrome

Recurrence: dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]

function longestPalindrome(s) {
  const n = s.length;
  const dp = Array.from({ length: n }, () => new Array(n).fill(false));
  let start = 0, maxLen = 1;
  for (let i = 0; i < n; i++) dp[i][i] = true;     // single chars
  for (let len = 2; len <= n; len++) {
    for (let i = 0; i <= n - len; i++) {
      const j = i + len - 1;
      if (s[i] === s[j] && (len <= 3 || dp[i + 1][j - 1])) {
        dp[i][j] = true;
        if (len > maxLen) { start = i; maxLen = len; }
      }
    }
  }
  return s.slice(start, start + maxLen);
}
def longest_palindrome(s):
    n = len(s)
    dp = [[False] * n for _ in range(n)]
    start, max_len = 0, 1
    for i in range(n):
        dp[i][i] = True                              # single chars
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and (length <= 3 or dp[i + 1][j - 1]):
                dp[i][j] = True
                if length > max_len:
                    start, max_len = i, length
    return s[start:start + max_len]
static String longestPalindrome(String s) {
    int n = s.length();
    boolean[][] dp = new boolean[n][n];
    int start = 0, maxLen = 1;
    for (int i = 0; i < n; i++) dp[i][i] = true;
    for (int len = 2; len <= n; len++) {
        for (int i = 0; i <= n - len; i++) {
            int j = i + len - 1;
            if (s.charAt(i) == s.charAt(j) && (len <= 3 || dp[i+1][j-1])) {
                dp[i][j] = true;
                if (len > maxLen) { start = i; maxLen = len; }
            }
        }
    }
    return s.substring(start, start + maxLen);
}

Note: There’s also a clever expand-around-center approach that uses O(1) space. But the DP version is what demonstrates the interval pattern.

Palindrome Partitioning II (Minimum Cuts)

Problem: Given a string, find the minimum number of cuts to split it so that every piece is a palindrome.

Example: "aab" needs 1 cut: "aa" | "b".

We need two things:

  1. A palindrome lookup table (is s[i..j] a palindrome?)
  2. A 1D DP for minimum cuts

State: dp[i] = minimum cuts for s[0..i]

function minCut(s) {
  const n = s.length;
  // Step 1: precompute palindrome table
  const isPalin = Array.from({ length: n }, () => new Array(n).fill(false));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = i; j < n; j++) {
      isPalin[i][j] = s[i] === s[j] && (j - i <= 2 || isPalin[i+1][j-1]);
    }
  }
  // Step 2: find min cuts
  const dp = new Array(n).fill(0);
  for (let i = 0; i < n; i++) {
    if (isPalin[0][i]) { dp[i] = 0; continue; } // whole prefix is palindrome
    dp[i] = Infinity;
    for (let j = 1; j <= i; j++) {
      if (isPalin[j][i]) dp[i] = Math.min(dp[i], dp[j - 1] + 1);
    }
  }
  return dp[n - 1];
}
def min_cut(s):
    n = len(s)
    # Step 1: precompute palindrome table
    is_palin = [[False] * n for _ in range(n)]
    for i in range(n - 1, -1, -1):
        for j in range(i, n):
            is_palin[i][j] = s[i] == s[j] and (j - i <= 2 or is_palin[i+1][j-1])
    # Step 2: find min cuts
    dp = [0] * n
    for i in range(n):
        if is_palin[0][i]:
            dp[i] = 0; continue      # whole prefix is palindrome
        dp[i] = float('inf')
        for j in range(1, i + 1):
            if is_palin[j][i]:
                dp[i] = min(dp[i], dp[j - 1] + 1)
    return dp[n - 1]
static int minCut(String s) {
    int n = s.length();
    boolean[][] isPalin = new boolean[n][n];
    for (int i = n - 1; i >= 0; i--) {
        for (int j = i; j < n; j++) {
            isPalin[i][j] = s.charAt(i) == s.charAt(j)
                && (j - i <= 2 || isPalin[i+1][j-1]);
        }
    }
    int[] dp = new int[n];
    for (int i = 0; i < n; i++) {
        if (isPalin[0][i]) { dp[i] = 0; continue; }
        dp[i] = Integer.MAX_VALUE;
        for (int j = 1; j <= i; j++) {
            if (isPalin[j][i]) dp[i] = Math.min(dp[i], dp[j - 1] + 1);
        }
    }
    return dp[n - 1];
}

Decode Ways

Problem: A message encoded with ‘A’=1, ‘B’=2, …, ‘Z’=26. Given a digit string, how many ways can we decode it?

Example: "226" can be decoded as “BZ” (2,26), “VF” (22,6), or “BBF” (2,2,6) = 3 ways.

This is actually more of a 1D DP problem, but it involves string parsing with choices, which is why it fits here.

State: dp[i] = number of ways to decode s[0..i-1]

function numDecodings(s) {
  if (s[0] === '0') return 0;
  const n = s.length;
  const dp = new Array(n + 1).fill(0);
  dp[0] = 1;                                // empty string = 1 way
  dp[1] = 1;                                // first char (not '0')
  for (let i = 2; i <= n; i++) {
    const oneDigit = parseInt(s[i - 1]);     // take last 1 digit
    const twoDigit = parseInt(s.slice(i - 2, i)); // take last 2 digits
    if (oneDigit >= 1) dp[i] += dp[i - 1];
    if (twoDigit >= 10 && twoDigit <= 26) dp[i] += dp[i - 2];
  }
  return dp[n];
}
def num_decodings(s):
    if s[0] == '0': return 0
    n = len(s)
    dp = [0] * (n + 1)
    dp[0] = 1                                # empty string = 1 way
    dp[1] = 1                                # first char (not '0')
    for i in range(2, n + 1):
        one_digit = int(s[i - 1])            # take last 1 digit
        two_digit = int(s[i - 2:i])          # take last 2 digits
        if one_digit >= 1: dp[i] += dp[i - 1]
        if 10 <= two_digit <= 26: dp[i] += dp[i - 2]
    return dp[n]
static int numDecodings(String s) {
    if (s.charAt(0) == '0') return 0;
    int n = s.length();
    int[] dp = new int[n + 1];
    dp[0] = 1; dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        int oneDigit = s.charAt(i - 1) - '0';
        int twoDigit = Integer.parseInt(s.substring(i - 2, i));
        if (oneDigit >= 1) dp[i] += dp[i - 1];
        if (twoDigit >= 10 && twoDigit <= 26) dp[i] += dp[i - 2];
    }
    return dp[n];
}

The Interval DP Pattern

Here’s the template that works for most interval problems:

for length from 2 to n:        // interval size
  for i from 0 to n-length:    // start index
    j = i + length - 1          // end index
    dp[i][j] = combine(dp[i+1][j], dp[i][j-1], dp[i+1][j-1])

The key insight: we process intervals by increasing length. When we compute dp[i][j], all smaller intervals are already filled in.

Common interview problems in this family:

  • Longest palindromic subsequence/substring
  • Palindrome partitioning
  • Burst balloons
  • Matrix chain multiplication
  • Stone game