DP on Stocks
Six LeetCode problems — 121, 122, 123, 188, 309 and 714 — look like six problems and are one. Each is a small variation on a single state machine, and once you can draw that machine the recurrence writes itself. People who memorise them individually get six chances to forget; people who learn the machine get one thing to remember and can derive any variant, including ones they have not seen.
What you’ll learn
Section titled “What you’ll learn”- The two-state machine behind all unlimited-transaction stock problems.
- How a cooldown, a fee, or a transaction limit each add exactly one thing to the machine — and nothing else changes.
- Why the answer is never the
holdstate. - The -space form, and how to derive it from the table form.
The cue
Section titled “The cue”Visual intuition
Section titled “Visual intuition”Two states, four transitions. Every day you pick an edge:
The numbers under each state are the best profit achievable while in that state. Every day both states update from the previous day's values, and the answer is the 'free' state — never 'hold', because ending while still owning a share means the money was never realised.
Now add a cooldown. One extra state, and the recurrence changes in exactly one place:
The 'rest' state reads YESTERDAY's 'sold' value, not today's. That one-day lag IS the cooldown, and it is why a prev_sold temporary is needed. Overwrite sold before rest reads it and the cooldown silently vanishes while the code still looks correct.
The template
Section titled “The template”def max_profit_unlimited(prices): # LC 122
hold, free = -prices[0], 0
for p in prices[1:]:
hold = max(hold, free - p) # keep holding, or buy today
free = max(free, hold + p) # stay out, or sell today
return free # never `hold`
def max_profit_with_fee(prices, fee): # LC 714
hold, free = -prices[0], 0
for p in prices[1:]:
hold = max(hold, free - p)
free = max(free, hold + p - fee) # the ONLY change: pay on sale
return free
def max_profit_cooldown(prices): # LC 309
hold, sold, rest = -prices[0], 0, 0
for p in prices[1:]:
prev_sold = sold # the cooldown lives in this line
sold = hold + p
hold = max(hold, rest - p)
rest = max(rest, prev_sold)
return max(sold, rest)
def max_profit_k(prices, k): # LC 123 (k=2) and LC 188
if k >= len(prices) // 2: # k so large it is unlimited
return max_profit_unlimited(prices)
hold = [float("-inf")] * (k + 1)
free = [0] * (k + 1)
for p in prices:
for t in range(1, k + 1):
hold[t] = max(hold[t], free[t - 1] - p) # buying starts transaction t
free[t] = max(free[t], hold[t] + p)
return free[k]
print(max_profit_unlimited([7, 1, 5, 3, 6, 4])) # expect 7
print(max_profit_with_fee([1, 3, 2, 8, 4, 9], 2)) # expect 8
print(max_profit_cooldown([1, 2, 3, 0, 2])) # expect 3
print(max_profit_k([3, 2, 6, 5, 0, 3], 2)) # expect 7Dry run
Section titled “Dry run”LC 122, prices = [7, 1, 5, 3, 6, 4]. Read the two columns as “best profit if I
end today in this state”.
| day | price | hold | free | reasoning |
|---|---|---|---|---|
| 0 | 7 | −7 | 0 | buying costs 7; not buying is worth 0 |
| 1 | 1 | −1 | 0 | better to be holding a share bought at 1 than at 7 |
| 2 | 5 | −1 | 4 | sell the share bought at 1 |
| 3 | 3 | 1 | 4 | buy again from a base of 4: 4 − 3 = 1 |
| 4 | 6 | 1 | 7 | sell: 1 + 6 = 7 |
| 5 | 4 | 3 | 7 | selling at 4 gives 5, worse than 7 |
Answer 7, from buying at 1, selling at 5, buying at 3, selling at 6.
Two things worth stating out loud:
holdis negative early on and that is correct. It is profit-so-far, and owning a share you paid for is a debt until you sell. Initialising it to0instead of-prices[0]is the standard bug and produces answers that are too large.freereads the just-updatedhold. That permits buying and selling on the same day, which is a no-op worth 0 and therefore harmless — and it is exactly what unlimited transactions allow. In the cooldown version the same shortcut would destroy the constraint, which is why that one needs the temporary.
Complexity
Section titled “Complexity”| Problem | Time | Space |
|---|---|---|
| LC 121 (one transaction) | ||
| LC 122 (unlimited) | ||
| LC 714 (fee) | ||
| LC 309 (cooldown) | ||
LC 123 (k = 2) | — four named variables | |
LC 188 (arbitrary k) |
The k >= n // 2 shortcut in LC 188 matters: without it, a large k makes the
loop time out even though the answer is just the unlimited case.
Any k above n // 2 cannot constrain you, because there are not enough days to
use that many transactions.
The variant map
Section titled “The variant map”| Problem | Constraint | What changes |
|---|---|---|
| 121 | at most one transaction | hold = max(hold, -p) — buying always starts from 0, never from accumulated profit |
| 122 | unlimited | the base machine |
| 714 | fee per transaction | subtract fee on the sell edge |
| 309 | one-day cooldown | add a rest state; rest reads yesterday’s sold |
| 123 | at most 2 transactions | two (hold, free) pairs, chained |
| 188 | at most k transactions | arrays of length k + 1, plus the k >= n // 2 shortcut |
Pitfalls
Section titled “Pitfalls”- Initialising
holdto 0. It must be-prices[0], or-infif you loop from day 0. Zero implies you acquired a share for free. - Returning
hold. Ending while holding means the profit was never realised. Returnfree(ormax(sold, rest)with a cooldown). - Overwriting
soldbeforerestreads it in LC 309. That deletes the cooldown, and the code still looks right. - Forgetting the
k >= n // 2shortcut in LC 188. Withk = 10^9the straightforward loop times out on a problem whose answer is trivial. - Applying the fee on both edges. Charge it once per completed transaction — conventionally on the sell.
- Using LC 122’s greedy one-liner everywhere. “Sum every positive consecutive difference” is correct only for unlimited transactions with no fee and no cooldown. Add any constraint and it silently breaks.
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
“Why is the answer never hold?” | Whether you understand the states | Being in hold means still owning a share, so the profit is unrealised. Only a state with no position represents money you actually have |
| “LC 122 has a greedy one-liner. Why bother with DP?” | Judgement | The greedy sums positive consecutive differences and is correct only with no fee and no cooldown. The machine survives every variant; the greedy survives none |
“Now k can be ” | Whether you spot the degenerate case | If k >= n // 2 there are not enough days to constrain you, so it reduces to unlimited. Without that check the loop times out |
| “Add a two-day cooldown” | Whether you can extend the machine | Add another rest state, or index the rest by days-since-sale. The structure is unchanged, which is the point of the framing |
| “Reduce the space in LC 188” | Space reasoning | Each day reads only the previous day, so two rows of length k + 1 suffice — and with care, one row iterated in the right direction |
| “What if you can hold multiple shares?” | Whether you know the boundary | The state space is no longer two-valued and this framing breaks. That becomes a different, usually greedy, problem |
Practice
Section titled “Practice”Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.
Exercises
Section titled “Exercises”LC 122 — Best Time to Buy and Sell Stock II · Medium
Section titled “LC 122 — Best Time to Buy and Sell Stock II · Medium”LC 714 — With Transaction Fee · Medium
Section titled “LC 714 — With Transaction Fee · Medium”LC 309 — With Cooldown · Medium
Section titled “LC 309 — With Cooldown · Medium”Self-check
Section titled “Self-check”-
Why must `hold` be initialised to -prices[0] rather than 0?
Initialising to 0 produces answers that are too large, because it treats the first purchase as costless. Either -prices[0] with the loop starting at day 1, or -inf with the loop starting at day 0.
pch.quizShowAnswer
B — Because hold is profit-so-far, and owning a share you paid for is a debt until you sell — zero would imply acquiring it free — Initialising to 0 produces answers that are too large, because it treats the first purchase as costless. Either -prices[0] with the loop starting at day 1, or -inf with the loop starting at day 0.
-
Why is the answer never the `hold` state?
This is the cleanest one-sentence statement of what the states mean, and it generalises: with a cooldown the answer is max(sold, rest), because both are no-position states.
pch.quizShowAnswer
B — Because being in hold means still owning a share, so the profit is unrealised — only a no-position state represents money you actually have — This is the cleanest one-sentence statement of what the states mean, and it generalises: with a cooldown the answer is max(sold, rest), because both are no-position states.
-
In LC 309 the cooldown, where exactly does the constraint live?
Overwrite sold before rest reads it and the cooldown vanishes while the code still looks correct. The temporary is not stylistic; it is the constraint.
pch.quizShowAnswer
B — In `rest` reading YESTERDAY's `sold` — the one-day lag is the cooldown, which is why a prev_sold temporary is needed — Overwrite sold before rest reads it and the cooldown vanishes while the code still looks correct. The temporary is not stylistic; it is the constraint.
-
LC 188 allows arbitrary k, and k can be 10^9. What must you check first?
Without the shortcut the O(n·k) loop times out on a problem whose answer is trivially the unlimited case. Each transaction needs at least two days, hence n // 2.
pch.quizShowAnswer
B — Whether k >= n // 2 — if so there are not enough days to constrain you, so it reduces to the unlimited case — Without the shortcut the O(n·k) loop times out on a problem whose answer is trivially the unlimited case. Each transaction needs at least two days, hence n // 2.
-
LC 122 has a famous greedy one-liner. Why learn the state machine anyway?
Summing positive consecutive differences works for exactly one of the six problems. Deriving the other five from the machine is far more reliable than memorising five special cases.
pch.quizShowAnswer
B — The greedy is correct only with unlimited transactions, no fee and no cooldown — the machine survives every variant, the greedy survives none — Summing positive consecutive differences works for exactly one of the six problems. Deriving the other five from the machine is far more reliable than memorising five special cases.
Recall card
Section titled “Recall card”- Cue — prices over time, plus a constraint on how you may trade, holding at most one share.
- The machine — states are
hold(own a share) andfree(own nothing). Each day, every state updates from yesterday’s states. - Base recurrence —
hold = max(hold, free - p);free = max(free, hold + p). - Each constraint adds one thing — a fee subtracts on the sell edge; a
cooldown adds a
reststate reading yesterday’ssold; a limitkadds a dimension. - Initialise
hold = -prices[0], returnfree— neverhold. - LC 188 — check
k >= n // 2first and fall back to unlimited.
- Six LeetCode problems, one state machine. Draw the states and transitions and the recurrence is readable off the diagram.
holdandfreeare “best profit while in this state”, which is whyholdstarts negative and why the answer is neverhold.- A fee, a cooldown, or a transaction cap each modify the machine in exactly one place. Nothing else changes.
- time and space for everything except arbitrary
k, which is time and space — with a shortcut for largek.
Next: Bitmask and Tree DP — what to do when the DP state is a set rather than an index, and why “n ≤ 20” in a problem statement is a hint.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading