Davide Spataro

Best Time to Buy and Sell Stock III

hard · Dynamic Programming · 28 min read
array dp state-machine subsidy two-transactions

Introduction

The third entry in the buy/sell DP family. The relaxation that made the unlimited-transactions variant easier — every up-day is its own trade — has been pulled back. Now you can transact at most twice, which forces you to pick which two trades. The greedy that worked for unlimited transactions is no longer correct: there are inputs with five up-days where you have to drop three of them and keep only the most valuable two.

The chapter walks the standard arc — brute-force baseline, then two clean linear-time solutions. The two-pass approach with bestLeft and bestRight arrays is the most readable. The four-state DP that trims the space to O(1)O(1) is the one to know cold; it’s the framing that survives all the way to LC 188 (at most kk transactions).

Adding a transaction cap forces a choice. The interview signal is the framing that turns the choice into a state machine.

If you haven’t read LC 121 and LC 122 yet, do that first — this chapter assumes the running-min and the unlimited-greedy are familiar territory. If you’ve coded the four-state DP before, jump to the correctness analysis under Approach 03; the subsidy trick is the part most candidates struggle to articulate.

What the interviewer is actually testing

  1. Recognizing why the greedy breaks. A candidate who tries to apply LC 122’s “sum of positive deltas” with an upper bound on transactions has missed the constraint. Naming why it breaks — too many positive deltas, but only two transactions allowed — is the entry signal.
  2. Comfort with multi-state DP. The two-pass approach is intuitive; the four-state collapse is the elegant answer. Demonstrating that you see them as the same algorithm with different state representations shows depth.
  3. Articulating the subsidy trick. “After the first trade, I effectively have profit1 cash in hand, so the second buy at price p costs me only p − profit1.” That sentence is the senior signal. Most candidates code it correctly but fumble the explanation.
  4. Knowing the family extension. This is the bridge between LC 121 / LC 122 (one or unlimited transactions) and LC 188 (at most k). Mentioning that the four-state DP generalizes to 2k2k states in the harder variant signals you’ve seen the family.

Problem statement

Figure · two trades, total profit 6The canonical input. The first trade ( green) buys on day 3 (price 0) and sells on day 5 (price 3). The second trade ( purple) buys on day 6 (price 1) and sells on day 7 (price 4). Each trade contributes 3.
0245+3+33031520304351647
Problem

You are given an integer array prices where prices[i] is the stock price on day ii. Find the maximum profit you can achieve by completing at most two non-overlapping buy/sell transactions. You must sell before you buy again.

Example 1
Input prices = [3, 3, 5, 0, 0, 3, 1, 4]
Output 6
Two trades: buy on day 3 (price 0), sell on day 5 (price 3) for +3. Then buy on day 6 (price 1), sell on day 7 (price 4) for +3. Total 6. Other splits exist that also reach 6.
Example 2
Input prices = [1, 2, 3, 4, 5]
Output 4
Strictly increasing — one trade (buy day 0 at 1, sell day 4 at 5) yields 4. The cap allows two trades but the second is unnecessary; the algorithm correctly returns 4 either way.
Example 3
Input prices = [7, 6, 4, 3, 1]
Output 0
Strictly decreasing — no profitable trade exists. The no-trade option (profit 0) is always available.

Clarifying questions — and why each one matters

Clarification questions

Q Is it 'exactly 2' or 'at most 2' transactions?
A At most 2. The empty trade set (profit 0) and the single-trade option (LC 121's answer) are both valid candidates. The algorithm should never return a negative number for an input where any trade would lose money.
Q Can transactions overlap or share a day?
A Sharing a day is allowed (sell trade 1 on day i, buy trade 2 on day i — same day is fine because the sell happens first). True overlap (holding two shares simultaneously) is forbidden by the problem statement.
Q Could transactions partially overlap if the splits are ambiguous?
A No. Each transaction is a (buy, sell) pair with buy strictly before sell. The two transactions are independent except for the must-sell-before-buying-again constraint. Brute force enumerates every split point that respects this.
Q What's the upper bound on n?
A Typical: n ≤ 10⁵. The brute-force O(n²) is borderline; the linear approaches are comfortable. If n is much larger or streaming, only the four-state constant-space DP fits.
Q Are we returning the profit, or the trade list?
A Default is profit. If asked for the trades, the two-pass approach is easier to backtrack: walk forward to the best split, then identify the buy/sell pair on each side via the running min/max. The four-state DP needs more bookkeeping to reconstruct.

A mental framework — the cap forces a choice

In LC 122 every up-day was independently profitable, so the greedy could capture them all. Adding at most two transactions changes the question from “should I take this up-day?” to “are these among the top two up-day clusters?”. Some up-days now have to be discarded — and which ones depends on the global structure of the price series, not on local information.

That global dependency is what forces the algorithm back into DP territory. Two approaches naturally fall out:

  1. Split the problem in half. For every possible “split day” ii, compute the best single trade in [0,i][0, i] and the best single trade in [i,n1][i, n-1]. Sum them and take the maximum. With prefix-/suffix-best arrays, this is O(n)O(n) time and O(n)O(n) space.

  2. Roll the two trades together with a four-state recurrence. Track four running values that simultaneously represent “best position after 0, 0.5, 1, or 1.5 trades.” Each new day updates all four in turn. Same O(n)O(n) time, but O(1)O(1) space.

Both are correct. The two-pass is easier to derive from first principles; the four-state collapse is the senior code.

Discussion

Approach 01

Brute force — try every split point

For each possible split, compute the best single trade in each half (using LC 121's running-min sweep). Sum. Take the maximum.

Time O(n²) Space O(1)

The interview narration

Say this out loud:

“The two trades are non-overlapping, so there’s a ‘split day’ ii such that trade 1 ends by day ii and trade 2 starts at day ii or later. For each split, the best total profit is the best single trade in [0,i][0, i] plus the best single trade in [i,n1][i, n-1]. There are nn possible splits and each LC 121 sub-call is O(n)O(n), so this is O(n2)O(n^2). Correct, slow, my baseline.”

How it works, step by step

For each split from 00 to n1n-1:

  1. Run LC 121’s running-min sweep on prices[0..split] to find the best single trade in the left half.
  2. Run LC 121’s running-min sweep on prices[split..n-1] for the right half.
  3. Sum the two and update the running maximum.

The same-day overlap is handled implicitly: when split = i, the second sweep starts at day i itself, and a trade that “buys on day i and sells on day i” yields zero profit, which is dominated by any genuine trade.

The code

brute-force.cpp
#include <algorithm>
#include <climits>
#include <vector>
namespace {
int singleTradeBest(const std::vector<int>& prices, int lo, int hi) {
if (lo >= hi) return 0;
int best = 0;
int minSeen = prices[lo];
for (int i = lo; i <= hi; ++i) {
minSeen = std::min(minSeen, prices[i]);
best = std::max(best, prices[i] - minSeen);
}
return best;
}
} // namespace
int maxProfit(const std::vector<int>& prices) {
const int n = static_cast<int>(prices.size());
if (n < 2) return 0;
int best = 0;
for (int split = 0; split < n; ++split) {
const int p1 = singleTradeBest(prices, 0, split);
const int p2 = singleTradeBest(prices, split, n - 1);
best = std::max(best, p1 + p2);
}
return best;
}

Two LC 121 calls per split point; n splits total. The helper `singleTradeBest` is the LC 121 sweep restricted to a sub-range.

Why both operations are O(n2)O(n^2)

Each split iteration calls singleTradeBest twice, each of which scans up to nn elements. Total: 2n22n^2 operations, which is O(n2)O(n^2). For n=104n = 10^4 this is already 10⁸ ops — borderline. For n=105n = 10^5, hopeless.

What it gets right

  • The split framing is correct. Every two-trade configuration corresponds to some split day; no valid trade pair is excluded.
  • It exposes the redundancy. The same prefix is rescanned for every split. The two-pass approach below precomputes those scans once.
Approach 02

Two-pass — bestLeft and bestRight arrays

Precompute the best single trade ending by day i (left sweep) and the best single trade starting at day i (right sweep). Sum the arrays element-wise and take the max.

Time O(n) Space O(n) dpprefix-arrays

The insight

The brute force re-runs LC 121’s sweep on every prefix and every suffix. Both can be computed once in linear time:

  • bestLeft[i] = max profit from a single trade entirely within prices[0..i]. Build it with a forward sweep maintaining the running minimum.
  • bestRight[i] = max profit from a single trade entirely within prices[i..n-1]. Build it with a backward sweep maintaining the running maximum.

Then the answer is maxi(bestLeft[i]+bestRight[i])\max_i \big(\text{bestLeft}[i] + \text{bestRight}[i]\big) — the sum over the best split.

Figure · two-pass: bestLeft above, bestRight belowThe green band along the top is bestLeft[i] — the best single trade in prices[0..i]. The purple band along the bottom is bestRight[i] — the best single trade in prices[i..n-1]. The answer is the maximum of bestLeft[i] + bestRight[i] across all i. Vertical accent line at the winning split (i = 2, total = 6).
split @ i = 2bestLeft00222334price01234567bestRight44444330

How it works, step by step

Forward pass for bestLeft:

  1. Initialize bestLeft[0] = 0 and a running minL = prices[0].
  2. For ii from 11 to n1n-1: update minL = min(minL, prices[i]), then bestLeft[i] = max(bestLeft[i-1], prices[i] - minL).

Backward pass for bestRight:

  1. Initialize bestRight[n-1] = 0 and a running maxR = prices[n-1].
  2. For ii from n2n-2 down to 00: update maxR = max(maxR, prices[i]), then bestRight[i] = max(bestRight[i+1], maxR - prices[i]).

Combine:

  1. Loop ii from 00 to n1n-1 and track max(bestLeft[i]+bestRight[i])\max\big(\text{bestLeft}[i] + \text{bestRight}[i]\big).

Each pass is O(n)O(n). Three passes total; O(n)O(n) space for the two arrays.

The code

two-pass.cpp
#include <algorithm>
#include <vector>
int maxProfit(const std::vector<int>& prices) {
const int n = static_cast<int>(prices.size());
if (n < 2) return 0;
std::vector<int> bestLeft(n, 0);
int minL = prices[0];
for (int i = 1; i < n; ++i) {
minL = std::min(minL, prices[i]);
bestLeft[i] = std::max(bestLeft[i - 1], prices[i] - minL);
}
std::vector<int> bestRight(n, 0);
int maxR = prices[n - 1];
for (int i = n - 2; i >= 0; --i) {
maxR = std::max(maxR, prices[i]);
bestRight[i] = std::max(bestRight[i + 1], maxR - prices[i]);
}
int best = 0;
for (int i = 0; i < n; ++i) {
best = std::max(best, bestLeft[i] + bestRight[i]);
}
return best;
}

Two prefix-style sweeps build the helper arrays; a third combines them. Each pass is independently easy to argue correct.

Why this is the readable answer

The two-pass approach reads like the math. Each array has a clear meaning. Tracing it on a small example is straightforward. If the interviewer is teaching the problem (or you’re explaining it on a whiteboard), this is the version to lead with.

Why we can do better on space

The two arrays together cost O(n)O(n) memory. But every element is read exactly once in the combining pass, then never used again. That suggests we can eliminate the arrays — if we can interleave the computation so each cell’s left value and right value are available at the same time. The four-state DP below does exactly that.

Complexity

  • Time: O(n)O(n) — three linear passes.
  • Space: O(n)O(n) for the two arrays.
Approach 03

Four-state DP — the constant-space production answer Recommended

Track four running values: the cheapest first buy, the best first profit, the cheapest effective second buy, and the best total profit. One pass, two if/else updates per day. O(n) time, O(1) space.

Time O(n) Space O(1) dpstate-machinerecommendedconstant-space

The insight, stated cleanly

Think of the trader as walking through four states in order: no trade yetbought first sharesold first sharebought second sharesold second share. The DP tracks the best running profit reachable in each state at each day. Four running variables suffice:

  • buy1 — cheapest price seen so far for the first buy. Equivalently, the minimum cost of being in the “bought first share” state.
  • profit1 — best profit seen so far after selling the first share.
  • buy2 — cheapest effective cost of the second buy, after subtracting the running first-trade profit. This is the subsidy trick.
  • profit2 — best total profit seen so far after the second sale.

The subsidy is the trick worth naming. After the first trade closes with profit p1p_1, the trader effectively has p1p_1 cash in hand. Buying the second share at price pp now costs pp1p - p_1 in net out-of-pocket spend. Tracking min(pprofit1)\min(p - \text{profit1}) across days gives the cheapest effective second buy.

Live · four-state DP sweepStep through the prices. Four running values update in sequence on every day. profit2 at the end is the answer; the others are intermediate state.
Input:
01234567
buy1+∞
profit10
buy2+∞
profit20
step 0 / 8

Start. buy1 = buy2 = +∞ (no buy yet). profit1 = profit2 = 0 (no trade is always available).

How it works, step by step

For each price pp in left-to-right order, perform four updates in this exact order:

  1. buy1 = min(buy1, p) — track the cheapest first-buy cost.
  2. profit1 = max(profit1, p - buy1) — best running first-trade profit, assuming we sell today.
  3. buy2 = min(buy2, p - profit1) — cheapest effective second-buy cost, where profit1 from step 2 subsidizes the spend.
  4. profit2 = max(profit2, p - buy2) — best total profit, assuming we sell the second share today.

Initialize buy1 and buy2 to ++\infty, and both profits to 00 (the no-trade baseline is always available).

The order matters. Each step uses the just-updated value from the prior step:

  • profit1 reads the new buy1 (we’d buy and sell on day pp in the degenerate case, profit 0).
  • buy2 reads the new profit1 (we’d subsidize today’s buy with today’s first-trade profit, possibly zero).
  • profit2 reads the new buy2.

This in-order chaining is what allows the 2D state space to collapse to four scalars without losing information.

The code

four-state-dp.cpp
#include <algorithm>
#include <climits>
#include <vector>
int maxProfit(const std::vector<int>& prices) {
if (prices.empty()) return 0;
int buy1 = INT_MAX; // cheapest price for the first buy
int profit1 = 0; // best profit after the first sale
int buy2 = INT_MAX; // cheapest effective second-buy cost (subsidized by profit1)
int profit2 = 0; // best total profit after the second sale
for (const int p : prices) {
buy1 = std::min(buy1, p);
profit1 = std::max(profit1, p - buy1);
buy2 = std::min(buy2, p - profit1);
profit2 = std::max(profit2, p - buy2);
}
return profit2;
}

Four lines of body. The first half is identical to LC 121; the second half is the subsidy trick that extends to a second trade.

Correctness — why the chained min/max gives the right answer

This is the section interviewers probe. State the argument in three claims:

Claim 1 (profit1 is correct). After processing day ii, profit1 equals the best profit achievable by buying on some day jij \le i and selling on some day kik \le i with jkj \le k. Proof. This is the running-min argument from LC 121. ✓

Claim 2 (buy2 is correct). After processing day ii, buy2=minki(prices[k]profit1k)\text{buy2} = \min_{k \le i} (\text{prices}[k] - \text{profit1}_k) — the cheapest effective second-buy cost over all valid second-buy days kk. The “effective” cost subtracts the best first-trade profit available up to and including day kk — which represents the cash on hand when the second buy happens.

Proof sketch. By induction. Base: at i=0i = 0, buy2 = prices[0] - 0 = prices[0] if we ignore the trivially zero profit1. Inductive step: at each new ii, buy2 either stays the same or improves to prices[i] - profit1 (where profit1 is the up-to-day-ii value from claim 1). The min over all ii of these candidate values is exactly the formula above.

Claim 3 (profit2 is correct). After processing day ii, profit2 equals the best total profit achievable by completing two non-overlapping trades using only prices in [0,i][0, i].

Proof. profit2=maxi(prices[i]buy2i)\text{profit2} = \max_i (\text{prices}[i] - \text{buy2}_i). Substituting from claim 2:

profit2=maxi(prices[i]minki(prices[k]profit1at k))\text{profit2} = \max_i \Big(\text{prices}[i] - \min_{k \le i} (\text{prices}[k] - \text{profit1}_{\text{at } k})\Big)=maxi,ki(prices[i]prices[k]+profit1at k)= \max_{i, k \le i} \Big(\text{prices}[i] - \text{prices}[k] + \text{profit1}_{\text{at } k}\Big)

The inner expression is (second trade profit using buy day k, sell day i) + (best first-trade profit completed by day k). Maximizing over all i,ki, k with kik \le i gives exactly the optimum two-trade profit. ✓

That three-step argument is the senior-signal proof for this problem. State the claims explicitly during the interview; show that the chained min/max updates give them in linear time.

Edge cases the mechanics handle for free

  • Empty array: the loop never runs; profit2 = 0 is returned. Correct.
  • Strictly increasing: profit1 grows to prices[n-1] - prices[0] (the full single trade). profit2 matches it (the second trade is a no-op). The algorithm correctly returns LC 121’s answer.
  • Strictly decreasing: every step keeps profit1 = profit2 = 0. Correct.
  • All same price: every step keeps profit1 = profit2 = 0. Correct.
  • Single profitable swing: profit2 = profit1; the second trade contributes nothing.

Complexity

  • Time: O(n)O(n). One pass, four constant-time updates per day.
  • Space: O(1)O(1). Four integers.

Approach comparison

ApproachTimeSpaceWhen to reach for it
Brute forceO(n2)O(n^2)O(1)O(1)Baseline only.
Two-passO(n)O(n)O(n)O(n)When clarity matters more than memory; easy to derive on a whiteboard.
Four-state DPO(n)O(n)O(1)O(1)Default. Production answer; bridges to LC 188.

Default answer: the four-state DP. State the subsidy trick out loud when you write buy2 = min(buy2, p - profit1) — that’s the line that makes the algorithm non-obvious.

Follow-ups to anticipate

The interviewer asks…What to do
”At most kk transactions instead of 2.”LC 188. Generalize the four states to 2k2k states (buy/profit pairs for each of kk transactions). The chained-update structure is identical.
”Return the actual two trades, not just the profit.”Use the two-pass approach: find the best split index ii^*, then within [0,i][0, i^*] find the buy/sell that yields bestLeft[i*], and similarly within [i,n1][i^*, n-1] for bestRight[i*]. Two extra walks, still O(n)O(n).
”Add a transaction fee.”LC 714 with the additional cap. Subtract the fee on every sale: profit1 = max(profit1, p - buy1 - fee), profit2 = max(profit2, p - buy2 - fee). Same structure.
”Add a 1-day cooldown.”LC 309 with the cap. Adds a third dimension to the state space (cooldown phase). The four-state DP grows to six states.
”What if the cap is ‘exactly 2’ instead of ‘at most 2’?”Subtle: now you must reject inputs where two profitable trades don’t exist. Modify the initialization so the no-trade option is not a baseline; track -\infty and verify the final value is 0\ge 0.
”How do you adapt the brute force for kk transactions?”The brute force generalizes to O(nk)O(n^{k}) — split into kk pieces and run LC 121 on each. Useless for k>2k > 2 but conceptually clean.
”Can the four-state DP be unrolled into a 2D DP table for clarity?”Yes — dp[i][t] = best profit using at most tt transactions through day ii. The recurrence is the same chained-update structure unrolled into a 2D table. O(nk)O(n \cdot k) time, O(nk)O(n \cdot k) space, easier to reason about for k>2k > 2.

Pattern recognition — collapsing 2D DP into running variables

This chapter teaches a specific structural move worth naming: when a 2D DP has dependencies that flow strictly forward in one dimension, the second dimension often collapses to a constant number of running variables.

LC 123’s natural DP is dp[i][t][holding] (day ii, transactions completed tt, currently holding stock or not). For t2t \le 2 and holding {0,1}\in \{0, 1\}, that’s 4 logical states per day. The four-state DP is exactly that table compressed: each running variable corresponds to one (t,holding)(t, \text{holding}) combination, and the in-order updates implement the recurrence without ever materializing the table.

Other problems with the same shape:

  • House Robber II (circular). A 2D state (current house, started-with-rob-or-skip) collapses to two running variables.
  • Decode Ways. State (position, last-digit-context) collapses to two running variables.
  • Distinct Subsequences. A 2D table where each row depends only on the previous row collapses to two rolling rows.
  • Longest Common Subsequence with O(min(m,n))O(\min(m, n)) space. Same row-rolling collapse.

The reflex: when you have a 2D DP whose inner dimension has small bounded size, ask if the recurrence can be evaluated by chaining updates on running variables. When yes, the space drops from O(nk)O(n \cdot k) to O(k)O(k) at no time cost.

What to say out loud — the delivery script

Ten beats:

  1. “Let me restate: prices array, at most two non-overlapping buy/sell trades, maximize total profit.”
  2. Ask: “at most 2 or exactly 2? trades can share a day? n bounds?”
  3. “The greedy from LC 122 doesn’t work — too many up-days, only two transactions allowed. So I’m back in DP territory.”
  4. “Brute force: try every split point, run LC 121 on each half. O(n2)O(n^2). Name and move on.”
  5. “Two-pass linear: precompute bestLeft[i] (best trade in [0, i]) and bestRight[i] (best trade in [i, n-1]) with two sweeps. Answer is the max of bestLeft[i] + bestRight[i].”
  6. “For constant space: four running variables — buy1, profit1, buy2, profit2. The trick is the second buy: buy2 = min(buy2, p - profit1) — the running first-trade profit subsidizes the second buy’s effective cost.”
  7. Write the four-state code. Six lines.
  8. “Correctness: profit1 is LC 121’s answer. buy2 = min over k of (prices[k] - profit1@k) is the cheapest effective second-buy. profit2 = max over i, k≤i of (prices[i] - prices[k] + profit1@k) is the optimum two-trade total.”
  9. Trace example 1 ([3, 3, 5, 0, 0, 3, 1, 4]). Show that profit2 reaches 6 by day 7.
  10. “Complexity: O(n)O(n) time, O(1)O(1) space. Generalizes to LC 188 (at most kk transactions) by tracking 2k2k running variables instead of 4.”

Summary

The two-transaction cap turns the buy/sell problem from a one-line greedy back into a real DP. Two clean linear-time solutions exist: the two-pass approach with bestLeft/bestRight arrays is the readable derivation; the four-state DP is the constant-space production answer. The teaching is the subsidy trick — using the running first-trade profit to discount the second buy — and the 2D-to-running-variable collapse that drops the space cost.

The four-state DP is also the bridge to the rest of the family. LC 188 generalizes it to 2k2k states; LC 309 adds a cooldown dimension; LC 714 adds a fee on the sell transition. All of them rotate this chapter’s structure rather than introducing a new one.