DSA DSA In Depth
0%
lesson dynamic-programmingmemoizationspace-optimization

Top-Down Memoization vs Bottom-Up Tabulation & Rolling-Array Space Compression

7 min read Module 6.2: Dynamic Programming Foundations & Classical Archetypes Updated Oct 23, 2026
📄 Cheat Sheet
Dynamic Programming Lecture #1 — Iteration vs Recursion & State Space Saving
Errichto Algorithms Dynamic Programming Lecture #1 — Iteration vs Recursion & State Space Saving Watch on YouTube ↗

Compares top-down DFS memoization against bottom-up topological tabulation and demonstrates how sliding dependency windows compress space.

0:00 / --:--

1. Top-Down Memoization vs. Bottom-Up Tabulation

Given the same state DAG $G = (V, E)$, how should we evaluate it?

  • Top-Down Memoization (DFS Post-Order): Starts at the target state $t$ and recursively evaluates predecessors, caching results in a table or hash map.
    • When Top-Down Wins: When the state space $V$ is sparse—only a tiny fraction of states in the bounding box are actually reachable from the initial state (e.g., Digit DP with tight bounds, Game DP with strange step sizes, or Interval DP with sparse valid cuts).
    • Where Top-Down Loses: Recursion stack frames cost $32$–$64$ bytes per depth (risking stack overflow at depth $N = 10^5$), function call prologue/epilogue overhead, non-contiguous memory access, and inability to apply rolling-array space compression.
  • Bottom-Up Tabulation (Explicit Topological Order): Iterates through nested loops in an order where all dependencies of state $x$ are already computed.
    • When Bottom-Up Wins: Dense state spaces where most states are visited, tight time limits requiring L1 cache locality and auto-vectorization (SIMD), or memory limits requiring $O(M)$ rolling-array compression instead of $O(N \cdot M)$.

Core Invariant — The Dependency Horizon & Rolling-Array Compression

Suppose a 2D recurrence $dp[i][j]$ depends only on states in rows $i$ and $i-1$: $dp[i][j] = f(dp[i-1][\dots], dp[i][\dots])$. Once row $i$ is completely computed, row $i-1$ is dead (out-degree $0$ to any future row $i' > i$). Thus we only ever need to store the active dependency frontier:
1. Two-Row Ping-Pong (dp[2][M]): Index rows via i & 1 (current) and (i - 1) & 1 (previous).
2. Single-Row In-Place (dp[M]): Overwrite dp[j] in-place by choosing the inner loop direction ($j = 0 \to M$ vs. $j = M \to 0$) so we never clobber a value from row $i-1$ that a later $j'$ in row $i$ still needs!

2. Choosing the Inner Loop Direction for 1-Row Compression

When compressing $dp[i][j]$ into a 1D array $dp[j]$:

  • If $dp[i][j]$ depends on $dp[i-1][j - w]$ (strictly smaller column from the previous row, as in 0/1 Knapsack), we must iterate $j$ backward ($W \to w$) so that when computing $dp[j]$, $dp[j - w]$ still holds its untouched value from row $i-1$.
  • If $dp[i][j]$ depends on $dp[i][j - w]$ (smaller column from the current row, as in Unbounded Knapsack), we iterate $j$ forward ($w \to W$) so that $dp[j - w]$ already holds the updated row-$i$ value.
  • If $dp[i][j]$ depends on $dp[i-1][j]$, $dp[i][j-1]$, and $dp[i-1][j-1]$ (as in Edit Distance / LCS), we iterate $j$ forward while saving the single overwritten diagonal scalar prev_diag = dp[j] in a temporary register!
flowchart TD subgraph TwoRow["2D Table O(N*M) vs 1D Rolling Array O(M)"] R0["Row i-1: ... [dp[i-1][j-1]] [dp[i-1][j]] ..."] --> R1["Row i: ... [dp[i][j-1]] [dp[i][j]] ..."] end R1 --> Scalar["1D Array + 1 Register (prev_diag):
prev_diag holds dp[i-1][j-1]
dp[j] holds dp[i-1][j] (before overwrite)
dp[j-1] holds dp[i][j-1] (already updated)"]

Hardware Reality — Why 1D Rolling Arrays Run $4\times$–$10\times$ Faster Than 2D Vector-of-Vectors

Allocating std::vector<std::vector<int>> dp(5000, std::vector<int>(5000)) allocates $5{,}000$ separate heap buffers totaling 100 MB, completely blowing out the CPU's 32 KB L1 cache and 1 MB L2 cache and stalling on DRAM bandwidth. Compressing to a single 1D std::vector<int> dp(5001) occupies just 20 KB—fitting 100% inside L1 data cache for the entire duration of the $2.5 \times 10^7$ loop iterations!

3. Complexity Matrix

DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff
O(N·W) / O(N·D) Edit Graph

Observe why 0/1 Knapsack iterates capacity w downwards (preventing item reuse), how Patience Sorting finds LIS in O(N log N), and how Myers Diff slides along diagonal snakes k = x - y.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Item 1 (wt=3, val=4): Sweep w = 7 downTo 3 so dp[w - 3] always reads the previous item layer.
🔗 Live Pointer & State Transition Graph — DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff Active Pointer Jump/Transition Matched/Optimal
w=0 0 [0] 0 [1] 0 [2] wt=3, v 4 [3] 4 [4] 4 [5] 4 [6] dp[7]=4 4 [7]
[0]0w=0
[1]0
[2]0
[3]4wt=3, val=4
[4]4
[5]4
[6]4
[7]4dp[7]=4
Item 1
weight=3, value=4
Sweep Direction
w = 7 → 3 (Reverse)
Best dp[7]
4
[Step 1] Initialized 1D table with Item 1 (wt=3, val=4) across capacities 3..7.
Evaluation Strategy Time Complexity Auxiliary Space Cache Locality Supports Decision Reconstruction?
Top-Down Memoization (Hash Map) O(|V_reach| + |E_reach|) O(|V_reach| + Depth) Poor (Pointer Chasing) Yes (Full visited table retained)
Top-Down Memoization (Flat Table) O(N * M) O(N * M + Depth) Moderate (DFS jumps) Yes (Full table retained)
Bottom-Up Full 2D Table O(N * M) O(N * M) High (Row-major stream) Yes (O(N + M) backtrace)
Bottom-Up 1D Rolling Array O(N * M) O(min(N, M)) Maximum (L1 Cache resident) Value only (Or Divide & Conquer / Hirschberg)

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <string_view>
#include <vector>

// Space-Optimized O(min(N, M)) Longest Common Subsequence using 1 Row + 1 Diagonal Register
int lcs_rolling_one_row(std::string_view a, std::string_view b) {
    // Always make b the shorter string so our 1D array uses O(min(N, M)) space!
    if (a.size() < b.size()) std::swap(a, b);
    const int n = static_cast<int>(a.size());
    const int m = static_cast<int>(b.size());

    std::vector<int> dp(m + 1, 0);

    for (int i = 1; i <= n; ++i) {
        int prev_diag = 0; // Holds dp[i - 1][j - 1]
        for (int j = 1; j <= m; ++j) {
            int saved_up = dp[j]; // Save dp[i - 1][j] before overwriting dp[j]
            if (a[i - 1] == b[j - 1]) {
                dp[j] = prev_diag + 1;
            } else {
                dp[j] = std::max(dp[j], dp[j - 1]); // max(dp[i-1][j], dp[i][j-1])
            }
            prev_diag = saved_up; // Advance diagonal register for j + 1
        }
    }
    return dp[m];
}

// Two-Row Ping-Pong Template (i & 1) when transitions read multiple non-adjacent columns
long long min_falling_path_ping_pong(const std::vector<std::vector<int>>& grid) {
    const int n = static_cast<int>(grid.size());
    const int m = static_cast<int>(grid[0].size());
    constexpr long long INF = 1LL << 60;

    std::vector<long long> dp[2];
    dp[0].assign(grid[0].begin(), grid[0].end());
    dp[1].assign(m, INF);

    for (int i = 1; i < n; ++i) {
        const int cur = i & 1;
        const int prv = (i - 1) & 1;
        for (int j = 0; j < m; ++j) {
            long long best = dp[prv][j];
            if (j > 0) best = std::min(best, dp[prv][j - 1]);
            if (j + 1 < m) best = std::min(best, dp[prv][j + 1]);
            dp[cur][j] = grid[i][j] + best;
        }
    }
    const auto& last_row = dp[(n - 1) & 1];
    return *std::min_element(last_row.begin(), last_row.end());
}

Production Pitfall — Failing to Reset dp[i & 1] in Ping-Pong Buffers

When using a two-row ping-pong buffer dp[2][M] with dp[cur][j] = std::min(dp[cur][j], ...) (especially in Push DP or multi-transition loops), remember that dp[cur] still holds stale values from row i - 2! You must reset std::fill(dp[cur].begin(), dp[cur].end(), INF) (or overwrite dp[cur][j] unconditionally first) at the start of every outer loop iteration.

Check yourself Question 1 of 4
Concept Check Rolling Array Space Compression Horizon recall

Suppose a 2D DP recurrence $dp[i][j]$ ($0 \le i \le N, 0 \le j \le M$) depends only on $dp[i-1][j]$, $dp[i-1][j-1]$, and $dp[i][j-1]$. If we only need the final scalar value $dp[N][M]$ and we are allowed to choose which dimension is the outer loop, what is the minimum auxiliary space required?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Consider a DP state space of size $N \times W$ where we start at $(0, W)$ and each step transitions from $(i, w)$ to $(i+1, \lfloor w/2 \rfloor)$ or $(i+1, \lfloor w/3 \rfloor)$. Why does Top-Down Memoization (with a hash map or sparse table) asymptotically outperform Bottom-Up Tabulation over $0..N \times 0..W$?

Key Takeaways

Top-down memoization visits only reachable states from the query via DFS, excelling on sparse state spaces.
Bottom-up tabulation eliminates recursion overhead, streams sequentially through cache lines, and enables rolling-array space compression.
When dp[i][j] depends only on row i-1 and row i, space compresses from O(N * M) to O(min(N, M)) by swapping the shorter dimension to the inner loop.
Always verify whether the inner loop must run forward (using current-row states) or backward (preserving previous-row states) when overwriting a single 1D array.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue