AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Data Structures & Algorithms · Part 10

Dynamic programming I

Dynamic programming is the paradigm for problems with overlapping subproblems and optimal substructure: the same smaller problems recur, so we solve each once and reuse the answer. This lesson builds the mental method for seeing a DP — defining a state, deriving a recurrence, choosing memoization vs tabulation — through the canonical 1-D problems: climbing stairs, house robber, coin change and longest increasing subsequence.

⏱️ ~2.5 hours🎯 Advanced → Expert🧩 state & recurrencerunnable

Learning objectives

  • Recognize overlapping subproblems and optimal substructure — when DP applies at all.
  • Contrast DP with greedy and plain divide-and-conquer.
  • Turn a naive recursion into memoization (top-down) and tabulation (bottom-up).
  • Follow a repeatable method to define a DP state and derive its recurrence.
  • Solve climbing stairs, house robber, coin change and LIS with honest complexity.
  • Reconstruct the optimal choice, not just its value.
📋 Before you startThis page builds on: Complexity & arrays. Jumping in? Skim those first — or open the recap.
30-second recap
  • Complexity & arrays: Big-O describes how cost grows with input size; arrays give O(1) index access but O(n) inserts in the middle.

1 · When does DP apply? advanced

Start with the moment that made this click for a lot of people. You write the textbook recursive Fibonacci — three lines, elegant, obviously correct. It returns fib(10) instantly. Then someone types fib(50) and your program simply… hangs. Not crashes, not errors — just sits there, apparently forever. You didn't write a bug. You wrote something correct that quietly does billions of times more work than it needs to, because it keeps re-solving the exact same tiny problems over and over. Dynamic programming is the discipline that notices "wait, I've already answered this" and never answers it twice.

Here's the everyday analogy. Imagine a group project where five people are each asked to "compute the total budget." If nobody talks to anybody, all five independently re-add the same line items — five times the work for one answer. Now imagine one shared whiteboard: the first person to compute a subtotal writes it up, and everyone else just reads it. That whiteboard is memoization. DP is nothing more exotic than "do the work once, write the answer on a whiteboard, and look it up instead of redoing it." The whole subject is that idea, applied carefully.

But DP doesn't magically speed up every slow recursion — it only helps when the problem has the right shape, and knowing that shape is the real skill. The common mistake is reaching for a DP table on a problem where the subproblems never actually repeat (nothing to cache) or where stitching optimal small answers together does not give an optimal big answer — in which case the table is either useless or, worse, silently wrong. So before writing a single line of code, you check for two properties.

DP needs two properties. Overlapping subproblems: a naive recursion solves the same smaller problem many times. Optimal substructure: an optimal solution is built from optimal solutions to subproblems. If both hold, cache the subproblem answers and the exponential blowup collapses to polynomial. Miss either one and DP is the wrong tool: no overlap means caching buys you nothing (that's ordinary divide-and-conquer, like merge sort's two disjoint halves), and no optimal substructure means the cached pieces can't be trusted to compose into the right whole.

The clearest illustration is Fibonacci. Naive recursion recomputes fib(k) exponentially often — the call tree branches into two at every level, so it balloons to roughly O(2ⁿ) calls, almost all of them redundant. Memoizing makes each of the n subproblems solved exactly once, collapsing the whole tree to O(n). Watch the call counter in the lab below: the same answer, computed with a few dozen calls instead of a few million. That gap — millions versus dozens — is dynamic programming.

naive fib(4): same subproblems solved again and again fib(4) fib(3) fib(2) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) red fib(2) is recomputed — the whole subtree is redundant memoized: solve each once, then look up fib(0) fib(1) fib(2) fib(3) fib(4) 5 cached values, each computed exactly once → O(n) Overlap is the whole opportunity. The naive tree re-solves fib(2) (and lower) many times; caching each subproblem once collapses O(2ⁿ) calls to O(n). That repetition is exactly what DP exploits.
🗺️ How to read this diagram

Read the left tree top-down: each box calls the two boxes below it, exactly how naive recursion expands. The right side is the same computation done once and stored.

  • On the left, fib(4) branches into fib(3) and fib(2), and each of those branches again — the fan-out that makes naive Fibonacci roughly O(2ⁿ).
  • The red fib(2) is the tell: it (and its children) get recomputed from scratch in more than one place. Nothing is remembered, so identical work is repeated.
  • The green boxes on the right are the memo: each of the n+1 distinct subproblems is solved once and the arrows show later values reading the stored ones instead of recursing — the tree collapses to O(n).

In short: The left tree wastes work by re-solving the same subproblem; the right side solves each once and looks it up. That reuse is what DP is.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — the overlap, made visible
pythoncalls = {"naive": 0, "memo": 0}

def fib_naive(n):
    calls["naive"] += 1
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)      # recomputes the same n repeatedly

def fib_memo(n, cache={}):
    calls["memo"] += 1
    if n < 2:
        return n
    if n not in cache:
        cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
    return cache[n]

print("fib(30) =", fib_naive(30), "in", calls["naive"], "naive calls")
print("fib(30) =", fib_memo(30), "in", calls["memo"], "memoized calls")
fib(30) = 832040 in 2692537 naive calls
fib(30) = 832040 in 59 memoized calls
ParadigmSubproblemsReuse?Choice rule
divide & conquerdisjoint (e.g. two halves)no overlapsplit & combine
dynamic programmingoverlappingcache & reusetry all, keep best
greedyone choice, no revisitingn/alocally best, commit

2 · Memoization vs tabulation intermediate

Picture two people solving the same jigsaw puzzle. The first grabs whatever piece the picture on the box points them toward, working inward from wherever their eye lands, and only reaches for a new piece when they actually need it — messy path, but they never touch a piece they don't require. The second lays out every edge piece first, then fills row by row in strict order, never jumping ahead. Both finish the same puzzle. That is exactly the difference between the two ways to do DP: the first is memoization (top-down, on demand), the second is tabulation (bottom-up, in order). They compute the identical answer with the identical big-O cost — the choice between them is about ergonomics and edge cases, not correctness.

Why does this deserve its own section instead of "just pick one"? Because each style has a failure mode the other doesn't, and interviewers love probing whether you know which. Here is the plain-language version of the trade. Memoization lets you keep the recursion you already wrote — you literally add a cache and a "have I seen this?" check, so it's the fastest way to get from a correct-but-slow recursion to a fast one. Its danger: it rides Python's call stack, and a deep chain of subproblems (think n = 100{,}000) can blow the recursion-depth limit and crash. Tabulation has no recursion and no stack risk, and it's usually a hair faster because it skips call overhead — but you must figure out the exact order to fill the table so every cell's dependencies already exist, and it eagerly computes every state even ones the answer never needs.

The mistake to name up front: beginners often think tabulation is "the real DP" and memoization is a lazy shortcut. Not so — they are two implementations of the same recurrence. If you can write the recurrence (state + transition), you can write it either way, and the smart move is to reach for whichever fits the problem: memoization when the recursion is natural and the state space is sparse (many states never get visited), tabulation when the recursion is deep or you want to later shrink the table to save memory. The diagram below shows the single pipeline both share: define the recurrence once, then realize it top-down or bottom-up.

There are two ways to fill the cache. Memoization (top-down) keeps the natural recursion and stores each answer the first time it is computed. Tabulation (bottom-up) throws away recursion and fills an array in dependency order. Same complexity; different trade-offs.

Recurrence state & transition Top-down:recurse + cache memoize Bottom-up:fill table tabulate in order
🗺️ How to read this diagram

Read it left to right, following the two arrows: one recurrence at the left splits into two ways to realize it.

  • The leftmost blue box, Recurrence ("define state & transition"), is the single idea both implementations share — you write it once.
  • The first arrow leads to the middle box, Top-down: recurse + cache ("memoize") — keep the natural recursion and store each answer the first time it is computed.
  • The second arrow leads to the green box, Bottom-up: fill table ("tabulate in order") — throw away recursion and fill an array in dependency order.

In short: Memoization and tabulation are two realizations of the same recurrence — define the state and transition once, then choose top-down or bottom-up.

Memoization (top-down)Tabulation (bottom-up)
writingkeep the recursion, add a cacherewrite as an array fill
computesonly reachable states (lazy)every state (eager)
riskrecursion-depth limitwasted states if many unused
spacecache + call stacktable (often reducible)
A repeatable method to SEE a DP(1) State — what does one subproblem look like? Name dp[i] and say in words what it means. (2) Transition — express dp[i] using strictly smaller states. (3) Base case — the smallest states you can fill directly. (4) Order — fill so every state's dependencies are ready. (5) Answer — which state (or aggregate) is the result. If you can write these five lines, the code is mechanical.

3 · Climbing stairs — the smallest real DP intermediate

Here's a question that looks like it needs clever combinatorics but doesn't. You're standing at the bottom of a staircase with n steps. Each stride you take moves you up either 1 step or 2. How many distinct ways can you reach the top? Most people freeze and start trying to enumerate paths by hand — 1+1+1, 1+2, 2+1 — and immediately lose track for anything past n=4. The trick is to stop thinking about the whole journey and ask one small question instead: how did I arrive at the very last step?

There are only two possible final moves. Either your last stride was a single step (so just before it you were standing on step n-1), or it was a double step (so you were on step n-2). Those are the only two ways to land on step n, and they don't overlap. So the number of ways to reach the top is simply the number of ways to reach n-1 plus the number of ways to reach n-2. That single sentence is the whole recurrence — and notice we never had to enumerate a single full path. This is the payoff of DP thinking: reduce a big count to a sum of two smaller counts you'll compute the same way.

The analogy that sticks: it's like counting the ways water can flow into a bucket that has exactly two feeder pipes. The total inflow is just the sum of what comes through each pipe — and each pipe's flow is itself the sum of its two feeders, all the way back to the source. The common trap here is botching the base cases: people set dp[0]=0 thinking "zero steps means no ways," but there's exactly one way to "already be" at the bottom (do nothing), so dp[0]=1. Get the base wrong and every count above it is off. Now apply the five-line method and watch the code fall out mechanically.

You climb n stairs taking 1 or 2 steps at a time; how many distinct ways? Apply the method. State: dp[i] = ways to reach step i. Transition: you arrive at i from i-1 (a 1-step) or i-2 (a 2-step), so dp[i]=dp[i-1]+dp[i-2]. Base: dp[0]=dp[1]=1. It is Fibonacci in disguise — Θ(n) time, and Θ(1) space once you keep only two values. That last point is worth savoring: because dp[i] only ever looks back at the two cells immediately behind it, you never need the whole array — two rolling variables suffice, and an O(n)-space table shrinks to O(1). That "how far back does the recurrence reach?" question is how you space-optimize almost any 1-D DP.

dp[i] = ways to reach step i = dp[i-1] + dp[i-2] 1dp[0] 1dp[1] 2dp[2] 3dp[3] 5dp[4] 8dp[5] 13dp[6] base cases filled directly dp[5] = dp[4] + dp[3] = 5 + 3 = 8 Every cell reads only the two behind it. Fill left to right; each dp[i] sums its two predecessors (Fibonacci in disguise). Because the reach is just two cells, the whole array shrinks to two rolling variables → O(1) space.
🗺️ How to read this diagram

Read the row left to right, the order the array is filled. Each box is one state dp[i] — the number of distinct ways to reach step i.

  • The two green cells on the left are the base cases dp[0]=dp[1]=1, filled directly with no computation.
  • The two curved arrows land on dp[5] from dp[4] and dp[3]: that is the transition dp[i]=dp[i-1]+dp[i-2] made visual — 5 + 3 = 8.
  • The payoff: every cell reaches back only two slots, so you never need the full array — keep two rolling variables and space drops from O(n) to O(1).

In short: Fill the row left to right, each cell = sum of its two left neighbours; a reach of two is why two variables replace the whole table.

Try it — tabulation, then space-optimized
pythondef climb_table(n):
    dp = [0] * (n + 1)
    dp[0] = dp[1] = 1                       # base cases
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]       # transition
    return dp[n]

def climb_o1(n):
    a, b = 1, 1                             # only the last two states matter
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

print([climb_table(n) for n in range(1, 9)])
print("space-O(1) agrees:", all(climb_table(n) == climb_o1(n) for n in range(1, 30)))
[1, 2, 3, 5, 8, 13, 21, 34]
space-O(1) agrees: True

4 · House robber — a real choice per state advanced

Climbing stairs was really just counting — this is the first problem where you have to decide. Picture a street of houses, each with a known amount of cash inside, and a rule enforced by a silent alarm: rob two adjacent houses and you're caught. Your job is to walk the street and choose a subset of non-adjacent houses that maximizes the total loot. The naive instinct — "just grab every house with a big number" — falls apart the moment two fat targets sit side by side, because you can only take one. And the greedy "always take the richer of two neighbors" heuristic quietly fails too: sometimes skipping a good house unlocks two even better ones further down.

The everyday version of this is the calendar you've all fought with. You're offered a stream of one-hour gigs, some paying more than others, and you can't take two that touch. Do you accept the $200 slot at 2pm, or skip it so you're free for the $150 at 2pm and the $180 at 3pm? You can't answer greedily — you have to weigh "take this and jump past its neighbor" against "skip this and keep my options open." That is precisely the house-robber decision, and it's the shape underneath a huge fraction of real DP problems.

Here's the key mental move: instead of agonizing over the whole street at once, stand at house i and ask one crisp question — what's the best I can do considering only houses 0 through i? Two futures branch from here. If I skip house i, my best is whatever I'd already achieved through house i-1. If I rob house i, I collect its cash plus the best I could do through house i-2 (because i-1 is now off-limits). I take the larger of those two. The single most common bug is writing the "rob it" branch alone and forgetting the "skip it" alternative — the max is not decoration, it's the entire point: it's where the choice lives, letting the DP quietly reject a tempting house when passing it up pays more.

Houses in a row hold cash; you cannot rob two adjacent houses. Maximize the loot. This introduces a genuine decision at each state. State: dp[i] = max loot considering houses 0..i. Transition: either skip house i (dp[i-1]) or rob it and add nums[i]+dp[i-2]; take the max. That 'skip vs take, keep the better' shape is the heart of most DP.

at house i: two branches, keep the larger house inums[i] = 9 skip irob i = dp[i-1]best up to previous house = nums[i] + dp[i-2]cash now, skip the neighbour dp[i] = max(skip, rob) The max is where the choice lives. Every state weighs skipping house i (keep dp[i-1]) against robbing it (nums[i]+dp[i-2], since the neighbour is now forbidden) and keeps the better. Drop the skip branch and you force robbing every other house.
🗺️ How to read this diagram

Read top to bottom: start at the state for house i, follow the two branches, and see them merge at the max.

  • The top box is the decision point — you are standing at house i holding nums[i] in cash.
  • The left branch (skip) takes nothing here and inherits dp[i-1], the best achievable through the previous house. The right branch (rob) takes the cash but must jump back to dp[i-2] because the adjacent house is off-limits.
  • The green box is the payoff: dp[i]=max(skip, rob). That max is the entire algorithm — it lets the DP silently reject a tempting house when passing it up pays more.

In short: Two branches enter each state (skip vs rob); the max keeps the winner, and that is where the real decision happens.

Try it — house robber with choice reconstruction
pythondef rob(nums):
    if not nums:
        return 0, []
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    for i in range(1, n):
        take = nums[i] + (dp[i - 2] if i >= 2 else 0)
        skip = dp[i - 1]
        dp[i] = max(take, skip)
    # reconstruct which houses were robbed
    chosen, i = [], n - 1
    while i >= 0:
        take = nums[i] + (dp[i - 2] if i >= 2 else 0)
        if take >= dp[i - 1] if i >= 1 else True:
            if (dp[i] == take):
                chosen.append(i); i -= 2
                continue
        i -= 1
    return dp[-1], sorted(chosen)

loot, houses = rob([2, 7, 9, 3, 1])
print("max loot:", loot, "robbing house indices:", houses)
print("check no two adjacent:", all(houses[k+1]-houses[k] >= 2 for k in range(len(houses)-1)))
max loot: 12 robbing house indices: [0, 2, 4]
check no two adjacent: True
Value first, path secondAlmost every DP computes the optimal value in the forward pass, then reconstructs the choice in a backward pass by asking, at each state, 'which transition produced this value?' Keeping a separate parent/decision array makes reconstruction cleaner on harder DPs.

5 · Coin change — minimization & unbounded choices advanced

This is the problem that has quietly shipped bugs into real payment and loyalty systems. A cashier making change reaches for the biggest coin that fits, then the next biggest, and so on — and for real-world currency that habit happens to be optimal, so it feels like a law of nature. It isn't. It's an accident of how national coin systems are designed. Swap in an arbitrary denomination set — a custom loyalty-point scheme, an in-game currency, a foreign mint from an old puzzle — and "grab the biggest" can hand you more coins than necessary, or even fail to make an amount it could have made. Teams learn this the hard way: the greedy change-maker passes every test written with US quarters and dimes, then miscounts in production the day someone configures a weird denomination.

Feel the trap concretely with coins {1, 3, 4} and a target of 6. Greedy grabs the 4 first (biggest that fits), leaving 2, which it must pay with 1+1 — three coins total. But the human eye sees it instantly: 3+3 is two coins. Greedy lost because committing to that 4 foreclosed the better pairing, and greedy never looks back. The lesson generalizes: the common mistake is trusting a locally-best choice to add up to a globally-best answer. Sometimes it does; often it doesn't; and "often" is where outages live.

DP fixes this by refusing to commit. Instead of picking one coin and hoping, it asks, for every amount from 1 up to the target: "what is the cheapest way to make this amount?" — and to answer for amount x it tries every coin as the last coin used, then keeps the best. The analogy is filling in a price chart from the smallest amount upward: by the time you reach amount x, you've already got the optimal answer for every smaller amount written down, so "make x using coin c" costs just one coin on top of the already-known best for x−c. Two subtleties worth flagging so they don't bite you: an unreachable amount must stay at infinity (not zero, or you'll fake a free solution), and dp[0]=0 because making zero costs no coins — the anchor the whole chart is built on.

Given coin denominations and an amount, find the fewest coins that sum to it (each coin usable unlimited times). State: dp[x] = min coins to make amount x. Transition: dp[x]=1+min(dp[x-c]) over coins c≤x. Base: dp[0]=0; unreachable amounts stay ∞. Complexity Θ(amount × #coins). This is where greedy fails — with coins {1,3,4} and amount 6, greedy takes 4+1+1=3 coins but the optimum is 3+3=2.

greedy: take the biggest that fits 4 1 1 6 = 4 + 1 + 1 → 3 coins (overshoot) DP: try every coin, keep the fewest 3 3 6 = 3 + 3 → 2 coins (optimum) why: the chart is filled from amount 1 upward 0dp[0]=0 1dp[3]=1 1+dp[3]dp[6] via coin 3 dp[6] = 2best over all coins Greedy commits; DP compares. Grabbing the biggest coin (4) strands amount 2 into 1+1 — three coins. DP asks the cheapest way to make every amount up to 6, so dp[6]=1+dp[3]=2 wins. Correct for any denominations, not just canonical ones.
🗺️ How to read this diagram

Compare the two panels left vs right for the same target (amount 6, coins {1,3,4}), then read the bottom row to see why DP wins.

  • The red left panel is greedy: it grabs the largest coin that fits (4), then is forced into 1+1 — three coins, because it never reconsiders that first grab.
  • The green right panel is the optimum 3+3 — two coins — which greedy can never find because it committed to the 4.
  • The bottom row shows the mechanism: DP fills a chart from amount 0 upward, so by the time it reaches 6 it already knows dp[3]=1 and computes dp[6]=1+dp[3]=2, taking the best over every coin.

In short: Greedy takes the biggest coin and gets stuck at 3; DP builds up from small amounts and reuses dp[3] to reach the true optimum of 2.

Try it — coin change, and where greedy loses
pythondef coin_change(coins, amount):
    INF = float("inf")
    dp = [0] + [INF] * amount
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x and dp[x - c] + 1 < dp[x]:
                dp[x] = dp[x - c] + 1
    return dp[amount] if dp[amount] != INF else -1

def greedy(coins, amount):
    count = 0
    for c in sorted(coins, reverse=True):
        while amount >= c:
            amount -= c; count += 1
    return count if amount == 0 else -1

coins = [1, 3, 4]
print("DP optimum for 6:", coin_change(coins, 6))
print("greedy for 6:    ", greedy(coins, 6))
print("DP for 11 with [1,2,5]:", coin_change([1, 2, 5], 11))
DP optimum for 6: 2
greedy for 6:     3
DP for 11 with [1,2,5]: 3
Greedy is not DPGreedy commits to the locally best coin and never reconsiders. It is correct only for canonical coin systems (like standard currency). DP tries every option and keeps the best, so it is correct for any denomination set — at the cost of more work.

6 · Longest increasing subsequence — two DP shapes expert

This one carries a lesson bigger than the problem itself: the same question can have two completely different DP "shapes," and reframing the state is how you drop a whole complexity class. First, the problem. A subsequence is what you get by deleting some elements from a list without reordering the rest — so from [3,1,4,1,5] the values [3,4,5] are a subsequence but [4,3] is not. The longest increasing subsequence (LIS) is the longest such run whose values strictly climb. Careful — this is not the longest increasing contiguous block; the winning elements can be scattered across the array as long as their order is preserved. Confusing "subsequence" with "substring" is the single most common misread, and it changes the answer entirely.

The intuitive DP mirrors house robber: stand at index i and ask "what's the longest increasing subsequence that ends right here at nums[i]?" To answer, look back at every earlier index j whose value is smaller — any of those could be the element just before i — and take the best chain you could extend. That gives a clean, correct answer, but notice the cost: for each of n positions you scan all the earlier ones, so it's O(n²). For a few thousand elements that's fine; for a stream of a million it's a wall.

Now the reframe that feels like magic the first time you see it. Play the card game "patience": deal cards one at a time, and place each onto the leftmost pile whose top card is not smaller. It turns out the number of piles you end up with equals the length of the LIS. Translated to code, you maintain a small array tails where tails[k] is the smallest value that could end an increasing subsequence of length k+1; each new element either extends the array (a new pile) or overwrites the first slot it's not smaller than — and because tails stays sorted, you find that slot with binary search in O(log n). One pass of n elements, each doing a log n lookup, is O(n log n). Same problem, same answer, a fundamentally cheaper state. One honest caveat so you don't over-claim in an interview: the tails array gives the correct length, but its contents are not themselves a valid LIS — reconstructing the actual subsequence needs extra bookkeeping.

The LIS of a sequence is the longest subsequence whose values strictly increase. The natural DP is Θ(n²): dp[i] = length of the best increasing subsequence ending at i, transitioning from any earlier j with nums[j]<nums[i]. A cleverer version uses binary search on a 'tails' array for Θ(n log n) — the patience-sorting insight.

O(n²): dp[i] = best ending at i 2 5 3 7 each i scans all smaller earlier values → n×n O(n log n): keep a sorted 'tails' array 2 3 7 18 len(tails) = LIS length = 4 next x binary search → extend or overwrite a slot O(log n) per element Reframing the state drops a complexity class. The natural state (best ending at i) forces each element to look back at all earlier ones — O(n²). A monotone tails array lets binary search place each element in O(log n), giving O(n log n) for the same answer.
🗺️ How to read this diagram

Compare the two panels left vs right: same input, two different DP "states" and therefore two different costs.

  • The left panel is the natural state dp[i] = longest chain ending at i. The curved arrows show that computing the highlighted cell means scanning back over every smaller earlier element — n positions each doing an n-scan → O(n²).
  • The right panel keeps a sorted tails array where tails[k] is the smallest value that can end an increasing subsequence of length k+1. Its length is the LIS length.
  • The payoff: because tails stays sorted, each new element finds its slot by binary search in O(log n) — one pass gives O(n log n). (The tails contents are not themselves a valid LIS — only the length is exact.)

In short: Same problem, two states — "best ending at i" costs O(n²); a sorted tails array with binary search costs O(n log n).

Try it — O(n²) DP and O(n log n) patience sorting
pythonfrom bisect import bisect_left

def lis_n2(nums):
    if not nums:
        return 0
    dp = [1] * len(nums)                    # dp[i]=LIS ending at i
    for i in range(len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

def lis_nlogn(nums):
    tails = []                              # tails[k]=smallest tail of an LIS of length k+1
    for x in nums:
        i = bisect_left(tails, x)
        if i == len(tails):
            tails.append(x)
        else:
            tails[i] = x
    return len(tails)

seq = [10, 9, 2, 5, 3, 7, 101, 18]
print("O(n^2) LIS:", lis_n2(seq), " O(n log n) LIS:", lis_nlogn(seq))
print("agree on random-ish input:",
      lis_n2([3,1,4,1,5,9,2,6,5,3,5]) == lis_nlogn([3,1,4,1,5,9,2,6,5,3,5]))
O(n^2) LIS: 4  O(n log n) LIS: 4
agree on random-ish input: True
Same problem, two statesThe Θ(n²) state is 'best ending at i'. The Θ(n log n) version keeps a monotone tails array where tails[k] is the smallest possible tail of an increasing subsequence of length k+1; binary search places each element. Reframing the state is often how you drop a complexity class.

Checkpoint expert

✓ Checkpoint — you can move on when you can…

  • Name the two properties a problem needs before DP is the right tool.
  • Write the five-line method (state, transition, base, order, answer) for a new problem.
  • Convert a naive recursion into both memoized and tabulated forms.
  • Explain why coin change needs DP where standard-currency change can be greedy.
  • State the LIS complexity for both the O(n²) and O(n log n) formulations.
✓ Knowledge check

A colleague solves 'min coins' greedily and it passes their tests with US coins, then fails in production with a custom loyalty-point denomination set. What happened?

Show answer
Greedy min-coins is correct only for canonical coin systems where taking the largest coin each time is provably optimal. US coins happen to be canonical; an arbitrary custom set (e.g. {1,3,4}) is not, so greedy overshoots the coin count. The DP recurrence dp[x]=1+min(dp[x-c]) tries every coin and is correct for any denominations.
✓ Knowledge check

In house robber, why is the transition dp[i]=max(dp[i-1], nums[i]+dp[i-2]) rather than nums[i]+dp[i-2]?

Show answer
Because robbing house i is optional. dp[i-1] is the best you can do if you skip i; nums[i]+dp[i-2] is the best if you rob it (which forbids i-1). Taking the max encodes the choice. Dropping dp[i-1] would force you to rob every other house even when skipping is better.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Min-cost climbing stairsBeginner

Context: A variant of climbing stairs where each step has a cost; used to teach that the 'answer state' is not always the last cell.

Your task: Given cost[i] to step on stair i, find the min cost to reach the top (past the last stair), starting from step 0 or 1.

Requirements:

  • Transition: dp[i] = cost[i] + min(dp[i-1], dp[i-2]).
  • The top is one past the last index — answer is min(dp[n-1], dp[n-2]).
  • Return the numeric minimum cost.

💡 Hint: Fill dp left to right; the top can be reached from either of the last two stairs.

Show solution
Solution
pythondef min_cost_climb(cost):
    n = len(cost)
    dp = [0] * n
    dp[0], dp[1] = cost[0], cost[1]
    for i in range(2, n):
        dp[i] = cost[i] + min(dp[i - 1], dp[i - 2])
    return min(dp[-1], dp[-2])              # top is beyond the last stair

print(min_cost_climb([10, 15, 20]))            # 15
print(min_cost_climb([1, 100, 1, 1, 1, 100, 1, 1, 100, 1]))  # 6
15
6
Exercise 2 · Count ways to make changeIntermediate

Context: Unlike min-coins, here we count the number of distinct combinations that sum to an amount — the classic 'combinations not permutations' DP subtlety.

Your task: Given coin denominations and an amount, count how many distinct coin combinations sum to it (order does not matter).

Requirements:

  • Loop coins on the OUTSIDE and amount on the inside to avoid counting permutations.
  • dp[0] = 1 (one way to make 0: use nothing).
  • Return dp[amount].

💡 Hint: Iterating coins outermost means each combination is counted once, in a fixed coin order.

Show solution
Solution
pythondef count_change(coins, amount):
    dp = [0] * (amount + 1)
    dp[0] = 1
    for c in coins:                         # coins OUTSIDE => combinations, not permutations
        for x in range(c, amount + 1):
            dp[x] += dp[x - c]
    return dp[amount]

print(count_change([1, 2, 5], 5))              # 4: 5, 2+2+1, 2+1+1+1, 1*5
print(count_change([2], 3))                    # 0
print(count_change([1, 5, 10, 25], 100))       # 242
4
0
242
Exercise 3 · Word breakAdvanced

Context: Word break (can a string be segmented into dictionary words?) powers tokenizers and spell-correctors; it is a 1-D boolean DP over string prefixes.

Your task: Given a string and a word dictionary, return True if the string can be split into a sequence of dictionary words.

Requirements:

  • State: dp[i] = can s[:i] be segmented.
  • Transition: dp[i] is True if some j<i has dp[j] True and s[j:i] in the dictionary.
  • dp[0] = True (empty prefix).

💡 Hint: For each end index i, scan split points j and check dp[j] and the substring s[j:i].

Show solution
Solution
pythondef word_break(s, words):
    wordset = set(words)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True                            # empty prefix is segmentable
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and s[j:i] in wordset:
                dp[i] = True
                break
    return dp[n]

print(word_break("leetcode", ["leet", "code"]))          # True
print(word_break("applepenapple", ["apple", "pen"]))     # True
print(word_break("catsandog", ["cats","dog","sand","and","cat"]))  # False
True
True
False
Exercise 4 · Decode waysExpert

Context: Decoding a digit string into letters (A=1..Z=26) is the DP that trips people on the edge cases: leading zeros and the 10/20 specials.

Your task: Count how many ways a digit string decodes to letters, where 1..26 map to A..Z.

Requirements:

  • State: dp[i] = ways to decode s[:i].
  • One-digit step valid if s[i-1] != '0'; two-digit step valid if s[i-2:i] in 10..26.
  • Return dp[n]; handle strings that cannot decode (return 0).

💡 Hint: At each position add dp[i-1] if the single digit is 1-9 and dp[i-2] if the pair is 10-26.

Show solution
Solution
pythondef num_decodings(s):
    if not s or s[0] == "0":
        return 0
    n = len(s)
    dp = [0] * (n + 1)
    dp[0], dp[1] = 1, 1
    for i in range(2, n + 1):
        if s[i - 1] != "0":                 # single digit 1-9
            dp[i] += dp[i - 1]
        if "10" <= s[i - 2:i] <= "26":      # pair 10-26
            dp[i] += dp[i - 2]
    return dp[n]

print(num_decodings("12"))     # 2  -> "AB","L"
print(num_decodings("226"))    # 3  -> "BZ","VF","BBF"
print(num_decodings("06"))     # 0  -> leading zero
2
3
0
Exercise 5 · Maximum product subarrayProfessional

Context: Max-product subarray is a favorite because a single running max fails: a negative times a negative flips to a large positive, so you must track the running MIN too.

Your task: Find the largest product of any contiguous subarray (may contain negatives and zeros).

Requirements:

  • Track both the current max and current min product ending here.
  • On a negative number, swap max/min before updating.
  • Return the best max seen; verify against brute force.

💡 Hint: hi = max(x, hi*x, lo*x); lo = min(x, hi_old*x, lo*x) — the min can become the new max after a sign flip.

Show solution
Solution
pythondef max_product(nums):
    best = hi = lo = nums[0]
    for x in nums[1:]:
        if x < 0:
            hi, lo = lo, hi                 # sign flip swaps roles
        hi = max(x, hi * x)
        lo = min(x, lo * x)
        best = max(best, hi)
    return best

def brute(nums):
    b = nums[0]
    for i in range(len(nums)):
        prod = 1
        for j in range(i, len(nums)):
            prod *= nums[j]; b = max(b, prod)
    return b

for t in ([2,3,-2,4], [-2,0,-1], [-2,3,-4], [2,-5,-2,-4,3]):
    print(t, "->", max_product(t), "brute:", brute(t))
[2, 3, -2, 4] -> 6 brute: 6
[-2, 0, -1] -> 0 brute: 0
[-2, 3, -4] -> 24 brute: 24
[2, -5, -2, -4, 3] -> 24 brute: 24
Exercise 6 · Best time to buy/sell stock with cooldownIndustry scenario

Context: A state-machine DP straight out of quant/fintech interviews: after selling you must cool down one day before buying again. It teaches modeling a DP as explicit states.

Your task: Given daily prices, maximize profit with unlimited transactions, but you cannot buy on the day right after you sell (one-day cooldown).

Requirements:

  • Model three states per day: hold (own a share), sold (just sold today), rest (idle, free to buy).
  • Transitions: hold = max(hold, rest - price); sold = hold + price; rest = max(rest, sold).
  • Answer is max(sold, rest) on the last day; verify on a known example.

💡 Hint: Track the three running values across days; 'rest' after a 'sold' enforces the cooldown because you can only buy from 'rest'.

Show solution
Solution
pythondef max_profit_cooldown(prices):
    if not prices:
        return 0
    hold = float("-inf")   # best profit while holding a share
    sold = 0               # best profit having just sold today
    rest = 0               # best profit resting (not holding, free to buy)
    for price in prices:
        prev_sold = sold
        sold = hold + price            # sell today
        hold = max(hold, rest - price) # keep holding, or buy from a rest day
        rest = max(rest, prev_sold)    # rest; can absorb yesterday's sale (cooldown)
    return max(sold, rest)

print(max_profit_cooldown([1, 2, 3, 0, 2]))   # 3: buy1 sell3, cooldown, buy0 sell2
print(max_profit_cooldown([1]))               # 0
print(max_profit_cooldown([6, 1, 3, 2, 4, 7]))# 6
3
0
6
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in