Best Time to Buy and Sell Stock III
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 is the one to know cold; it’s the framing that survives all the way to LC 188 (at most 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
- 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.
- 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.
- 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.
- 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 states in the harder variant signals you’ve seen the family.
Problem statement
You are given an integer array prices where prices[i] is the stock price on day . Find the maximum profit you can achieve by completing at most two non-overlapping buy/sell transactions. You must sell before you buy again.
prices = [3, 3, 5, 0, 0, 3, 1, 4] 6 prices = [1, 2, 3, 4, 5] 4 prices = [7, 6, 4, 3, 1] 0 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:
-
Split the problem in half. For every possible “split day” , compute the best single trade in and the best single trade in . Sum them and take the maximum. With prefix-/suffix-best arrays, this is time and space.
-
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 time, but space.
Both are correct. The two-pass is easier to derive from first principles; the four-state collapse is the senior code.
Discussion
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.
The interview narration
Say this out loud:
“The two trades are non-overlapping, so there’s a ‘split day’ such that trade 1 ends by day and trade 2 starts at day or later. For each split, the best total profit is the best single trade in plus the best single trade in . There are possible splits and each LC 121 sub-call is , so this is . Correct, slow, my baseline.”
How it works, step by step
For each split from to :
- Run LC 121’s running-min sweep on
prices[0..split]to find the best single trade in the left half. - Run LC 121’s running-min sweep on
prices[split..n-1]for the right half. - 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
#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
Each split iteration calls singleTradeBest twice, each of which scans up to elements. Total: operations, which is . For this is already 10⁸ ops — borderline. For , 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.
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.
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 withinprices[0..i]. Build it with a forward sweep maintaining the running minimum.bestRight[i]= max profit from a single trade entirely withinprices[i..n-1]. Build it with a backward sweep maintaining the running maximum.
Then the answer is — the sum over the best split.
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).How it works, step by step
Forward pass for bestLeft:
- Initialize
bestLeft[0] = 0and a runningminL = prices[0]. - For from to : update
minL = min(minL, prices[i]), thenbestLeft[i] = max(bestLeft[i-1], prices[i] - minL).
Backward pass for bestRight:
- Initialize
bestRight[n-1] = 0and a runningmaxR = prices[n-1]. - For from down to : update
maxR = max(maxR, prices[i]), thenbestRight[i] = max(bestRight[i+1], maxR - prices[i]).
Combine:
- Loop from to and track .
Each pass is . Three passes total; space for the two arrays.
The code
#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 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: — three linear passes.
- Space: for the two arrays.
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.
The insight, stated cleanly
Think of the trader as walking through four states in order: no trade yet → bought first share → sold first share → bought second share → sold 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 , the trader effectively has cash in hand. Buying the second share at price now costs in net out-of-pocket spend. Tracking across days gives the cheapest effective second buy.
profit2 at the end is the answer; the others are intermediate state.Start. buy1 = buy2 = +∞ (no buy yet). profit1 = profit2 = 0 (no trade is always available).
How it works, step by step
For each price in left-to-right order, perform four updates in this exact order:
buy1 = min(buy1, p)— track the cheapest first-buy cost.profit1 = max(profit1, p - buy1)— best running first-trade profit, assuming we sell today.buy2 = min(buy2, p - profit1)— cheapest effective second-buy cost, whereprofit1from step 2 subsidizes the spend.profit2 = max(profit2, p - buy2)— best total profit, assuming we sell the second share today.
Initialize buy1 and buy2 to , and both profits to (the no-trade baseline is always available).
The order matters. Each step uses the just-updated value from the prior step:
profit1reads the newbuy1(we’d buy and sell on day in the degenerate case, profit 0).buy2reads the newprofit1(we’d subsidize today’s buy with today’s first-trade profit, possibly zero).profit2reads the newbuy2.
This in-order chaining is what allows the 2D state space to collapse to four scalars without losing information.
The code
#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 , profit1 equals the best profit achievable by buying on some day and selling on some day with . Proof. This is the running-min argument from LC 121. ✓
Claim 2 (buy2 is correct). After processing day , — the cheapest effective second-buy cost over all valid second-buy days . The “effective” cost subtracts the best first-trade profit available up to and including day — which represents the cash on hand when the second buy happens.
Proof sketch. By induction. Base: at , buy2 = prices[0] - 0 = prices[0] if we ignore the trivially zero profit1. Inductive step: at each new , buy2 either stays the same or improves to prices[i] - profit1 (where profit1 is the up-to-day- value from claim 1). The min over all of these candidate values is exactly the formula above.
Claim 3 (profit2 is correct). After processing day , profit2 equals the best total profit achievable by completing two non-overlapping trades using only prices in .
Proof. . Substituting from claim 2:
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 with 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 = 0is returned. Correct. - Strictly increasing:
profit1grows toprices[n-1] - prices[0](the full single trade).profit2matches 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: . One pass, four constant-time updates per day.
- Space: . Four integers.
Approach comparison
| Approach | Time | Space | When to reach for it |
|---|---|---|---|
| Brute force | Baseline only. | ||
| Two-pass | When clarity matters more than memory; easy to derive on a whiteboard. | ||
| Four-state DP | 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 transactions instead of 2.” | LC 188. Generalize the four states to states (buy/profit pairs for each of 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 , then within find the buy/sell that yields bestLeft[i*], and similarly within for bestRight[i*]. Two extra walks, still . |
| ”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 and verify the final value is . |
| ”How do you adapt the brute force for transactions?” | The brute force generalizes to — split into pieces and run LC 121 on each. Useless for 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 transactions through day . The recurrence is the same chained-update structure unrolled into a 2D table. time, space, easier to reason about for . |
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 , transactions completed , currently holding stock or not). For and holding , that’s 4 logical states per day. The four-state DP is exactly that table compressed: each running variable corresponds to one 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 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 to at no time cost.
What to say out loud — the delivery script
Ten beats:
- “Let me restate: prices array, at most two non-overlapping buy/sell trades, maximize total profit.”
- Ask: “at most 2 or exactly 2? trades can share a day? n bounds?”
- “The greedy from LC 122 doesn’t work — too many up-days, only two transactions allowed. So I’m back in DP territory.”
- “Brute force: try every split point, run LC 121 on each half. . Name and move on.”
- “Two-pass linear: precompute
bestLeft[i](best trade in[0, i]) andbestRight[i](best trade in[i, n-1]) with two sweeps. Answer is the max ofbestLeft[i] + bestRight[i].” - “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.” - Write the four-state code. Six lines.
- “Correctness:
profit1is 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.” - Trace example 1 ([3, 3, 5, 0, 0, 3, 1, 4]). Show that
profit2reaches 6 by day 7. - “Complexity: time, space. Generalizes to LC 188 (at most transactions) by tracking 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 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.