DEV Community

Cover image for Dynamic Programming Demystified: From Memoization to Tabulation and Space Optimization
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Dynamic Programming Demystified: From Memoization to Tabulation and Space Optimization

Dynamic Programming Demystified: From Memoization to Tabulation and Space Optimization

Dynamic Programming (DP) is frequently cited as one of the most challenging topics in computer science and technical interviews. Yet, at its core, dynamic programming is simply an optimization technique for solving complex problems by breaking them down into simpler, overlapping subproblems.

This guide demystifies DP by taking you through the systematic process of identifying dynamic programming problems, writing brute-force solutions, applying memoization, converting to bottom-up tabulation, and finally optimizing space complexity down to O(1).

1. The Core Pillars of Dynamic Programming

Before writing code, a problem must satisfy two fundamental properties to be solvable via dynamic programming:

  1. Optimal Substructure: An optimal solution to the problem can be constructed from optimal solutions of its subproblems. If you can express the solution of a problem as a recurrence relation combining solutions to its sub-parts, it has optimal substructure.
  2. Overlapping Subproblems: The recursive algorithm visits the same subproblems repeatedly rather than generating new subproblems constantly. If a recursive tree evaluates the exact same parameters multiple times, DP can cache these results to avoid redundant work.

2. The Classic Benchmark: The Fibonacci Sequence

While mathematically trivial, the Fibonacci sequence serves as the canonical example for illustrating the progression from recursion to optimized DP.

The Naive Recursive Approach

The mathematical definition of Fibonacci is:

F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) for n > 1
Enter fullscreen mode Exit fullscreen mode

Directly translating this into code yields an exponential time complexity of $O(2^n)$:

def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)
Enter fullscreen mode Exit fullscreen mode

As n grows, the call tree branches out exponentially, calculating fib_naive(2) or fib_naive(3) millions of times.

3. Top-Down Approach: Memoization

To fix exponential redundancy, we introduce Memoization (Top-Down DP). We keep a cache (an array or hash map) initialized with null values. When a subproblem is solved, its result is stored in the cache. When encountered again, the cached value is returned immediately.

Python Implementation

def fib_memoization(n, memo=None):
    if memo is None:
        memo = {}

    if n <= 1:
        return n

    if n in memo:
        return memo[n]

    # State transition equation
    memo[n] = fib_memoization(n - 1, memo) + fib_memoization(n - 2, memo)
    return memo[n]
Enter fullscreen mode Exit fullscreen mode
  • Time Complexity: $O(n)$, because each subproblem from $0$ to $n$ is calculated exactly once.
  • Space Complexity: $O(n)$ for the memoization hash map/array and the recursive call stack.

4. Bottom-Up Approach: Tabulation

While memoization is intuitive, it still incurs overhead from the recursive call stack. Tabulation (Bottom-Up DP) eliminates recursion entirely. We iteratively build the solution from the base cases up to the target value n using an array.

Transforming Recursion to Iteration

  1. Initialize a DP table of size n + 1.
  2. Set the base cases explicitly.
  3. Loop from the smallest subproblem to the largest, filling the table using the state transition relation.

Python Implementation

def fib_tabulation(n):
    if n <= 1:
        return n

    dp = [0] * (n + 1)
    dp[1] = 1

    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]

    return dp[n]
Enter fullscreen mode Exit fullscreen mode
  • Time Complexity: $O(n)$
  • Space Complexity: $O(n)$ due to the dp array.

5. Space Optimization: Dropping Dimensions

Look closely at the tabulation equation for Fibonacci: dp[i] = dp[i - 1] + dp[i - 2]. To compute the value at index i, we only require the results from the two preceding indices (i-1 and i-2). We do not need the results from indices 0 through i-3.

We can completely discard the dp array and maintain only two variables representing the previous two states.

Optimized Python Implementation

def fib_optimized(n):
    if n <= 1:
        return n

    prev2 = 0
    prev1 = 1
    current = 0

    for _ in range(2, n + 1):
        current = prev1 + prev2
        prev2 = prev1
        prev1 = current

    return current
Enter fullscreen mode Exit fullscreen mode
  • Time Complexity: $O(n)$
  • Space Complexity: $O(1)$ — constant auxiliary space.

6. Case Study: 0/1 Knapsack Problem

Let us apply this methodology to a classic multi-variable DP problem: The 0/1 Knapsack Problem.

Problem Statement

Given weights and values of n items, put these items in a knapsack of capacity W to get the maximum total value in the knapsack. You cannot fractionally break an item; you must take it entirely or leave it.

1. State Definition

Let dp[i][w] represent the maximum value that can be attained considering the first i items and a maximum weight capacity w.

2. State Transition

For each item i, we have two choices:

  1. Exclude the item: The value is the same as considering i-1 items with weight w: dp[i-1][w].
  2. Include the item (if weight of item i is less than or equal to w): The value is the value of item i plus the optimal value of the remaining capacity w - weight[i] using the previous items: value[i] + dp[i-1][w - weight[i]].

$$\text{dp}[i][w] = \max(\text{dp}[i-1][w], \text{value}[i] + \text{dp}[i-1][w - \text{weight}[i]])$$

Tabulated Implementation (Java)

public class Knapsack {
    public static int knapsack(int capacity, int[] weights, int[] values, int n) {
        int[][] dp = new int[n + 1][capacity + 1];

        for (int i = 0; i <= n; i++) {
            for (int w = 0; w <= capacity; w++) {
                if (i == 0 || w == 0) {
                    dp[i][w] = 0;
                } else if (weights[i - 1] <= w) {
                    dp[i][w] = Math.max(values[i - 1] + dp[i - 1][w - weights[i - 1]], dp[i - 1][w]);
                } else {
                    dp[i][w] = dp[i - 1][w];
                }
            }
        }
        return dp[n][capacity];
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Time Complexity: $O(n \times W)$
  • Space Complexity: $O(n \times W)$ for the 2D table.

Space Optimization for Knapsack

Notice that dp[i][w] only ever relies on the previous row dp[i-1]. We can optimize the 2D matrix into a 1D array of size W + 1.

public static int knapsackOptimized(int capacity, int[] weights, int[] values, int n) {
    int[] dp = new int[capacity + 1];

    for (int i = 0; i < n; i++) {
        // Iterate backwards to avoid using updated values from the same row
        for (int w = capacity; w >= weights[i]; w--) {
            dp[w] = Math.max(dp[w], values[i] + dp[w - weights[i]]);
        }
    }
    return dp[capacity];
}
Enter fullscreen mode Exit fullscreen mode
  • Time Complexity: $O(n \times W)$
  • Space Complexity: $O(W)$ — reducing the space footprint dramatically from matrix dimensions to a single capacity array.

Summary Checklist for Solving DP Problems

  1. Identify the Objective: What is the final question asking for? (Min cost, max profit, number of ways).
  2. Define the State: Write down parameters that uniquely identify a subproblem (e.g., index i, remaining capacity w).
  3. Formulate the Recurrence: Relate dp[state] to smaller subproblem states.
  4. Write Memoized Recursion: Implement top-down with a cache to verify correctness.
  5. Convert to Tabulation: Rewrite iteratively to remove stack overflow risks.
  6. Analyze Dependencies: Check if previous rows or elements can be dropped to achieve optimal space complexity.

Top comments (0)