DevLift
Back to Blog

2D Dynamic Programming: Tables, Grids, and String Comparisons

Grid navigation, sequence alignment, and string transformation — three canonical 2D DSA DP patterns explained with JavaScript, tables, and interview-ready solutions.

Admin
August 2, 20265 min read0 views

2D Dynamic Programming: Tables, Grids, and String Comparisons

There's a point in DP learning where 1D arrays stop being enough. You're not just asking "what's the best at this index?" — you're asking "what's the best when considering the first i characters of one string and the first j characters of another?" That's when you need a 2D table.

Two-dimensional DP shows up in two distinct flavors: grid problems (navigating a 2D board) and string comparison problems (aligning or transforming two sequences). This article covers both with three problems: Unique Paths (the canonical grid DP), Longest Common Subsequence (the foundational string DP), and Edit Distance (the most complex of the three and a genuine favorite at Big Tech).

The Core Mental Model

In 1D DP, you fill a single row from left to right. In 2D DP, you fill a table — typically row by row, left to right. Each cell dp[i][j] represents the answer to a subproblem defined by two parameters: usually the first i elements of one input and the first j elements of another.

The relationship between cells is what changes between problems. Sometimes you only look left or up. Sometimes you look diagonally. The transitions tell you the structure.

Draw the table. Seriously — fill in a few cells by hand on paper before writing code. The pattern you see in the table is exactly the recurrence you'll code.

Problem 1: Unique Paths (LeetCode 62)

The problem: An m × n grid. A robot starts at the top-left corner and must reach the bottom-right corner. It can only move right or down. How many unique paths are there?

Input: m = 3, n = 7
Output: 28

Why This Is DP

To reach cell (i, j), you either came from the left (i, j-1) or from above (i-1, j). The number of ways to reach (i, j) is the sum of both. No choice is made — it's pure counting.

State: dp[i][j] = number of distinct paths to reach cell (i, j) Base cases: the entire top row and left column are all 1 (only one way to traverse a straight line)

Rendering diagram...

Approach 1: Naive Recursion

// O(2^(m+n)) time, O(m+n) space (call stack)
const uniquePaths = (m, n) => {
  const count = (row, col) => {
    if (row === m - 1 || col === n - 1) return 1; // Edge: only one direction possible
    return count(row + 1, col) + count(row, col + 1);
  };
  return count(0, 0);
};

Extremely wasteful — overlapping subproblems everywhere.

Approach 2: 2D DP Table

// O(m*n) time, O(m*n) space
const uniquePaths = (m, n) => {
  const dp = Array.from({ length: m }, () => new Array(n).fill(1));
 
  // First row and first column stay 1 (already set)
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }
 
  return dp[m - 1][n - 1];
};

Approach 3: Space-Optimized DP

Each row only depends on the previous row, so you can compress to a 1D array:

// O(m*n) time, O(n) space
const uniquePaths = (m, n) => {
  let row = new Array(n).fill(1);
 
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      row[j] += row[j - 1]; // row[j] = prev_row[j] (from above) + row[j-1] (from left)
    }
  }
 
  return row[n - 1];
};

This is the version to go for in interviews once you've explained the 2D table version. It shows you understand which dimension is redundant.

💡

There's also an O(1) math solution using combinatorics: you need exactly m-1 down moves and n-1 right moves in some order, so the answer is C(m+n-2, m-1). Worth mentioning, but implement the DP version first — it scales to the obstacle grid variant (LeetCode 63) where the math breaks down.

Problem 2: Longest Common Subsequence (LeetCode 1143)

The problem: Given two strings text1 and text2, return the length of their longest common subsequence. A subsequence can skip characters but must preserve order.

Input: text1 = "abcde", text2 = "ace"
Output: 3 — the LCS is "ace"

Building the Table

dp[i][j] = LCS of the first i characters of text1 and the first j characters of text2.

Two cases for each cell:

  • If text1[i-1] === text2[j-1]: the characters match, so dp[i][j] = dp[i-1][j-1] + 1
  • Otherwise: take the best from ignoring either character: dp[i][j] = max(dp[i-1][j], dp[i][j-1])

Base cases: dp[0][j] = 0 and dp[i][0] = 0 — empty string has no common subsequence.

The completed table for "abcde" vs "ace":

     ""  a  c  e
  ""  0  0  0  0
  a   0  1  1  1
  b   0  1  1  1
  c   0  1  2  2
  d   0  1  2  2
  e   0  1  2  3

Approach 1: Recursion (Brute Force)

// O(2^(m+n)) time, O(m+n) space
const longestCommonSubsequence = (text1, text2) => {
  const lcs = (i, j) => {
    if (i === text1.length || j === text2.length) return 0;
    if (text1[i] === text2[j]) return 1 + lcs(i + 1, j + 1);
    return Math.max(lcs(i + 1, j), lcs(i, j + 1));
  };
  return lcs(0, 0);
};

Approach 2: Memoization (Top-Down DP)

// O(m*n) time, O(m*n) space
const longestCommonSubsequence = (text1, text2) => {
  const m = text1.length, n = text2.length;
  const memo = Array.from({ length: m }, () => new Array(n).fill(-1));
 
  const lcs = (i, j) => {
    if (i === m || j === n) return 0;
    if (memo[i][j] !== -1) return memo[i][j];
 
    if (text1[i] === text2[j]) {
      memo[i][j] = 1 + lcs(i + 1, j + 1);
    } else {
      memo[i][j] = Math.max(lcs(i + 1, j), lcs(i, j + 1));
    }
 
    return memo[i][j];
  };
 
  return lcs(0, 0);
};

Approach 3: Bottom-Up DP Table

// O(m*n) time, O(m*n) space
const longestCommonSubsequence = (text1, text2) => {
  const m = text1.length, n = text2.length;
  // (m+1) x (n+1) table, zero-initialized for base cases
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
 
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
 
  return dp[m][n];
};

This is the canonical interview solution. Clear recurrence, O(m·n) time, and it's the foundation for Edit Distance.

⚠️

Common mistake: using text1[i] and text2[j] instead of text1[i-1] and text2[j-1] in the bottom-up version. The table is 1-indexed for the characters but the strings are 0-indexed. Keep that offset clear or you'll get wrong answers on examples with duplicate characters.

Problem 3: Edit Distance (LeetCode 72)

The problem: Given two strings word1 and word2, return the minimum number of operations to convert word1 to word2. Allowed operations: insert a character, delete a character, replace a character.

Input: word1 = "horse", word2 = "ros"
Output: 3

This is genuinely hard, and it's a real question at FAANG. The complexity comes from having three operations to choose from at each step.

The State and Transitions

dp[i][j] = minimum edit distance between the first i characters of word1 and the first j characters of word2.

Three cases:

  • Characters match (word1[i-1] === word2[j-1]): no operation needed, dp[i][j] = dp[i-1][j-1]
  • Replace: change word1[i-1] to word2[j-1], costs 1 + dp[i-1][j-1]
  • Delete from word1: costs 1 + dp[i-1][j]
  • Insert into word1 (= delete from word2): costs 1 + dp[i][j-1]

When characters don't match: dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])

Base cases: converting to/from an empty string requires i or j operations respectively.

Rendering diagram...

Approach 1: Recursion

// O(3^(m+n)) time without memoization, O(m+n) space
const minDistance = (word1, word2) => {
  const dist = (i, j) => {
    if (i === 0) return j; // Insert j characters
    if (j === 0) return i; // Delete i characters
 
    if (word1[i - 1] === word2[j - 1]) {
      return dist(i - 1, j - 1); // Characters match — no cost
    }
 
    return 1 + Math.min(
      dist(i - 1, j - 1), // Replace
      dist(i - 1, j),     // Delete
      dist(i, j - 1)      // Insert
    );
  };
 
  return dist(word1.length, word2.length);
};

Approach 2: Bottom-Up DP

// O(m*n) time, O(m*n) space
const minDistance = (word1, word2) => {
  const m = word1.length, n = word2.length;
  const dp = Array.from({ length: m + 1 }, (_, i) =>
    Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
  );
 
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1]; // Free — characters already match
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j - 1], // Replace
          dp[i - 1][j],     // Delete word1[i-1]
          dp[i][j - 1]      // Insert (equiv: delete word2[j-1])
        );
      }
    }
  }
 
  return dp[m][n];
};

Approach 3: Space-Optimized

Since each row only depends on the previous row, you can drop to a two-row rolling array. That makes the space O(n) where n = word2.length — the row you allocate. If you also swap the arguments so the shorter string drives the row width, it becomes O(min(m, n)); the version below keeps the arguments as given.

// O(m*n) time, O(n) space
const minDistance = (word1, word2) => {
  const m = word1.length, n = word2.length;
  let prev = Array.from({ length: n + 1 }, (_, j) => j); // Base case: row 0
 
  for (let i = 1; i <= m; i++) {
    const curr = new Array(n + 1);
    curr[0] = i; // Base case: column 0
 
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        curr[j] = prev[j - 1];
      } else {
        curr[j] = 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
      }
    }
 
    prev = curr;
  }
 
  return prev[n];
};

Interview Tips

On Unique Paths: Mention the obstacle grid variant (LeetCode 63) proactively — it's a natural follow-up and only requires one extra check: if grid[i][j] === 1, dp[i][j] = 0. The math solution with combinatorics breaks in that variant, which is why the DP approach is more general.

On LCS: LCS is a building block for several harder problems: Diff algorithms, DNA sequence alignment, and Shortest Common Supersequence (LeetCode 1092). The interviewer might ask you to reconstruct the actual subsequence — do that by backtracking through the DP table from dp[m][n] to dp[0][0], following the path that led to each cell's value.

On Edit Distance: The three-operation recurrence is the hardest part to internalize. A trick that helps: think of dp[i-1][j] as "I deleted from word1" (one fewer character in word1 to match), dp[i][j-1] as "I inserted into word1" (one fewer character in word2 left to cover), and dp[i-1][j-1] as "I replaced or matched." Once you have those semantics, the transitions feel natural rather than magic.

The broader lesson: 2D DP always traces back to "I have two parameters that define the subproblem." Grid problems use coordinates; string problems use prefix lengths. In both cases, filling the table from top-left to bottom-right and reading transitions from neighboring cells is the universal approach.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Two pointers, one pass, O(n) — learn how the variable-size sliding window grows and shrinks to solve substring and subarray problems efficiently.
AdminAugust 2, 20264 min read
Two matrix search problems, two completely different optimal approaches. Learn when to use virtual 1D binary search vs the staircase elimination — the distinction that trips up most candidates.
AdminAugust 2, 20266 min read
Reverse a Linked List — Iterative and Recursive
The three-pointer pattern is the foundation of every linked list reversal. Understand it once and LC 206, 92, and 25 become variations on the same idea.
AdminAugust 2, 20264 min read