DSA Field Notes
Course map / 11 Dynamic Programming
NeetCode · DSA for Beginners · Lessons 32-33

Dynamic Programming Field Notes

Recursion that remembers. Fibonacci in 1-D, unique paths in 2-D, and the same four moves both times: brute force, find the repeats, add a cache, flip it bottom-up. Every diagram shows which call was saved.

0Why dynamic programming exists

Analogy

A homework set where the same small sub-question shows up inside ten different problems. One student re-solves it ten times. Another solves it once, writes the answer in the margin of the notebook, and just looks it up the other nine times. Same answers, a tenth of the work. The notebook is the cache. Dynamic programming is "recursion with a notebook".

NeetCode says it plainly: DP is considered one of the hardest topics in CS, mostly because people overcomplicate it. Some DP problems are very hard, but the concept is not. Dynamic programming (DP) is an optimized version of recursion. You take a big problem, break it into smaller problems, and make sure each small problem is solved only once. That is usually better in time, and often in space, than plain recursion.

Start with the recursion you already know. Fibonacci, F(n) = F(n-1) + F(n-2):

def bruteForce(n):
    if n <= 1:
        return n
    return bruteForce(n - 1) + bruteForce(n - 2)

Every call makes two more calls, so each level of the call tree doubles. Time is O(2n). Look at the tree for bruteForce(5) and count how often the same subtree appears.

F(3) subtree, again F(3) subtree, again F(5) F(4) F(3) F(3) F(2) F(2) F(1) F(2) F(1) F(1) F(0) F(1) F(0) F(1) F(0) F(2) is computed 3 times (orange). F(3) is computed 2 times (dashed). 15 calls for n = 5. Each level doubles → O(2^n). For n = 100 the tree has more calls than you can wait for.
The waste is the repeated subtrees. F(2) is calculated three times, and each time its whole subtree is rebuilt. For n = 5 that is a few extra calls. For fibonacci of 100 the repeated work grows exponentially and the function never finishes. DP removes exactly this repetition.

Two words you need for the rest of the page. A subproblem is a smaller copy of the same question: F(3) is a subproblem of F(5). Subproblems are overlapping when the same one is asked more than once. Overlapping subproblems are the signal that DP will help.

11-D DP, top-down: recursion plus a cache

Memoization is a fancy word for caching

Memoization means: once a computation is done, store the result in a cache (a hash map or an array). If the same computation is asked again, retrieve it from the cache instead of recomputing. For fibonacci the hash map key is the index n and the value is F(n). A hash map lookup is O(1).

Recursion with memoization is called top-down DP, because you still start at the root of the tree (F(5)) and recurse down to the base cases. Only three lines change compared with the brute force:

def memoization(n, cache):
    if n <= 1:
        return n
    if n in cache:               # ② new base case: already solved? return it
        return cache[n]

    cache[n] = memoization(n - 1, cache) + memoization(n - 2, cache)
    return cache[n]               # ③ store, then return

memoization(5, {})                # ① the cache is passed in as a parameter → 5
  1. Add a cache parameter (a hash map).
  2. Add a base case: if n is in the cache, return the cached value instead of recursing.
  3. After computing F(n), store it in the cache before returning.
F(5) ④ F(4) ③ F(3) ② F(2) ① F(1) base F(1) base F(0) base F(3) → hit cache[3] = 2, O(1) F(2) → hit cache[2] = 1, O(1) cache (hash map) ① 2 : 1 ② 3 : 2 ③ 4 : 3 ④ 5 : 5 key = n, value = F(n) filled in the order ①②③④ real computations: F(5) F(4) F(3) F(2) F(1) F(0) = n + 1 = 6 plus 2 cache hits and 1 extra base case → 9 calls instead of 15
The repeated subtrees collapse into one cache lookup each. Orange marks what changed: the second F(3) and the second F(2) are no longer computed; they are read from the cache in O(1). Only n + 1 = 6 fibonacci values are ever computed. The cache fills from the smallest solved subproblem up, in the order ①②③④, even though the calls started at the top.

Time O(n): each fibonacci number is computed once. Space O(n): the cache holds up to n entries, and the recursion stack is also n deep at its deepest point.

In real Python: functools.cache

Python ships the cache as a decorator. @lru_cache(maxsize=None) or the shorter @cache (Python 3.9+) wraps a function so that every call with the same arguments returns the stored result. You write the brute force and add one line above it.

from functools import lru_cache, cache

@lru_cache(maxsize=None)          # unlimited cache, keyed by the arguments
def fib_lru(n):
    if n <= 1:
        return n
    return fib_lru(n - 1) + fib_lru(n - 2)

@cache                            # same thing, shorter (3.9+)
def fib_cache(n):
    if n <= 1:
        return n
    return fib_cache(n - 1) + fib_cache(n - 2)

fib_cache(100)                    # 354224848179261915075, instantly
fib_lru.cache_info()              # shows hits and misses - proof the cache is working
Keep in mind

The decorator does exactly the three edits from the lesson, for you: it adds the hidden hash map, checks it before running the body, and stores the result after. In an interview say "I will memoize this with a dictionary" and then write either version. Arguments must be hashable (int, str, tuple - not list) because they become dictionary keys.

21-D DP, bottom-up: fill a table, then shrink it

Tabulation: base cases first, work up

Top-down starts at F(5) and asks for smaller values. Bottom-up DP (also called tabulation) goes the other way: compute the base cases first, then use them to build the next value, and so on up to n. No recursion. Use an array of size n + 1 where index i holds F(i). For n = 5 the finished array is [0, 1, 1, 2, 3, 5].

def tabulation(n):
    if n < 2:
        return n
    table = [0] * (n + 1)         # index i will hold F(i)
    table[1] = 1                  # base cases: table[0] = 0, table[1] = 1
    for i in range(2, n + 1):
        table[i] = table[i - 1] + table[i - 2]
    return table[n]
table[5] = table[4] + table[3] = 3 + 2 0 1 1 2 3 5 = F(5), return this i 0 1 2 3 4 5 base cases fill order: left to right, each cell uses the two before it
Same numbers as the tree, no recursion. The base cases sit at index 0 and 1. Every other cell is the sum of the two cells to its left, so by the time you reach index i, both inputs already exist. Time O(n), space O(n) for the table.

Do we need the whole array? Only the last two

Look at the arrows in the picture: cell 5 reads only cells 3 and 4. Cells 0, 1, 2 are never touched again. We only ever use the previous two values, so keep just those two and compute in order. NeetCode uses an array of size 2, dp = [F(i-2), F(i-1)], and slides it forward with a temporary variable: store dp[1] in tmp, compute the next number into dp[1], then overwrite dp[0] with tmp. Space drops to O(1).

def dp(n):
    if n < 2:
        return n

    dp = [0, 1]                  # [F(0), F(1)]  - only two slots, ever
    i = 2
    while i <= n:
        tmp = dp[1]               # ① remember F(i-1) before it is overwritten
        dp[1] = dp[0] + dp[1]     # ② F(i) = F(i-2) + F(i-1)
        dp[0] = tmp               # ③ slide: old F(i-1) becomes the new "two back"
        i += 1
    return dp[1]
dp[0] dp[1] before tmp dp[0] dp[1] after i=2 0 1 ① 1 ② dp[0] + dp[1] ③ 1 1 F(2) = 1 i=3 1 1 1 1 2 F(3) = 2 i=4 1 2 2 2 3 F(4) = 3 i=5 2 3 3 3 5 F(5) = 5 ← answer ① tmp = dp[1] ② dp[1] = dp[0] + dp[1] ③ dp[0] = tmp (the "after" pair is the next row's "before")
Two slots slide along the fibonacci sequence. The orange pair on the right of each row is the input pair of the next row. tmp exists only because ② destroys the old dp[1] before ③ needs it. Time O(n) (we iterate up to n), space O(1) (always an array of size 2).
itmp = dp[1]dp[0]dp[1]meaning
start-01dp = [F(0), F(1)]
2111dp[1] = 0 + 1; dp = [F(1), F(2)]
3112dp[1] = 1 + 1; dp = [F(2), F(3)]
4223dp[1] = 1 + 2; dp = [F(3), F(4)]
5335dp[1] = 2 + 3; dp = [F(4), F(5)]
6i <= n is False. Return dp[1] = 5.

The idiomatic Python version

Python's tuple assignment evaluates the whole right side before assigning, so tmp disappears. Same algorithm, same O(n) time and O(1) space.

def fib_two_vars(n):
    a, b = 0, 1                  # a = F(i), b = F(i+1)
    for _ in range(n):
        a, b = b, a + b           # slide both by one; right side computed first
    return a
Why it is called 1-D

Every subproblem is identified by one number, n, and its result fits in a one-dimensional structure of size proportional to n (the cache, or the table). That is 1-D DP. When a subproblem needs two numbers to describe it, you get 2-D DP, which is the next lesson.

32-D DP: unique paths in a grid

Analogy

City blocks. You stand at the top-left corner and want the bottom-right corner. Streets only go down or right. How many different walks are there? Ask the same question from any corner: "how many ways from here?" The answer from a corner is the ways from the corner below plus the ways from the corner to the right. That is a subproblem with two coordinates, r and c.

When the result of a subproblem depends on two variables, the cache is a grid instead of a list: that is 2-D DP. The problem from the lesson: count the number of unique paths from the top-left to the bottom-right of a grid with rows rows and cols columns, moving only down or right. Because you only ever increase r or c, the only way to leave the grid is r == rows or c == cols.

Brute force: two choices per step

From (0, 0) you have at most two choices, down or right. The number of unique paths from a coordinate is the sum of the two recursive calls. Base cases: out of bounds returns 0 (that path is dead), the bottom-right cell returns 1 (one complete path).

# Brute Force - Time: O(2 ^ (n + m)), Space: O(n + m)
def bruteForce(r, c, rows, cols):
    if r == rows or c == cols:          # fell off the grid → 0 paths
        return 0
    if r == rows - 1 and c == cols - 1:  # reached the target → 1 path
        return 1

    return (bruteForce(r + 1, c, rows, cols) +   # go down
            bruteForce(r, c + 1, rows, cols))     # go right

bruteForce(0, 0, 4, 4)     # 20
c=0 c=1 c=2 c=3 c=4 r=0 r=1 r=2 r=3 r=4 0 c == cols or r == rows → return 0 start 1 end (1,1) A B A: down, then right B: right, then down Both routes land on (1, 1). bruteForce(1, 1) runs twice, and everything under it runs twice. (2, 2) is reached 6 times. (3, 3) is reached 20 times, once per path. 99 calls for a grid of 16 cells. Two choices per step, about n + m steps per path → O(2^(n+m)) time, O(n + m) recursion depth for space.
Same cell, two routes, two computations. Going down then right reaches (1, 1); going right then down reaches the same (1, 1). The brute force does not remember, so it recounts the paths from (1, 1) twice, and deeper cells many more times. Small grids are fine; the number of calls grows exponentially with n rows and m columns.

Top-down: cache the answer for each coordinate

Same code plus a cache parameter. The cache is a rows × cols grid of zeros, and cache[r][c] stores the number of unique paths from that coordinate. NeetCode checks cache[r][c] > 0 to mean "already computed"; that works here because every in-bounds cell has at least one path, so 0 can only mean "not yet computed".

# Memoization - Time and Space: O(n * m)
def memoization(r, c, rows, cols, cache):
    if r == rows or c == cols:
        return 0
    if cache[r][c] > 0:                 # already solved this coordinate
        return cache[r][c]
    if r == rows - 1 and c == cols - 1:
        return 1

    cache[r][c] = (memoization(r + 1, c, rows, cols, cache) +
                   memoization(r, c + 1, rows, cols, cache))
    return cache[r][c]

cache = [[0] * 4 for _ in range(4)]   # rows x cols grid of zeros
memoization(0, 0, 4, 4, cache)         # 20
cache[r][c] = paths from (r, c) 20 10 4 1 10 6 3 1 4 3 2 1 1 1 1 1 base values become known from the bottom-right bottom row and right column: only one way (straight) → all 1 (0,0) = (1,0) + (0,1) = 10 + 10 = 20 Every cell = cell below + cell right. Computing row 1, e.g. (1,0) = 4 + 6 = 10, is what makes row 0 cheap. Dashed: (1,1) = 6 is computed once, when (1,0) asks for it. Later (0,1) asks again and gets a cache hit - no recomputation. Each of the n·m cells is computed once → time O(n·m), space O(n·m) for the cache. Note: the code returns 1 for the target cell before storing it, so cache[3][3] stays 0. Harmless: nothing ever reads it from cache.
The cache fills from the bottom-right corner up. The calls start at (0, 0), but the first value that becomes known is at the target, then the cells next to it, exactly like the fibonacci cache filling from F(2) upward. Once (1, 1) is stored, the second route to it is a lookup. Time and space are both O(n · m), one slot per cell.

Bottom-up: start from the target, build rows upward

The picture above tells you the iterative order. Values become known from the bottom-right, so the loop must run bottom to top and, inside each row, right to left. Order matters because every value comes from the base-case side: below and to the right. And just like fibonacci, you do not need the whole grid in memory. To compute a row you only need the row below it. Keep two rows: prevRow (the row below) and curRow (the one being built). When a row is finished, it becomes the new prevRow.

# Dynamic Programming - Time: O(n * m), Space: O(m), where m is num of cols
def dp(rows, cols):
    prevRow = [0] * cols                       # imaginary row below the grid: all 0

    for r in range(rows - 1, -1, -1):           # bottom row first, up to row 0
        curRow = [0] * cols
        curRow[cols - 1] = 1                   # right-most column is the base case: 1
        for c in range(cols - 2, -1, -1):       # right to left
            curRow[c] = curRow[c + 1] + prevRow[c]  # right neighbor + cell below
        prevRow = curRow                        # slide up
    return prevRow[0]                           # top-left of the last row built

dp(4, 4)    # 20
r=0 r=1 r=2 r=3 init answer 20 10 4 1 10 6 3 1 4 3 2 1 curRow[1] prevRow[0] 1 1 1 1 0 0 0 0 bottom to top right to left inside a row curRow[c] = curRow[c + 1] + prevRow[c] curRow[0] = 3 + 1 = 4 (row r = 2) prevRow starts as [0, 0, 0, 0]: the row "below" the grid, so the bottom row adds nothing from below. Every curRow starts as [0, 0, 0, 1]: the right-most column is the base-case value 1. Inner loop c = cols-2 ... 0: right neighbor is already in curRow, cell below is in prevRow. After the row: prevRow = curRow. The old row is dropped → only two rows in memory, O(m). Loop ends after r = 0. Return prevRow[0] = 20.
Each row is built right to left from the row below it. Orange shows one step: cell (2, 0) = right neighbor 3 + cell below 1 = 4. The row of 0s under the grid is a trick to avoid a special case for the bottom row. Same O(n · m) time as top-down, but the space drops to O(m): only prevRow and curRow exist at any time.

Trace of dp(3, 3), every row. The answer for a 3 × 3 grid is 6; for 4 × 4 it is 20; for 3 × 7 it is 28.

rcurRow startc = 1c = 0curRow endthen prevRow =
initprevRow = [0, 0, 0][0, 0, 0]
2[0, 0, 1]1 + 0 = 11 + 0 = 1[1, 1, 1][1, 1, 1]
1[0, 0, 1]1 + 1 = 22 + 1 = 3[3, 2, 1][3, 2, 1]
0[0, 0, 1]1 + 2 = 33 + 3 = 6[6, 3, 1][6, 3, 1]
endLoop over r is done. Return prevRow[0] = 6.

The idiomatic top-down version

With @cache the coordinate pair (r, c) becomes the dictionary key automatically, and the "is it cached?" check disappears from your code. A nested function keeps rows and cols in scope so the cache key stays small.

from functools import cache

def uniquePaths_cache(rows, cols):
    @cache
    def go(r, c):
        if r == rows or c == cols:
            return 0
        if r == rows - 1 and c == cols - 1:
            return 1
        return go(r + 1, c) + go(r, c + 1)
    return go(0, 0)

uniquePaths_cache(3, 7)    # 28

Top-down vs bottom-up, side by side

TOP-DOWN: start at the answer, ask for smaller pieces, cache on the way back asks F(0) F(1) F(2) F(3) F(4) F(5) base base answer builds BOTTOM-UP: start at the base cases, build bigger answers in order, stop at n
Both compute every F(i) exactly once; they only differ in who decides the order. Top-down lets the recursion find the order for you (and pays a call stack for it). Bottom-up makes you state the order yourself (and rewards you with no recursion and a chance to shrink the space).
Top-down (memoization)Bottom-up (tabulation)
DirectionRoot of the tree down to the base casesBase cases up to the answer
StyleRecursion + a cache (hash map or grid)Loops + a table (array or grid)
Order of subproblemsFound automatically by the recursionYou must choose it: from the base-case side (left to right, or bottom-right to top-left)
Work doneOnly the subproblems actually neededEvery cell of the table, needed or not
TimeO(n) / O(n·m)O(n) / O(n·m)
Spacecache + recursion stacktable, often shrinkable to O(1) or O(m)
RiskRecursion limit on deep inputs (Python default about 1000 frames)Wrong fill order reads a cell that is still 0
Python tool@cache on the brute forcedp = [...], two variables, or prevRow / curRow
Reach for it whenYou have the recursion and want a fast, safe speed-upYou need the best space, or n is too deep for recursion

4The 4-step DP recipe

Both lessons follow the same four moves. This is the order to use in an interview, out loud, and you can stop at any step that is good enough for the constraints.

  1. Write the brute-force recursion. Decide what one call means ("paths from (r, c)", "F(n)"), write the base cases, and write the choices as recursive calls added or compared together. This is the recurrence: the formula that relates a problem to its smaller copies.
  2. Find the repeated subproblems. Draw the call tree for a small input. If the same call appears twice (F(2) three times, (1, 1) twice), you have overlapping subproblems and DP will pay off. The parameters that identify a call are the state: one number → 1-D, two numbers → 2-D.
  3. Add a cache. Check the cache before recursing, store after computing. In Python: @cache, or an explicit dictionary / grid parameter. Time drops from exponential to (number of states) × (work per state).
  4. Flip to bottom-up and shrink the space. Read the fill order off the cache picture (from the base cases toward the answer), replace recursion with loops, then keep only the rows or values the recurrence actually reads: two variables for fibonacci, two rows for the grid.
Do not skip step 1

Most people who "find DP hard" try to write the bottom-up table first. The table is step 4. The recursion is where you find the states and the base cases; the table is only a re-ordering of it. Write the slow version, make it correct on a tiny input, then speed it up.

5Moves that solve most DP problems

The lessons teach fibonacci and unique paths. Interviews reuse the same two shapes with different words. Learn the shapes, and learn how to notice that a problem is one of them.

Move 1: The memo decorator (top-down template)

When to use: you have a correct recursion and it is too slow. Wrap it. Works for any number of state parameters, as long as they are hashable.

from functools import cache

def solve_with_memo(n):
    @cache
    def f(i):                       # f(i) = answer for state i
        if i <= 1:                  # base case(s)
            return i
        return f(i - 1) + f(i - 2)  # recurrence: combine smaller states
    return f(n)

Move 2: The 1-D "two back" family: dp[i] from dp[i-1] and dp[i-2]

When to use: a line of positions, and the answer at position i depends only on the one or two positions before it. Fibonacci, climbing stairs, house robber, min cost climbing stairs all have this shape. Keep two variables and slide.

# Climbing Stairs (LC 70): ways to reach the top taking 1 or 2 steps at a time
def climbStairs(n):
    one, two = 1, 1              # ways from step n, and from step n-1 (both 1)
    for _ in range(n - 1):
        one, two = one + two, one # exactly the fibonacci slide
    return one                    # climbStairs(5) → 8

# House Robber (LC 198): rob non-adjacent houses for the max total
def rob(nums):
    rob1, rob2 = 0, 0            # best total ending two back, and one back
    for n in nums:
        rob1, rob2 = rob2, max(rob1 + n, rob2)  # rob this house, or skip it
    return rob2                   # rob([2, 7, 9, 3, 1]) → 12

# Min Cost Climbing Stairs (LC 746): cheapest way past the last step
def minCostClimbingStairs(cost):
    a, b = 0, 0                  # min cost to stand on step i-2, i-1
    for i in range(2, len(cost) + 1):
        a, b = b, min(b + cost[i - 1], a + cost[i - 2])
    return b                      # minCostClimbingStairs([10, 15, 20]) → 15
nums 2 7 9 3 1 best 2 7 11 ? ? rob1 rob2 = max(2 + 9, 7) = 11 rob this house, or keep the best without it then slide: rob1 = 7, rob2 = 11. Final answer rob([2,7,9,3,1]) = 12 (rob 2, 9, 1)
The "best so far" row is fibonacci with a max instead of a plus. Each new cell reads only the two cells before it, so two variables are enough. The only thing that changes between problems is the one-line recurrence.

Move 3: The 2-D grid family: cell from below and right (or above and left)

When to use: a grid, moves restricted to two directions, and a question about counting or optimizing paths. Unique paths counts (plus), min path sum optimizes (min). Same two-row loop; only the combine step changes, and for a cost grid you add the cell's own value.

# Minimum Path Sum (LC 64): cheapest down/right path from top-left to bottom-right
def minPathSum(grid):
    rows, cols = len(grid), len(grid[0])
    prev = [float("inf")] * cols                # "below the grid" is impossible → infinity
    for r in range(rows - 1, -1, -1):
        cur = [0] * cols
        for c in range(cols - 1, -1, -1):
            if r == rows - 1 and c == cols - 1:
                cur[c] = grid[r][c]              # base case: the target itself
            else:
                right = cur[c + 1] if c + 1 < cols else float("inf")
                down = prev[c]
                cur[c] = grid[r][c] + min(right, down)   # min instead of +
        prev = cur
    return prev[0]      # minPathSum([[1,3,1],[1,5,1],[4,2,1]]) → 7

Move 4: How to spot DP in a problem statement

Three kinds of questions, each answered over a series of choices, are DP suspects:

  • "How many ways" to reach / build / arrange something (unique paths, climbing stairs, coin change II). The recurrence adds.
  • "Minimum or maximum" cost, length, profit, sum over a sequence of choices (min path sum, house robber, longest increasing subsequence, coin change). The recurrence takes min or max.
  • "Is it possible / true or false" to reach a state (word break, partition equal subset sum). The recurrence uses or.

Then confirm the two conditions. Optimal substructure: the best answer for the big problem is built from best answers of smaller ones. Overlapping subproblems: the brute force asks the same smaller question more than once. If the second condition fails (every subproblem is different, like merge sort), plain recursion is already fine and a cache buys nothing.

6Big-O summary

n is the fibonacci index; for grids, n is rows and m is columns. The pattern is always the same: exponential brute force, then (number of states) for time, then shrink the space.

VersionTimeSpaceWhy
Fibonacci, brute forceO(2^n)O(n)each call makes 2 calls, tree depth n; space is the recursion stack
Fibonacci, memoization (top-down)O(n)O(n)each F(i) computed once; cache of n entries plus a stack n deep
Fibonacci, bottom-up tableO(n)O(n)one loop; array of size n + 1
Fibonacci, two variablesO(n)O(1)only the last two values are ever read
Unique paths, brute forceO(2^(n+m))O(n + m)2 choices per step, about n + m steps; stack depth is the path length
Unique paths, memoizationO(n · m)O(n · m)each cell computed once; cache is the whole grid
Unique paths, bottom-up two rowsO(n · m)O(m)still every cell once, but only prevRow and curRow are kept
Hash-map cache lookupO(1)-that is why the check "is it cached?" costs nothing

General rule to say in interviews: memoized time = (number of distinct states) × (work per state, not counting the recursive calls). Fibonacci: n states × O(1) = O(n). Grid: n · m states × O(1) = O(n · m).

7Mistakes to watch for

Computing but never storing

You add the if n in cache check but forget cache[n] = ... before returning. The cache stays empty and the function is still O(2^n). Store, then return the stored value.

Cache check in the wrong place

The lookup goes before the recursive calls and after the cheap base cases. If you put it after the recursion, you recurse anyway. If you put it before the out-of-bounds check in the grid code, you index cache[r][c] with r = rows and crash.

Using 0 as "not computed" when 0 is a real answer

NeetCode's cache[r][c] > 0 is safe for unique paths because every cell has at least one path. For problems where 0 (or a negative) is a valid result, use -1 as the empty marker, or a dictionary with in, or @cache.

Bottom-up in the wrong order

curRow[c] = curRow[c + 1] + prevRow[c] reads the right neighbor, so the inner loop must go right to left. Going left to right reads a curRow[c + 1] that is still 0 and every answer becomes 0 or 1. Rule: iterate from the base-case side toward the answer.

Overwriting before you read

In the two-variable version, dp[0] = dp[1] followed by dp[1] = dp[0] + dp[1] doubles instead of adding. Save tmp = dp[1] first, or use Python's tuple swap a, b = b, a + b, which reads the whole right side first.

Off by one in the table size or loop

The table must have n + 1 slots so table[n] exists. The loop must run while i <= n (or range(2, n + 1)). range(2, n) stops one early and returns F(n - 1).

Mutable default argument as the cache

def memo(n, cache={}) creates one dictionary shared by every call of the function for the life of the program. For fibonacci that happens to be harmless; for a function whose answer depends on other inputs (a different grid) it returns stale results. Pass the cache in, or create it inside a wrapper, or use @cache on a nested function.

Unhashable state with @cache

@cache keys on the arguments. Passing a list (go(nums, i)) raises TypeError: unhashable type. Pass only the indices; keep the array in the enclosing scope.

Hitting the recursion limit

Top-down on n = 5000 raises RecursionError in Python (default limit near 1000 frames). Either sys.setrecursionlimit or, better, use the bottom-up loop. This is a real reason interviewers ask for step 4 of the recipe.

Interview one-liners

  • "DP is recursion where each subproblem is solved once and cached; it turns O(2^n) fibonacci into O(n)."
  • "Memoization is top-down: recurse from the root and store results in a hash map keyed by the state."
  • "Tabulation is bottom-up: fill a table from the base cases, in the order the recurrence needs."
  • "The state is whatever identifies a subproblem: one number means 1-D DP, two numbers means 2-D DP."
  • "Memoized time is number of states times work per state; here n states times O(1)."
  • "Fibonacci only reads the previous two values, so I keep two variables and get O(1) space; the grid only reads the row below, so I keep two rows and get O(m) space."
  • "In Python I write the brute force, add @cache, then flip to loops if the input is deep enough to hit the recursion limit."

8Practice, in this order

Do each one in the four steps: brute force, find the repeat, add @cache, flip to bottom-up. Even when the easy version is enough, write the bottom-up version once. That is the muscle.

#ProblemMove it trainsLevel
509Fibonacci Numberthe lesson exactly: memo, then two variablesEasy
70Climbing Stairs1-D "two back", fibonacci in disguiseEasy
1137N-th Tribonacci Number1-D "three back", slide three variablesEasy
746Min Cost Climbing Stairs1-D "two back" with minEasy
198House Robber1-D "two back" with max, rob or skipMedium
213House Robber IIrun House Robber twice on a circleMedium
62Unique Paths2-D grid, the lesson exactly, two rowsMedium
63Unique Paths II2-D grid with obstacles (cell = 0)Medium
64Minimum Path Sum2-D grid with min instead of plusMedium
1143Longest Common Subsequence2-D DP on two strings, state (i, j)Medium
322Coin Change1-D min over choices, unboundedMedium
300Longest Increasing Subsequence1-D max, dp[i] looks at all j < iMedium

Your next step today: LeetCode 70, Climbing Stairs. Write the brute force, draw the tree for n = 5, add @cache, then write the two-variable loop from memory and compare with the fibonacci trace table.

9Self-check

Answer out loud before opening. If you cannot answer in one breath, reread that section.

Q1In one sentence, what is dynamic programming?

An optimized version of recursion: break the problem into smaller subproblems and solve each subproblem only once by caching its result.

Q2Why is bruteForce(n) for fibonacci O(2^n)? Which value is repeated in the tree for n = 5, and how many times?

Every call makes two more calls, so each level of the tree doubles and the depth is n. In the tree for F(5), F(2) is computed three times and F(3) twice.

Q3What three changes turn the brute force into memoization(n, cache)?

1. Add a cache parameter (hash map). 2. Add a base case: if n is in the cache, return the cached value. 3. After computing F(n), store it in the cache before returning.

Q4Why is memoized fibonacci O(n) time and O(n) space?

Each fibonacci number is computed once and a cache lookup is O(1), so n computations total. The cache holds up to n entries, and the recursion stack is also up to n deep.

Q5What do "top-down" and "bottom-up" mean?

Top-down: start at the root (F(n)) and recurse down to the base cases, caching on the way back. Bottom-up: compute the base cases first and build up to n with a loop, no recursion.

Q6Trace it: run dp(6) from the lesson. Give (i, dp[0], dp[1]) after every loop turn and the return value.

Start [0, 1]. i=2: [1, 1]. i=3: [1, 2]. i=4: [2, 3]. i=5: [3, 5]. i=6: [5, 8]. Loop stops because 7 > 6. Return dp[1] = 8.

Q7Why does the two-variable version need tmp? What Python line removes it?

dp[1] = dp[0] + dp[1] destroys the old dp[1], but dp[0] must become that old value next. tmp saves it. a, b = b, a + b evaluates the whole right side before assigning, so no temporary is needed.

Q8What makes unique paths a 2-D problem instead of 1-D?

A subproblem is identified by two variables, the row r and the column c. So the cache is a rows × cols grid, one slot per (r, c).

Q9In the grid brute force, which two cells do the same work twice, and why?

Any cell reachable by more than one route, for example (1, 1): down-then-right and right-then-down both arrive there, so bruteForce(1, 1) and its whole subtree run twice. The brute force has no memory of earlier calls.

Q10Trace it: run the bottom-up dp(2, 3). Give prevRow after each row and the answer.

init [0, 0, 0]. r=1: curRow starts [0, 0, 1], c=1: 1 + 0 = 1, c=0: 1 + 0 = 1 → [1, 1, 1]. r=0: [0, 0, 1], c=1: 1 + 1 = 2, c=0: 2 + 1 = 3 → [3, 2, 1]. Return prevRow[0] = 3.

Q11Why does the bottom-up grid loop go right to left inside each row, and bottom to top across rows?

curRow[c] = curRow[c + 1] + prevRow[c]: the value comes from the right neighbor and the cell below, which is the base-case side. Those must already be computed, so you fill from the bottom-right toward the top-left.

Q12How does the space drop from O(n·m) to O(m) in the grid, and what is the fibonacci equivalent?

Computing a row only needs the row below it, so keep prevRow and curRow and drop the rest: two rows of m cells. Fibonacci only needs the previous two values, so keep two variables: O(1).

Q13What does @cache from functools do, and what must be true of the arguments?

It wraps the function with a hidden dictionary: same arguments → return the stored result instead of running the body. Arguments must be hashable (int, str, tuple), because they become the dictionary key. Lists are not allowed.