Skip to content
← Journal
12 min readAta Mohammadi

The Frontend Engineer's LeetCode Blueprint, Part 4: The Dynamic Programming Vault & String Automata

useMemo and Reselect are dynamic programming with different names. Part 4 covers top-down versus bottom-up DP, the 0/1 knapsack pattern, Edit Distance, and a Trie-backed Word Search II solver.

Part 4: The Dynamic Programming Vault & String Automata (Memoization, Tabulation, Knapsacks, and Trie Engines)

The Great DP Phobia

Mention "Dynamic Programming" in a room of software engineers and you can feel the room tense up.

For frontend developers, this aversion is ironic. We write code every single day whose entire premise is not recomputing what we already know:

  • React’s useMemo and useCallback prevent recalculating expensive sub-trees.

  • Reselect caches derived state selectors in Redux.

  • HTTP caching via ETag and Cache-Control prevents redundant network round-trips.

Dynamic Programming is simply the formalization of that exact instinct. DP is not a mathematical trick; it is just recursion with a cache, or a directed acyclic graph (DAG) traversed in topological order.

When candidates fail DP in technical screens, it is rarely because they don't understand caching. It is because they cannot identify:

  1. The minimal State variables needed to define a subproblem.

  2. The Transition recurrence linking smaller subproblems to larger ones.

  3. The Base cases where the recursion terminates.

In this article, we will break down the two major paradigms (Top-Down Memoization vs. Bottom-Up Tabulation), solve the two core archetypes that unlock 80% of DP problems (Knapsack and Multi-dimensional String Matching), and construct an industrial-strength Trie (Prefix Tree)—the engine behind autocomplete inputs, routing tables, and complex grid search algorithms.

1. Demystifying DP: Top-Down vs. Bottom-Up

Every dynamic programming problem is fundamentally about breaking a problem into overlapping subproblems with optimal substructure.

                  f(5)
                /      \
             f(4)       f(3)
            /    \     /    \
         f(3)   f(2) f(2)   f(1)  <-- Notice f(3) and f(2) are computed multiple times!

Paradigm A: Top-Down (Memoization)

  • Mental Model: Write the natural, intuitive recursive brute-force solution first. Then wrap it with a cache (Map or lookup table).

  • Pros: Easier to derive during a high-pressure interview. You only compute states that are actually reachable.

  • Cons: Recursion introduces stack frame overhead. In JavaScript, deep recursion risks hitting V8's call stack limit (~10,000 calls).

Paradigm B: Bottom-Up (Tabulation)

  • Mental Model: Invert the problem. Start at the smallest base cases (0 or 1) and fill a table (array) iteratively until you reach the target state.

  • Pros: Zero recursion stack overhead. It runs significantly faster in V8 and allows Space Optimization (e.g., reducing an O(n) space table to two variables O(1)).

  • Cons: Requires thinking backwards and identifying the exact order in which states depend on each other.

V8 Memory Reality: 2D Array Performance

In JavaScript, creating a 2D array is often done like this:

const dp = Array.from({ length: m }, () => new Array(n).fill(0));

Under the hood, this allocates m distinct array objects on the heap, each with its own object header and pointer table. If m = 1000 and n = 1000, you just instantiated 1,001 objects, generating GC pressure and cache misses.

When speed and memory matter, flatten your 2D table into a single contiguous 1D typed array:

// 1D contiguous block: index (r, c) becomes r * cols + c
const dp = new Int32Array(m * n);

This single optimization can double your solution's speed on V8 platforms like LeetCode and Codility.

2. Archetype 1: The 0/1 Knapsack Pattern

The Knapsack archetype covers problems where you have a set of choices, each with a cost and value, and you must make optimal binary decisions: include or exclude.

State Definition Invariant:

Let dp[i][w] be the maximum value (or feasibility) considering the first i items with a remaining weight capacity w.

At each step, we have two branches:

  1. Exclude item i: The answer is simply the previous state without this item: dp[i - 1][w].

  2. Include item i: If w ≥ weight[i], the answer is dp[i - 1][w - weight[i]] + value[i].

