State Machine DP
State is (position, small status flag); transitions are the edges of a tiny automaton evaluated once per input element.
Overview
Many linear problems are not solvable with a single dp[i] because the best decision at i depends on a mode you are in: holding a stock or not, in cooldown or not, last character was a vowel or not, currently inside a segment or not. The fix is to add a small categorical dimension: dp[i][s] for status s in a set of k values, with transitions given by the allowed moves between statuses. The result is a finite automaton whose edges are re-evaluated for each input element.
The classic family is "best time to buy and sell stock" with cooldown, transaction fee, or at most k transactions: states hold / free / cooldown, transitions buy, sell, rest. Other members: paint house (status = colour of last house), delete-and-earn, counting strings with forbidden adjacent patterns, and House Robber written as robbed / skipped.
Time is O(n · k · k) in general (k statuses, each looking at k predecessors), and O(n · k) when the automaton is sparse. Space is O(k) since only the previous position is read. Constraints are typically n ≤ 10^5 with k ≤ 3–10, or n ≤ 1000 with k up to a few hundred (colours, transaction counts).
Intuition
A mental model before the formal terms.
Draw the statuses as circles and the allowed moves as arrows: free --buy--> hold, hold --sell--> cooldown, cooldown --rest--> free, plus self-loops for doing nothing. Now feed prices in one at a time; each circle keeps the best profit you could have while sitting in it. After each price, every circle updates from its incoming arrows. The answer is the best circle you can end in with no stock in hand.
The point is that "what you can do next" is entirely determined by which circle you stand in, so the circle is the whole memory you need.
How it works
- State:
dp[i][s]= best value after processing elementiand being in statuss. Enumerate the statuses explicitly and draw the transition graph before coding. - Transition: for each status
tand each edges → twith the cost/gain of taking it at elementi:dp[i][t] = best over incoming s of dp[i-1][s] + gain(s → t, a[i]). - Base cases:
dp[0][start] = 0and all unreachable statuses= -INF(or+INFfor minimization). Getting the initial "impossible" values right is the main source of bugs. - Order: increasing
i; compute all statuses of stepifrom stepi-1(use temporaries so updates within a step do not feed each other). Optimization: keepkvariables instead of a table.
Why it works
The status captures every constraint that the past imposes on the future (e.g. "you cannot buy while holding"). Given the status, the optimal continuation is independent of how you reached it, which is exactly optimal substructure.
Because each step reads only the previous step, increasing i is a valid order, and the per-step work is bounded by the number of automaton edges.
Recognition
How to tell a problem wants this.
- Rules of the form "you cannot do X immediately after Y" (cooldown, no two adjacent, must alternate).
- A small number of modes/phases the process can be in, each with different allowed actions.
- Stock, painting, tiling, or string-generation problems with constraints on consecutive choices.
- A plain
dp[i]solution "almost works" but needs to know one extra bit about the previous step.
Interactive visualization
Play, step, change the input. ← → and space work too.
Showing the closely related House Robber visualization.
| 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 2 | 7 | · | · | · | · | · |
1dp[0] = v[0]; dp[1] = max(v[0], v[1])2for i in 2 .. n-1:3 take = dp[i-2] + v[i]4 skip = dp[i-1]5 dp[i] = max(take, skip)6return dp[n-1]Pseudocode
1# stock with cooldown: statuses hold, free (can buy), cool (just sold)2hold = -INF, free = 0, cool = -INF3for p in prices:4 new_hold = max(hold, free - p) # keep holding, or buy today5 new_free = max(free, cool) # stay free, or cooldown ended6 new_cool = hold + p # sell today7 hold, free, cool = new_hold, new_free, new_cool8return max(free, cool)Implementations
1# Representative problem: best time to buy and sell stock with cooldown2def max_profit(prices: list[int]) -> int:31 · States: hold, just sold (cooldown), resting4 NEG = float("-inf")5 hold, sold, rest = NEG, NEG, 0 # holding / sold today / free to buy62 · Daily transitions7 for p in prices:83 · Each new state reads only yesterday's values9 hold, sold, rest = (10 max(hold, rest - p), # keep holding, or buy after resting11 hold + p, # sell today -> cooldown tomorrow12 max(rest, sold), # idle; cooldown ends here13 )144 · Answer: end without holding stock15 return int(max(sold, rest))16 17 18if __name__ == "__main__":19 print(max_profit([1, 2, 3, 0, 2])) # 3 (buy, sell, cooldown, buy, sell)- Representative example of state-machine DP: three statuses (hold / sold / rest); day
idepends only on dayi - 1, so three variables suffice. - The tuple assignment evaluates the entire right-hand side before rebinding — Python's built-in simultaneous update, no temporaries needed.
float("-inf")marks unreachable states;maxignores it and-inf + pstays-inf.- Buying is only reachable from
rest(rest - p); the missingsold -> holdedge is the cooldown rule. int(max(sold, rest))converts the float sentinel type back tointfor the caller.
- Tuple assignment is the idiomatic simultaneous update and eliminates the classic ordering bug entirely.
float("-inf")mixes fine with ints in comparisons and arithmetic; convert the final answer back withint().- This is a bottom-up loop, so no recursion limit concerns; a memoized
@lru_cacheversion over(day, status)also works but is slower.
- Splitting the tuple assignment into three ordinary statements and reading already-updated values.
- Starting
restat-infinstead of 0. - Including
holdin the finalmax.
- Unreachable sentinel: JS/TS
-Infinityand Pythonfloat("-inf")absorb additions safely; C++ needs a finite very-negativelong longkept away fromLLONG_MINto avoid overflow UB. - Simultaneous state update: Python tuple assignment is atomic by construction; C++/JS/TS need explicit
next*temporaries (or JS/TS array destructuring, which allocates). - Python's
-infis a float, so the final answer should be converted back toint; the other languages stay in one numeric type throughout.
Complexity
k is usually 2–4, so this is effectively linear.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Sequential decisions with a small set of modes that restrict the next action.
- Stock problems with cooldown/fee/limited transactions; painting/tiling with adjacency rules.
- Whenever adding one small status flag turns a broken
dp[i]into a correct one.
- The "status" would need to be unbounded (the full history) — it is no longer a finite automaton; reconsider the state.
- No constraint links consecutive decisions — plain 1D (Linear) DP or a greedy suffices (stock II with unlimited transactions is greedy).
- The status space is huge (hundreds of thousands) — think of it as a graph problem or use a different formulation.
Alternatives
Common mistakes
- Initializing unreachable statuses to
0instead of-INF, which lets "sell without ever buying" produce profit. - Updating statuses in place so a later status reads the already-updated value of an earlier one from the same day.
- Forgetting which statuses are valid at the end (must not be holding).
- Not drawing the automaton first and missing a transition (e.g.
free → freeself-loop).
Interview patterns
- Best Time to Buy and Sell Stock II/III/IV, with Cooldown, with Transaction Fee.
- Paint House / Paint Fence (status = last colour).
- House Robber as a two-status machine; Delete and Earn.
- Count strings/arrays satisfying local adjacency rules (vowel permutations, no consecutive 1s).
- Coin ChangeIntermediate