Memoization — Top-Down Dynamic Programming
When Top-Down is Natural
Read aloud is unavailable in this browser.
Choosing Your Approach Wisely
Dynamic programming has two primary implementation styles: top-down memoization and bottom-up tabulation. Both solve the same problems and have the same asymptotic complexity. Yet for many problems, one approach is clearly more natural than the other.
Choosing the right approach isn't just about personal preference—it affects code clarity, debugging ease, and sometimes even practical performance. Understanding when top-down memoization is the natural fit will make you a more effective problem solver.
In this page, we'll explore the characteristics that make top-down the superior choice for certain problems, compare the two approaches head-to-head, and develop heuristics for selecting the right one quickly.
By the end of this page, you'll understand when top-down memoization is preferable to bottom-up tabulation, why certain problem structures favor recursive thinking, and how to quickly assess which approach will be cleaner for a given problem.
Top-Down vs Bottom-Up: A Comparative Overview
Before diving into when top-down excels, let's clearly distinguish the two approaches:
Top-Down (Memoization):
- Start with the target problem
- Recursively break into subproblems
- Cache results to avoid recomputation
- Natural recursive thinking: "What do I need to solve this?"
Bottom-Up (Tabulation):
- Start with the smallest subproblems (base cases)
- Iteratively build up to the target
- Fill a table in dependency order
- Constructive thinking: "What can I build from what I have?"
| Aspect | Top-Down (Memoization) | Bottom-Up (Tabulation) |
|---|---|---|
| Direction | Target → Base cases | Base cases → Target |
| Implementation | Recursive with cache | Iterative with table |
| Computation order | Determined by recursion | Explicitly controlled via loops |
| Subproblems computed | Only those reachable from target | All subproblems in the table |
| Stack usage | O(recursion depth) call stack | O(1) stack, just loop variables |
| Space optimization | Harder (full cache persists) | Easier (rolling arrays possible) |
| Debugging | Trace recursive calls | Inspect table at any iteration |
| Derivation ease | Add memo to recursive solution | Must determine fill order |
Both approaches have the same time complexity for a given problem (work per subproblem × number of subproblems). The difference is in constant factors, code clarity, and practical considerations—not algorithmic efficiency.
When Recursive Thinking is Natural
Some problems are inherently recursive in nature—they almost demand to be thought about top-down. When the problem statement itself suggests "solve this by solving smaller versions," memoization is typically the natural fit.
Indicators that a problem suits top-down thinking:
- •Problem has a recursive definition — Mathematical definitions like Fibonacci, Catalan numbers, or combinatorics naturally translate to recursion
- •Decision tree structure — Problems where you make a choice, then recursively solve what remains (knapsack, partition, subset sum)
- •Tree/graph traversal with state — DFS-style exploration where you accumulate state while descending
- •The 'suffix' pattern — Problems defined on suffixes of input (e.g., 'starting from index i...'), since solving for index i depends on solving for index i+1
- •Multiple branching paths — When a single problem can be reduced to subproblems in multiple ways (take/skip, go left/right)
- •Non-obvious iteration order — When it's not clear how to fill a table, but recursive dependencies are obvious
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
from functools import cache # EXAMPLE 1: Catalan Numbers# Natural recursive definition: C(n) = Σ C(i) * C(n-1-i) for i in [0, n-1]# This is fundamentally recursive - the definition IS the recursion @cachedef catalan(n: int) -> int: """ The nth Catalan number. Counts: BST shapes, valid parentheses, polygon triangulations, etc. The definition is recursive, so top-down is the natural approach. """ if n <= 1: return 1 result = 0 for i in range(n): result += catalan(i) * catalan(n - 1 - i) return result # EXAMPLE 2: Subset Sum (Decision Tree)# "Can we select items that sum to target?"# Natural as: "Take first item and solve for (target - item), OR skip and solve for same target" @cachedef subset_sum(nums: tuple, target: int, idx: int = 0) -> bool: """ Can we select elements from nums[idx:] that sum to target? Decision tree: At each element, we can include it or exclude it. Top-down naturally models this binary choice structure. """ if target == 0: return True # Found a valid subset if target < 0 or idx >= len(nums): return False # Invalid or exhausted # Decision: include nums[idx] OR exclude it include = subset_sum(nums, target - nums[idx], idx + 1) exclude = subset_sum(nums, target, idx + 1) return include or exclude # EXAMPLE 3: Word Break (Suffix pattern)# "Can s[i:] be segmented using dictionary words?"# Naturally recursive: try each word starting at i, then solve s[i + len(word):] @cachedef word_break(s: str, word_dict: frozenset, start: int = 0) -> bool: """ Can s[start:] be broken into dictionary words? The suffix pattern: if we match word at start, can we break s[start + len(word):]? """ if start == len(s): return True # Successfully broke entire string for end in range(start + 1, len(s) + 1): word = s[start:end] if word in word_dict and word_break(s, word_dict, end): return True return False # Testsprint(f"Catalan(5) = {catalan(5)}") # 42print(f"subset_sum([3,1,5,9], 8) = {subset_sum((3,1,5,9), 8)}") # True (3+5)print(f"word_break('leetcode', {{'leet','code'}}) = " f"{word_break('leetcode', frozenset(['leet', 'code']))}") # TrueNotice how each of these problems naturally expresses itself as "solve the smaller version first." The top-down approach matches our mental model of the problem.
The Lazy Evaluation Advantage
One of the most significant advantages of top-down memoization is lazy evaluation—subproblems are only computed when actually needed. Bottom-up tabulation, by contrast, computes all subproblems whether or not they're required.
This matters most when:
- The target requires only a fraction of the state space
- The problem involves early termination (finding a valid solution, not counting all)
- Certain branches of the subproblem tree can be pruned entirely
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
# Example where lazy evaluation provides significant savings # SPARSE DEPENDENCY: "Is there a path from (0,0) to (m-1, n-1) # avoiding obstacles, using only right/down moves?"# If the target is blocked or unreachable, we don't need full grid def grid_path_exists(grid: list[list[int]], memo: dict = None, i: int = 0, j: int = 0) -> bool: """ Top-down: Only explores cells reachable from (i, j). If path exists, we find it without touching every cell. """ if memo is None: memo = {} m, n = len(grid), len(grid[0]) # Base cases if i >= m or j >= n or grid[i][j] == 1: # 1 = obstacle return False if i == m - 1 and j == n - 1: return True # Reached target! key = (i, j) if key in memo: return memo[key] # Try moving right or down result = (grid_path_exists(grid, memo, i + 1, j) or grid_path_exists(grid, memo, i, j + 1)) memo[key] = result return result # For comparison, bottom-up must fill entire grid even if blockeddef grid_path_exists_bottomup(grid: list[list[int]]) -> bool: """ Bottom-up: Must process every cell regardless of reachability. """ m, n = len(grid), len(grid[0]) dp = [[False] * n for _ in range(m)] # Must fill entire table for i in range(m - 1, -1, -1): for j in range(n - 1, -1, -1): if grid[i][j] == 1: dp[i][j] = False elif i == m - 1 and j == n - 1: dp[i][j] = True else: down = dp[i + 1][j] if i + 1 < m else False right = dp[i][j + 1] if j + 1 < n else False dp[i][j] = down or right return dp[0][0] # Example with early obstructiongrid = [ [0, 1, 0, 0, 0], # Obstacle at (0,1) blocks quick paths [0, 1, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0],] # Top-down will explore efficiently around obstacles# Bottom-up fills all 25 cells regardless # EARLY TERMINATION example: Find any valid partition# (not count all partitions - just find one) def can_partition(nums: list[int]) -> bool: """ Can we partition nums into two equal-sum subsets? Top-down with early termination: Stop as soon as we find a valid partition. Bottom-up would compute all reachable sums. """ total = sum(nums) if total % 2 != 0: return False target = total // 2 memo = {} def dp(idx: int, remaining: int) -> bool: if remaining == 0: return True # Found valid partition - STOP HERE if idx >= len(nums) or remaining < 0: return False if (idx, remaining) in memo: return memo[(idx, remaining)] # IMPORTANT: Short-circuit OR - if first is True, don't check second result = dp(idx + 1, remaining - nums[idx]) or dp(idx + 1, remaining) memo[(idx, remaining)] = result return result return dp(0, target) # When partition exists early, top-down stops immediately# Bottom-up computes all (idx, sum) combinationsprint(f"Can partition [1,5,11,5]: {can_partition([1, 5, 11, 5])}") # True (1+5+5 = 11)Lazy evaluation is most valuable for: (1) Existence problems ('is there a X?') where finding one is enough, (2) Sparse problems where most of the state space is irrelevant, (3) Problems with heavy pruning where many branches are invalid. If you need to compute ALL subproblems anyway, lazy evaluation provides no benefit.
Complex State Spaces
Top-down memoization truly shines when the state space is complex, irregular, or hard to enumerate. With dictionaries and arbitrary hashable keys, memoization handles states that would be awkward or impossible to fit into a rectangular array.
Complex state scenarios:
- •Non-rectangular domains — States that don't form a grid (e.g., positions on a triangular board, valid game configurations)
- •String-based states — The 'remaining string' in parsing or pattern matching problems
- •Set-based states — Which items have been used/visited (bitmask DP, though often converted to array index)
- •Variable-length state — When state size changes during computation
- •Sparse exploration — When valid states are scattered across a large theoretical domain
- •Multi-constraint problems — States with many dimensions that don't all vary together
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
from functools import cache # EXAMPLE 1: Regular Expression Matching# State: (text_index, pattern_index) - simple enough for arrays# BUT the pattern with '*' creates complex branching that's cleaner recursively @cachedef regex_match(s: str, p: str, i: int = 0, j: int = 0) -> bool: """ Does pattern p match string s? '.' matches any single character '*' matches zero or more of the preceding element Top-down is natural because '*' creates complex case analysis. """ # Pattern exhausted - success only if string also exhausted if j == len(p): return i == len(s) # Check if current characters match first_match = i < len(s) and (p[j] == s[i] or p[j] == '.') # Handle '*' - must be preceded by another character (j >= 1) if j + 1 < len(p) and p[j + 1] == '*': # Either skip the 'x*' pattern (zero occurrences) # Or consume one character and try again return (regex_match(s, p, i, j + 2) or # Skip 'x*' (first_match and regex_match(s, p, i + 1, j))) # Use 'x*' once # No '*' coming - just check current and continue return first_match and regex_match(s, p, i + 1, j + 1) # EXAMPLE 2: Traveling Salesman (Bitmask DP)# State: (current_city, visited_set as bitmask)# Top-down lets us use the bitmask directly @cachedef tsp(distances: tuple, current: int, visited: int, n: int) -> int: """ Minimum cost to visit all cities starting from 'current', having already visited cities encoded in 'visited' bitmask. State: (current_city, visited_mask) Top-down because the state is naturally a bitmask, not array indices. """ # All cities visited - return to start if visited == (1 << n) - 1: return distances[current][0] # Return to city 0 min_cost = float('inf') for next_city in range(n): if visited & (1 << next_city) == 0: # Not yet visited new_visited = visited | (1 << next_city) cost = distances[current][next_city] + \ tsp(distances, next_city, new_visited, n) min_cost = min(min_cost, cost) return min_cost # EXAMPLE 3: Scramble String (Complex String State)# State: Two substrings being compared# Very natural with recursion, awkward to tabulate @cachedef is_scramble(s1: str, s2: str) -> bool: """ Is s2 a 'scrambled' version of s1? (Can be obtained by recursively swapping children of binary split) State is essentially two strings - trivial to memoize with strings as keys, but would require complex indexing for tabulation. """ if s1 == s2: return True if sorted(s1) != sorted(s2): return False if len(s1) <= 1: return s1 == s2 n = len(s1) for i in range(1, n): # Try split at position i # Either halves aren't swapped, or they are if (is_scramble(s1[:i], s2[:i]) and is_scramble(s1[i:], s2[i:])) or \ (is_scramble(s1[:i], s2[n-i:]) and is_scramble(s1[i:], s2[:n-i])): return True return False # Testsprint(f"regex_match('mississippi', 'mis*is*p*.') = " f"{regex_match('mississippi', 'mis*is*p*.')}") # False distances = ((0, 10, 15, 20), (10, 0, 35, 25), (15, 35, 0, 30), (20, 25, 30, 0))print(f"TSP minimum cost: {tsp(distances, 0, 1, 4)}") # Start at 0, visited={0} print(f"is_scramble('great', 'rgeat') = {is_scramble('great', 'rgeat')}") # TrueWith memoization, your state can be anything hashable: tuples, strings, frozensets, bitmasks. Bottom-up tabulation requires mapping states to array indices, which is awkward or impossible for irregular or sparse state spaces.
Easy Derivation from Recursion
Perhaps the strongest practical argument for top-down memoization is its ease of derivation. The path from problem understanding to working solution is often shorter:
- Write the plain recursive solution (often straightforward)
- Add memoization (a mechanical transformation)
- Done — no need to determine iteration order, table dimensions, or boundary handling
Bottom-up tabulation requires additional thinking:
- Define the DP table structure and dimensions
- Identify base cases and how they initialize the table
- Determine the correct iteration order (which cells filled before which)
- Handle boundaries and index arithmetic carefully
This additional cognitive load makes bottom-up more error-prone during initial development.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
"""Comparison: Deriving Top-Down vs Bottom-Up for Edit Distance============================================================= Edit Distance: Minimum edits (insert, delete, replace) to transform s1 → s2""" # STEP 1: Plain recursion (the starting point for both approaches)def edit_distance_recursive(s1: str, s2: str, i: int = None, j: int = None) -> int: """ Plain recursive solution - easy to write from problem understanding. But has exponential time complexity due to overlapping subproblems. """ if i is None: i, j = len(s1), len(s2) # Base cases if i == 0: return j # Insert j characters if j == 0: return i # Delete i characters # If last characters match, no operation needed if s1[i - 1] == s2[j - 1]: return edit_distance_recursive(s1, s2, i - 1, j - 1) # Try all three operations, take minimum return 1 + min( edit_distance_recursive(s1, s2, i, j - 1), # Insert edit_distance_recursive(s1, s2, i - 1, j), # Delete edit_distance_recursive(s1, s2, i - 1, j - 1), # Replace ) # STEP 2A: Add memoization - TRIVIAL transformationfrom functools import cache @cachedef edit_distance_memoized(s1: str, s2: str, i: int = None, j: int = None) -> int: """ Memoized version: Just add @cache decorator. That's literally the only change needed! (Note: We need to make i, j non-None defaults for caching to work properly) """ if i is None: return edit_distance_memoized(s1, s2, len(s1), len(s2)) if i == 0: return j if j == 0: return i if s1[i - 1] == s2[j - 1]: return edit_distance_memoized(s1, s2, i - 1, j - 1) return 1 + min( edit_distance_memoized(s1, s2, i, j - 1), edit_distance_memoized(s1, s2, i - 1, j), edit_distance_memoized(s1, s2, i - 1, j - 1), ) # STEP 2B: Convert to bottom-up - REQUIRES thinking about iteration orderdef edit_distance_bottomup(s1: str, s2: str) -> int: """ Bottom-up tabulation. Required additional decisions: 1. Table dimensions: (len(s1) + 1) × (len(s2) + 1) 2. Base case initialization: First row and column 3. Iteration order: Row by row, left to right (or column by column) 4. Access pattern: dp[i][j] depends on dp[i-1][j-1], dp[i-1][j], dp[i][j-1] """ m, n = len(s1), len(s2) # Create table with proper dimensions dp = [[0] * (n + 1) for _ in range(m + 1)] # Initialize base cases (first row and column) for i in range(m + 1): dp[i][0] = i # Delete i characters for j in range(n + 1): dp[0][j] = j # Insert j characters # Fill table in correct order for i in range(1, m + 1): for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = 1 + min( dp[i][j - 1], # Insert dp[i - 1][j], # Delete dp[i - 1][j - 1], # Replace ) return dp[m][n] # Both give correct resultss1, s2 = "intention", "execution"print(f"Edit distance '{s1}' → '{s2}':")print(f" Memoized: {edit_distance_memoized(s1, s2)}")print(f" Bottom-up: {edit_distance_bottomup(s1, s2)}")# Both return 5In interviews, time is limited and bugs are costly. Top-down memoization lets you get a working solution faster: write the recursive logic, add @cache, and it works. You can always optimize to bottom-up later if needed, but having a working solution first is paramount.
When Bottom-Up is Better
To make informed decisions, we must also understand when bottom-up tabulation is the better choice. Knowing both sides strengthens your ability to select appropriately.
Bottom-up advantages:
- •Space optimization needed — Bottom-up enables rolling arrays (keeping only last 1-2 rows instead of entire table), reducing O(mn) space to O(n)
- •Stack overflow concerns — Deep recursion hits stack limits (often ~1000-10000 deep). Bottom-up uses O(1) stack space
- •All subproblems needed anyway — If you must compute the entire table (e.g., for reconstruction), lazy evaluation provides no benefit
- •Performance-critical code — Bottom-up avoids function call overhead and hash table lookups; cache-friendly memory access patterns are faster
- •Simple linear dependencies — When filling order is obvious (like Fibonacci: left to right), tabulation is straightforward
- •Reconstruction required — Building the actual solution path is often easier by tracing back through a table
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
# EXAMPLE 1: Space Optimization with Rolling Array# Edit distance with O(n) space instead of O(mn) def edit_distance_optimized(s1: str, s2: str) -> int: """ Bottom-up with space optimization. Since each row only depends on the previous row, keep only two rows. Space: O(min(m, n)) instead of O(m × n) This isn't possible with top-down memoization! """ m, n = len(s1), len(s2) # Ensure s2 is the shorter string for better space usage if m < n: s1, s2 = s2, s1 m, n = n, m # Only keep current and previous row prev = list(range(n + 1)) curr = [0] * (n + 1) for i in range(1, m + 1): curr[0] = i for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: curr[j] = prev[j - 1] else: curr[j] = 1 + min(curr[j - 1], prev[j], prev[j - 1]) prev, curr = curr, prev return prev[n] # EXAMPLE 2: Fibonacci with O(1) space# Only need last two values, not entire array def fib_space_optimized(n: int) -> int: """ O(1) space Fibonacci - impossible with memoization. """ if n <= 1: return n prev2, prev1 = 0, 1 for i in range(2, n + 1): curr = prev1 + prev2 prev2, prev1 = prev1, curr return prev1 # EXAMPLE 3: LCS with solution reconstruction# Bottom-up makes backtracking through the table easier def lcs_with_reconstruction(s1: str, s2: str) -> tuple[int, str]: """ Returns both length and the actual LCS string. Reconstruction is natural with bottom-up: trace back through the table. """ m, n = len(s1), len(s2) dp = [[0] * (n + 1) for _ in range(m + 1)] # Fill table for i in range(1, m + 1): for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: dp[i][j] = 1 + dp[i - 1][j - 1] else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # Reconstruct by tracing back lcs = [] i, j = m, n while i > 0 and j > 0: if s1[i - 1] == s2[j - 1]: lcs.append(s1[i - 1]) i -= 1 j -= 1 elif dp[i - 1][j] > dp[i][j - 1]: i -= 1 else: j -= 1 return dp[m][n], ''.join(reversed(lcs)) # Testsprint(f"Edit distance optimized: {edit_distance_optimized('intention', 'execution')}")print(f"Fib(100) O(1) space: {fib_space_optimized(100)}") length, lcs = lcs_with_reconstruction("ABCBDAB", "BDCABA")print(f"LCS length: {length}, LCS: '{lcs}'")Python's default recursion limit is ~1000. For n > 1000, naive memoization will stack overflow. Either increase the limit (sys.setrecursionlimit), use iterative deepening, or switch to bottom-up. This is a real practical limitation of top-down.
Decision Framework: Choosing Your Approach
Given what we've learned, here's a practical framework for choosing between top-down and bottom-up:
Start with Top-Down (Memoization) when:
- •You're prototyping or in an interview — faster to develop and debug
- •The recursive structure is clear — problem naturally decomposes top-down
- •State space is complex — irregular shapes, string keys, bitmasks
- •Early termination possible — existence problems where one solution suffices
- •Not all subproblems needed — sparse exploration of state space
- •Fill order is unclear — recursion handles dependencies automatically
- •Space optimization is critical — need rolling arrays to reduce memory
- •N is large (> 1000) — avoid stack overflow from deep recursion
- •Performance is paramount — avoid function call and hash lookup overhead
- •All subproblems needed — computing entire table anyway
- •Reconstruction required — tracing back through table to build solution
- •Fill order is simple — left-to-right, bottom-to-top is obvious
| Question | Yes → Top-Down | Yes → Bottom-Up |
|---|---|---|
| Is this a learning/interview context? | ✓ | |
| Is the recursion natural and clear? | ✓ | |
| Need early termination? | ✓ | |
| Complex/sparse state space? | ✓ | |
| Is N > 1000? | ✓ | |
| Need O(1) or O(n) space? | ✓ | |
| Need to reconstruct solution? | ✓ (usually easier) | |
| Is performance critical? | ✓ |
Start with top-down memoization for most problems—it's faster to develop correctly. Only convert to bottom-up when you have a specific reason: stack overflow, space constraints, or performance requirements. Don't prematurely optimize to bottom-up.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.