Challenge 1: Partition Equal Subset Sum (LeetCode 416 - Medium / Toptal Core)

Problem: Given an integer array nums, return true if you can partition the array into two subsets such that the sum of the elements in both subsets is equal or false otherwise.

Input: nums = [1, 5, 11, 5]
Output: true (Subset [1, 5, 5] and [11] both sum to 11)

Input: nums = [1, 2, 3, 5]
Output: false (Total sum is 11, which cannot be split evenly into integers)

Deconstructing the Reduction:

  1. If the total sum of nums is odd, return false immediately (an odd number cannot be divided into two equal integers).

  2. Let target = totalSum / 2.

  3. The problem reduces to: Can we find a subset of nums that sums up exactly to target?

This is the canonical 0/1 Knapsack problem.

Space Optimization Secret:

In a 2D table, row i only depends on row i - 1. We can compress the entire 2D table into a single 1D array of size target + 1.

The Golden Rule of 1D 0/1 Knapsack: You must iterate your capacity backwards (from target down to num).

Why? If you iterate forward, you might use the same element multiple times (which converts 0/1 Knapsack into Unbounded Knapsack)!

The Production Code:

/**
 * Time Complexity:  O(n * target) - where target = sum / 2
 * Space Complexity: O(target) - Compressed 1D state array
 * @param {number[]} nums
 * @return {boolean}
 */
function canPartition(nums) {
  if (!nums || nums.length < 2) return false;

  let totalSum = 0;
  for (let i = 0; i < nums.length; i++) {
    totalSum += nums[i];
  }

  // An odd sum cannot be divided into two equal integer subsets
  if (totalSum % 2 !== 0) return false;

  const target = totalSum / 2;

  // dp[j] represents whether a subset sum equal to j is achievable
  // Using Uint8Array (0 or 1) for fast V8 memory locality
  const dp = new Uint8Array(target + 1);
  dp[0] = 1; // Base case: sum of 0 is always achievable (empty set)

  for (let i = 0; i < nums.length; i++) {
    const num = nums[i];

    // Must traverse backwards to avoid using the current element more than once
    for (let j = target; j >= num; j--) {
      if (dp[j - num] === 1) {
        dp[j] = 1;
      }
    }

    // Early termination optimization: if target is reachable, stop
    if (dp[target] === 1) return true;
  }

  return dp[target] === 1;
}

3. Archetype 2: Multi-Dimensional String Matching (Edit Distance)

String matching DP powers Git diffs, spellcheckers, and text reconciliation algorithms in client-side text editors.

Challenge 2: Edit Distance (LeetCode 72 - Hard)

Problem: Given two strings word1 and word2, return the minimum number of operations required to convert word1 to word2. You have three operations permitted on a word:

  1. Insert a character

  2. Delete a character

  3. Replace a character

Input: word1 = "horse", word2 = "ros"
Output: 3
Explanation: 
horse -> rorse (replace 'h' with 'r')
rorse -> rose (remove 'r')
rose -> ros (remove 'e')

Defining the Recurrence Relation:

Let dp[i][j] be the minimum edit operations to transform word1[0...i - 1] into word2[0...j - 1].

  1. If characters match (word1[i - 1] === word2[j - 1]):

    No operation needed! Inherit the cost from the diagonal:

    dp[i][j] = dp[i - 1][j - 1]
  2. If characters do not match:

    We must take the best among three possible operations and add 1:

    • Insert: dp[i][j - 1] + 1 (insert word2[j - 1] into word1)

    • Delete: dp[i - 1][j] + 1 (delete word1[i - 1])

    • Replace: dp[i - 1][j - 1] + 1 (replace word1[i - 1] with word2[j - 1])

dp[i][j] = 1 + min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1])

Base Cases:

  • Transforming an empty string into a string of length j requires j insertions: dp[0][j] = j.

  • Transforming a string of length i into an empty string requires i deletions: dp[i][0] = i.

The Production Code (Optimized to Two Rows):

Instead of maintaining an m × n matrix, notice that each row only needs the values from the immediately preceding row. We can solve this using only two rows (O(n) space).

/**
 * Time Complexity:  O(m * n) - where m and n are string lengths
 * Space Complexity: O(n) - using two rolling rows
 * @param {string} word1
 * @param {string} word2
 * @return {number}
 */
function minDistance(word1, word2) {
  const m = word1.length;
  const n = word2.length;

  // Let word2 be the shorter string to minimize space
  if (m < n) return minDistance(word2, word1);

  // prevRow stores dp[i - 1][*], currRow stores dp[i][*]
  let prevRow = new Int32Array(n + 1);
  let currRow = new Int32Array(n + 1);

  // Initialize base case for converting empty word1 to word2[0...j]
  for (let j = 0; j <= n; j++) {
    prevRow[j] = j;
  }

  for (let i = 1; i <= m; i++) {
    // Base case: converting word1[0...i] to empty word2 requires i deletions
    currRow[0] = i;

    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        currRow[j] = prevRow[j - 1]; // Characters match, no cost added
      } else {
        const insertOp = currRow[j - 1];
        const deleteOp = prevRow[j];
        const replaceOp = prevRow[j - 1];

        currRow[j] = 1 + Math.min(insertOp, deleteOp, replaceOp);
      }
    }

    // Swap row references for the next iteration
    const temp = prevRow;
    prevRow = currRow;
    currRow = temp;
  }

  return prevRow[n];
}

4. Tries (Prefix Trees): Autocomplete and String Engines

A Trie is a specialized tree used to store associative arrays where keys are usually strings.

In frontend engineering, Tries are the foundation of:

  • Instant search autocomplete and typeahead components.

  • URL route matchers (e.g., parsing path segments in file-system-based routers).

  • IP lookup tables and dictionary validation engines.

       (root)
      /      \
    'c'      'a'
    /          \
  'a'          'p'
  /  \           \
't'  'r'         'p'
                 /
               'l'
               /
             'e'
Words: "cat", "car", "apple"

The Standard Trie Node Structure

class TrieNode {
  constructor() {
    this.children = new Map(); // char -> TrieNode (or new Array(26) for lowercase a-z)
    this.isEndOfWord = false;
    this.word = null;          // Optional: store full word at leaf to avoid string reconstruction
  }
}

Challenge 3: Word Search II (LeetCode 212 - Hard / Toptal Boss Level)

This problem combines Backtracking on a 2D Grid with a Prefix Trie. It is one of the most frequently asked problems to test whether an engineer can prevent combinatorial explosions.

Problem: Given an m × n board of characters and a list of strings words, return all words on the board. Each word must be constructed from letters of sequentially adjacent cells (horizontally or vertically neighboring). The same letter cell may not be used more than once in a word.

Board:
[
  ['o','a','a','n'],
  ['e','t','a','e'],
  ['i','h','k','r'],
  ['i','f','l','v']
]
Words: ["oath", "pea", "eat", "rain"]
Output: ["eat", "oath"]

Why Naive DFS Fails:

If you run DFS for each word individually across an m × n board, the runtime is O(W × m × n × 4^L), where W is the number of words and L is the max length of a word. With W = 30,000, this results in an immediate Time Limit Exceeded.

The Trie Optimization:

  1. Insert all search words into a single Trie.

  2. Run DFS across the board once. Instead of searching for a specific word, let the board walk through the Trie!

  3. If the current path on the board does not match any prefix in the Trie, prune that branch immediately.

  4. Critical Pruning Optimization (Beating TLE on V8):

    • When a word is found and added to results, clear node.word = null so it is not recorded twice.

    • Incrementally remove leaf nodes from the Trie. If a leaf node has no remaining children, delete it from its parent. This reduces the search space dynamically while searching.

The Production Code:

class TrieNode {
  constructor() {
    this.children = new Map();
    this.word = null; // Holds the completed string when this node ends a word
  }
}

/**
 * Time Complexity:  O(M * N * 4^L) worst case, but pruned exponentially by Trie prefixes
 * Space Complexity: O(Total characters in words) - Trie space
 * @param {character[][]} board
 * @param {string[]} words
 * @return {string[]}
 */
function findWords(board, words) {
  const root = new TrieNode();

  // 1. Build Trie
  for (let i = 0; i < words.length; i++) {
    const word = words[i];
    let curr = root;
    for (let j = 0; j < word.length; j++) {
      const char = word[j];
      if (!curr.children.has(char)) {
        curr.children.set(char, new TrieNode());
      }
      curr = curr.children.get(char);
    }
    curr.word = word; // Store full word at terminal node
  }

  const rows = board.length;
  const cols = board[0].length;
  const result = [];

  // 2. DFS Traversal with Backtracking
  function dfs(r, c, parentNode) {
    const char = board[r][c];
    const currNode = parentNode.children.get(char);

    // If current character doesn't match Trie path, abort
    if (!currNode) return;

    // Check if we found a valid word
    if (currNode.word !== null) {
      result.push(currNode.word);
      currNode.word = null; // Avoid duplicate collection
    }

    // Mark current cell as visited using in-place character masking
    board[r][c] = '#';

    // Explore 4 directional neighbors: Up, Down, Left, Right
    if (r > 0 && board[r - 1][c] !== '#') dfs(r - 1, c, currNode);
    if (r < rows - 1 && board[r + 1][c] !== '#') dfs(r + 1, c, currNode);
    if (c > 0 && board[r][c - 1] !== '#') dfs(r, c - 1, currNode);
    if (c < cols - 1 && board[r][c + 1] !== '#') dfs(r, c + 1, currNode);

    // Backtrack: Restore board character
    board[r][c] = char;

    // Trie Leaf Pruning: If currNode has no children left, remove it from parent
    if (currNode.children.size === 0) {
      parentNode.children.delete(char);
    }
  }

  // 3. Initiate DFS from every cell on the board
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (root.children.has(board[r][c])) {
        dfs(r, c, root);
      }
    }
  }

  return result;
}

Look at the cleanup on line 64: board[r][c] = '#'. Using in-place cell mutation avoids allocating a visited = new Set() and string serializations like '${r},${c}', keeping space complexity strictly O(1) auxiliary during traversal.

5. Architectural Checklist for DP & Trie Challenges

  1. State Independence: Ensure that your memoization cache keys encapsulate all variables that change during recursion. If your recursive function is fn(index, remainingWeight), your cache key must depend on both index and remainingWeight.

  2. Primitive Keys for Maps: If using a JavaScript Map for memoization with multiple parameters, do not use object keys unless you serialize them. Better yet, use a nested array or 1D index:

    // SLOW: string serialization allocates thousands of strings
    memo.set(`${i}-${w}`, val);
    
    // FAST: Flattened index or 2D array
    memo[i * (maxWeight + 1) + w] = val;
  3. In-Place Board Masking: In grid-based backtracking (like Word Search or Island Perimeter), avoid creating a duplicate visited 2D array if the problem allows mutating the input. Mask with an invalid character ('#') and restore it on backtrack.

6. The Part 4 Practice Gauntlet

Complete these to cement your dynamic programming and string search fundamentals:

  1. LeetCode 322 (Medium): Coin Change (Classic Unbounded Knapsack / BFS minimization).

  2. LeetCode 416 (Medium): Partition Equal Subset Sum (Implement the 1D backward traversal from memory).

  3. LeetCode 300 (Medium): Longest Increasing Subsequence (Solve in O(n^2) DP first, then discover the O(n log n) patience sort).

  4. LeetCode 72 (Hard): Edit Distance (Implement using two-row space optimization).

  5. LeetCode 208 (Medium): Implement Trie (Prefix Tree) (Fundamental component architecture).

  6. LeetCode 212 (Hard): Word Search II (Implement Trie + DFS with leaf-node pruning).

What’s Coming Next in Part 5

In the grand finale, we bring everything together:

  • Advanced Backtracking & Constraint Satisfaction: N-Queens, Sudoku Solver, and branch-pruning patterns.

  • Bit Manipulation Mastery: Bitwise operations, bitmasks for subset DP, and bitsets for ultra-fast set operations.

  • The Toptal / Codility Survival Strategy: Time allocation, surviving corner cases, handling zero-visibility test suites, and how to structure your 60-minute live coding session for maximum score.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.