188. Best Time to Buy and Sell Stock IV
Problem Description
You are given an array prices
where prices[i]
represents the stock price on day i
. You also have a limit k
on the maximum number of transactions you can make.
Your goal is to find the maximum profit you can achieve by buying and selling the stock, with the following constraints:
- You can complete at most
k
transactions (buying and then selling counts as one transaction) - You cannot hold multiple stocks at once - you must sell your current stock before buying again
- You cannot buy and sell on the same day
The solution uses dynamic programming with memoization. The function dfs(i, j, k)
tracks three parameters:
i
: the current day indexj
: the number of remaining transactions allowedk
: whether currently holding stock (0 = not holding, 1 = holding)
At each day, there are three possible decisions:
- Do nothing and move to the next day
- If holding stock (
k = 1
), sell it forprices[i]
profit and decrement available transactions - If not holding stock (
k = 0
) and have transactions remaining (j > 0
), buy stock for-prices[i]
cost
The function recursively explores all valid paths and returns the maximum profit achievable. The @cache
decorator prevents recalculating the same state multiple times, improving efficiency.
The answer is found by calling dfs(0, k, 0)
, which starts from day 0 with k
transactions available and no stock held initially.
Intuition
The key insight is that at any given moment, we need to track three pieces of information: which day we're on, how many transactions we have left, and whether we're currently holding stock or not.
Think about the problem as a series of states. On each day, we're in one of two states: either holding stock or not holding stock. When we're holding stock, we can only sell. When we're not holding stock, we can only buy (if we have transactions remaining).
Why do we need to track transactions? Because each complete buy-sell cycle counts as one transaction, and we're limited to k
transactions total. We decrement the transaction count when we complete a full cycle (at the selling point).
The recursive approach naturally emerges when we consider that at each day, we make a decision that leads to a new state. From day i
, we want to know: "What's the maximum profit I can get from here onwards?" This depends on:
- Our current position (holding stock or not)
- How many transactions we can still make
- The future decisions we'll make
The three choices at each step represent our decision tree:
- Skip this day - Sometimes the best action is no action. We simply move to the next day with the same state.
- Sell (if holding) - If
k = 1
(holding stock), we can sell forprices[i]
profit. After selling, we're back to not holding stock (k = 0
) and we've used up one transaction. - Buy (if not holding and have transactions left) - If
k = 0
andj > 0
, we can buy stock forprices[i]
cost (hence the negative). After buying, we're holding stock (k = 1
).
The beauty of this approach is that it naturally handles the constraint that we can't buy and sell simultaneously - the state k
ensures we're either in "can buy" mode or "can sell" mode, never both.
By exploring all possible paths through these decisions and taking the maximum, we find the optimal trading strategy. The memoization (@cache
) is crucial because many different sequences of decisions can lead to the same state (same day, same transactions left, same holding status), so we avoid recalculating these overlapping subproblems.
Solution Approach
The solution implements a top-down dynamic programming approach using memoization. Let's walk through the implementation step by step:
Function Definition:
The core function dfs(i, j, k)
represents:
i
: current day index (0 to n-1)j
: number of transactions remainingk
: stock holding state (0 = not holding, 1 = holding)
Base Case:
if i >= len(prices):
return 0
When we've processed all days, no more profit can be made, so return 0.
State Transitions:
-
Default Action - Do Nothing:
ans = dfs(i + 1, j, k)
We always consider the option of skipping the current day and maintaining our current state.
-
Selling Stock (when k = 1):
if k: ans = max(ans, prices[i] + dfs(i + 1, j, 0))
- If currently holding stock (
k = 1
), we can sell it - Selling gives us
prices[i]
profit - After selling, we transition to not holding (
k = 0
) - Transactions remain the same (
j
) because we count a transaction when we complete the buy-sell cycle
- If currently holding stock (
-
Buying Stock (when k = 0 and j > 0):
elif j: ans = max(ans, -prices[i] + dfs(i + 1, j - 1, 1))
- If not holding stock (
k = 0
) and have transactions available (j > 0
) - Buying costs us
prices[i]
(hence negative) - After buying, we transition to holding (
k = 1
) - We decrement available transactions (
j - 1
) as we're starting a new transaction
- If not holding stock (
Key Design Decisions:
-
Transaction Counting: The transaction is decremented when buying (not selling). This simplifies tracking since each buy operation initiates a new transaction that will be completed upon selling.
-
Memoization with @cache: The decorator automatically caches results for each unique
(i, j, k)
tuple, preventing redundant calculations. This is crucial as many different trading sequences can lead to the same state. -
State Space: The total state space is
O(n × k × 2)
where:n
is the number of daysk
is the maximum transactions2
represents the two holding states
Time and Space Complexity:
- Time:
O(n × k)
as we compute each state at most once - Space:
O(n × k)
for the memoization cache
The solution starts with dfs(0, k, 0)
- beginning at day 0, with k
transactions available, and not holding any stock. The function explores all valid trading paths and returns the maximum achievable profit.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example with prices = [3, 2, 6, 5, 0, 3]
and k = 2
(maximum 2 transactions).
We start with dfs(0, 2, 0)
- day 0, 2 transactions available, not holding stock.
Day 0 (price = 3): Not holding stock, 2 transactions available
- Option 1: Skip day →
dfs(1, 2, 0)
- Option 2: Buy stock →
-3 + dfs(1, 1, 1)
(spend 3, now holding, 1 transaction left) - We explore both paths
Following the buy path from Day 0: Day 1 (price = 2): Holding stock, 1 transaction available
- Option 1: Skip day →
dfs(2, 1, 1)
- Option 2: Sell stock →
2 + dfs(2, 1, 0)
(gain 2, not holding) - Selling would give us -1 profit so far (bought at 3, sold at 2)
Better path - Skip Day 0: Day 1 (price = 2): Not holding stock, 2 transactions available
- Option 1: Skip day →
dfs(2, 2, 0)
- Option 2: Buy stock →
-2 + dfs(2, 1, 1)
(spend 2, now holding, 1 transaction left)
Following the buy at Day 1 path: Day 2 (price = 6): Holding stock, 1 transaction available
- Option 1: Skip day →
dfs(3, 1, 1)
- Option 2: Sell stock →
6 + dfs(3, 1, 0)
(gain 6, not holding) - Selling gives us 4 profit (bought at 2, sold at 6)
After selling at Day 2: Day 3 (price = 5): Not holding stock, 1 transaction available
- Option 1: Skip day →
dfs(4, 1, 0)
- Option 2: Buy stock →
-5 + dfs(4, 0, 1)
(spend 5, now holding, 0 transactions left)
Day 4 (price = 0): Following the skip path, not holding, 1 transaction available
- Option 1: Skip day →
dfs(5, 1, 0)
- Option 2: Buy stock →
0 + dfs(5, 0, 1)
(buy at 0, now holding, 0 transactions left)
Day 5 (price = 3): If we bought at Day 4, holding stock, 0 transactions available
- Option 1: Skip day →
dfs(6, 0, 1)
→ returns 0 (base case) - Option 2: Sell stock →
3 + dfs(6, 0, 0)
→ returns 3
Optimal Path:
- Buy at day 1 (price = 2) → -2 profit, 1 transaction used
- Sell at day 2 (price = 6) → +6 profit, total = 4
- Buy at day 4 (price = 0) → -0 profit, 2nd transaction used
- Sell at day 5 (price = 3) → +3 profit, total = 7
The function explores all possible paths through memoized recursion and returns the maximum profit of 7. The memoization ensures that when we encounter the same state (same day, transactions, and holding status) through different paths, we don't recalculate it.
Solution Implementation
1class Solution:
2 def maxProfit(self, k: int, prices: List[int]) -> int:
3 """
4 Find maximum profit from at most k stock transactions.
5
6 Args:
7 k: Maximum number of transactions allowed
8 prices: List of stock prices
9
10 Returns:
11 Maximum profit achievable
12 """
13 from functools import cache
14
15 @cache
16 def dfs(day_index: int, transactions_left: int, holding_stock: int) -> int:
17 """
18 Dynamic programming helper to calculate maximum profit.
19
20 Args:
21 day_index: Current day index in prices array
22 transactions_left: Number of buy-sell transactions remaining
23 holding_stock: 1 if currently holding stock, 0 otherwise
24
25 Returns:
26 Maximum profit from current state onwards
27 """
28 # Base case: no more days left
29 if day_index >= len(prices):
30 return 0
31
32 # Option 1: Do nothing today (skip to next day)
33 max_profit = dfs(day_index + 1, transactions_left, holding_stock)
34
35 if holding_stock:
36 # Currently holding stock - can sell today
37 max_profit = max(
38 max_profit,
39 prices[day_index] + dfs(day_index + 1, transactions_left, 0)
40 )
41 elif transactions_left > 0:
42 # Not holding stock and have transactions left - can buy today
43 # Buying uses one transaction
44 max_profit = max(
45 max_profit,
46 -prices[day_index] + dfs(day_index + 1, transactions_left - 1, 1)
47 )
48
49 return max_profit
50
51 # Start from day 0, with k transactions available, not holding any stock
52 return dfs(0, k, 0)
53
1class Solution {
2 // Memoization cache: dp[day][transactionsLeft][holdingStock]
3 // dp[i][j][k] represents max profit at day i with j transactions left and k=1 if holding stock
4 private Integer[][][] dp;
5 private int[] prices;
6 private int n;
7
8 public int maxProfit(int k, int[] prices) {
9 n = prices.length;
10 this.prices = prices;
11 // Initialize memoization array
12 // n days, k+1 transactions (0 to k), 2 states (0: not holding, 1: holding)
13 dp = new Integer[n][k + 1][2];
14
15 // Start from day 0, with k transactions available, not holding any stock
16 return dfs(0, k, 0);
17 }
18
19 /**
20 * Dynamic programming with memoization to find maximum profit
21 * @param day Current day index
22 * @param transactionsLeft Number of complete transactions remaining
23 * @param isHolding 0 if not holding stock, 1 if holding stock
24 * @return Maximum profit from current state
25 */
26 private int dfs(int day, int transactionsLeft, int isHolding) {
27 // Base case: no more days left
28 if (day >= n) {
29 return 0;
30 }
31
32 // Return memoized result if already computed
33 if (dp[day][transactionsLeft][isHolding] != null) {
34 return dp[day][transactionsLeft][isHolding];
35 }
36
37 // Option 1: Do nothing on this day (skip to next day)
38 int maxProfit = dfs(day + 1, transactionsLeft, isHolding);
39
40 if (isHolding == 1) {
41 // Currently holding stock: can sell
42 // Selling completes a transaction, so transactionsLeft stays the same
43 maxProfit = Math.max(maxProfit,
44 prices[day] + dfs(day + 1, transactionsLeft, 0));
45 } else if (transactionsLeft > 0) {
46 // Not holding stock and have transactions left: can buy
47 // Buying starts a new transaction, so decrement transactionsLeft
48 maxProfit = Math.max(maxProfit,
49 -prices[day] + dfs(day + 1, transactionsLeft - 1, 1));
50 }
51
52 // Memoize and return the result
53 return dp[day][transactionsLeft][isHolding] = maxProfit;
54 }
55}
56
1class Solution {
2public:
3 int maxProfit(int k, vector<int>& prices) {
4 int n = prices.size();
5
6 // dp[day][transactions_left][holding_stock]
7 // dp[i][j][hold]: max profit at day i with j transactions left and hold status
8 // hold = 1: currently holding stock, hold = 0: not holding stock
9 int dp[n][k + 1][2];
10 memset(dp, -1, sizeof(dp));
11
12 // Recursive function with memoization
13 // day: current day index
14 // transactionsLeft: remaining transactions allowed
15 // holdingStock: 1 if currently holding stock, 0 otherwise
16 function<int(int, int, int)> dfs = [&](int day, int transactionsLeft, int holdingStock) -> int {
17 // Base case: no more days left
18 if (day >= n) {
19 return 0;
20 }
21
22 // Return memoized result if already calculated
23 if (dp[day][transactionsLeft][holdingStock] != -1) {
24 return dp[day][transactionsLeft][holdingStock];
25 }
26
27 // Option 1: Do nothing today (skip to next day)
28 int maxProfit = dfs(day + 1, transactionsLeft, holdingStock);
29
30 if (holdingStock) {
31 // Currently holding stock: can sell today
32 // Selling completes a transaction (no change in transaction count)
33 maxProfit = max(maxProfit, prices[day] + dfs(day + 1, transactionsLeft, 0));
34 } else if (transactionsLeft > 0) {
35 // Not holding stock and have transactions left: can buy today
36 // Buying starts a new transaction (decrement transaction count)
37 maxProfit = max(maxProfit, -prices[day] + dfs(day + 1, transactionsLeft - 1, 1));
38 }
39
40 // Memoize and return the result
41 return dp[day][transactionsLeft][holdingStock] = maxProfit;
42 };
43
44 // Start from day 0, with k transactions available, not holding any stock
45 return dfs(0, k, 0);
46 }
47};
48
1/**
2 * Calculates the maximum profit from at most k stock transactions
3 * @param k - Maximum number of transactions allowed
4 * @param prices - Array of stock prices on each day
5 * @returns Maximum profit achievable
6 */
7function maxProfit(k: number, prices: number[]): number {
8 const n: number = prices.length;
9
10 // Initialize memoization table
11 // memo[day][transactionsRemaining][holdingStock]
12 // memo[i][j][0] = max profit on day i with j transactions left, not holding stock
13 // memo[i][j][1] = max profit on day i with j transactions left, holding stock
14 const memo: number[][][] = Array.from({ length: n }, () =>
15 Array.from({ length: k + 1 }, () =>
16 Array.from({ length: 2 }, () => -1)
17 )
18 );
19
20 /**
21 * Dynamic programming helper function with memoization
22 * @param day - Current day index
23 * @param transactionsRemaining - Number of transactions still available
24 * @param isHoldingStock - 1 if currently holding stock, 0 otherwise
25 * @returns Maximum profit from current state onwards
26 */
27 const dfs = (day: number, transactionsRemaining: number, isHoldingStock: number): number => {
28 // Base case: no more days to trade
29 if (day >= n) {
30 return 0;
31 }
32
33 // Return memoized result if already computed
34 if (memo[day][transactionsRemaining][isHoldingStock] !== -1) {
35 return memo[day][transactionsRemaining][isHoldingStock];
36 }
37
38 // Option 1: Do nothing on this day
39 let maxProfitFromHere: number = dfs(day + 1, transactionsRemaining, isHoldingStock);
40
41 if (isHoldingStock) {
42 // Option 2: Sell stock today (complete a transaction)
43 maxProfitFromHere = Math.max(
44 maxProfitFromHere,
45 prices[day] + dfs(day + 1, transactionsRemaining, 0)
46 );
47 } else if (transactionsRemaining > 0) {
48 // Option 2: Buy stock today (start a new transaction)
49 maxProfitFromHere = Math.max(
50 maxProfitFromHere,
51 -prices[day] + dfs(day + 1, transactionsRemaining - 1, 1)
52 );
53 }
54
55 // Memoize and return the result
56 memo[day][transactionsRemaining][isHoldingStock] = maxProfitFromHere;
57 return maxProfitFromHere;
58 };
59
60 // Start from day 0, with k transactions available, not holding any stock
61 return dfs(0, k, 0);
62}
63
Time and Space Complexity
The time complexity is O(n × k)
, where n
is the length of the prices array and k
is the maximum number of transactions allowed.
This is because the recursive function dfs
uses memoization with @cache
. The state space is defined by three parameters:
i
: ranges from0
ton-1
(n possible values)j
: ranges from0
tok
(k+1 possible values)k
(the third parameter): can only be0
or1
(2 possible values)
Therefore, the total number of unique states is n × (k+1) × 2 = O(n × k)
. Each state is computed only once due to memoization, and each computation takes O(1)
time (excluding recursive calls).
The space complexity is O(n × k)
as well. This accounts for:
- The memoization cache storing all possible states:
O(n × k)
- The recursion call stack depth, which can go up to
O(n)
in the worst case
Since O(n × k) + O(n) = O(n × k)
when k ≥ 1
, the overall space complexity is O(n × k)
.
Common Pitfalls
1. Transaction Counting Confusion
One of the most common mistakes is misunderstanding when to decrement the transaction count. Many developers incorrectly decrement transactions when selling instead of buying.
Incorrect approach:
if holding_stock:
# Wrong: decrementing when selling
max_profit = max(max_profit,
prices[day_index] + dfs(day_index + 1, transactions_left - 1, 0))
elif transactions_left > 0:
# Wrong: not decrementing when buying
max_profit = max(max_profit,
-prices[day_index] + dfs(day_index + 1, transactions_left, 1))
Why this fails: This approach double-counts transactions since both buying and selling would consume a transaction, effectively allowing only k/2 complete transactions.
Correct approach: Decrement transactions only when initiating a new transaction (buying):
if holding_stock:
# Selling completes the transaction, no decrement needed
max_profit = max(max_profit,
prices[day_index] + dfs(day_index + 1, transactions_left, 0))
elif transactions_left > 0:
# Buying starts a new transaction, decrement here
max_profit = max(max_profit,
-prices[day_index] + dfs(day_index + 1, transactions_left - 1, 1))
2. Memory Optimization for Large k Values
When k is very large (k ≥ n/2 where n is the number of days), the problem essentially becomes unlimited transactions, but the current solution still tracks all k states unnecessarily.
Issue: This wastes memory and computation for states that will never be reached.
Solution: Add an optimization check:
def maxProfit(self, k: int, prices: List[int]) -> int:
if not prices:
return 0
n = len(prices)
# If k >= n/2, we can make as many transactions as we want
if k >= n // 2:
# Simplified unlimited transactions solution
profit = 0
for i in range(1, n):
if prices[i] > prices[i-1]:
profit += prices[i] - prices[i-1]
return profit
# Original DP solution for limited k
@cache
def dfs(day_index, transactions_left, holding_stock):
# ... rest of the implementation
3. Forgetting Edge Cases
Missing empty array check:
def maxProfit(self, k: int, prices: List[int]) -> int:
# Should check for empty prices first
if not prices or k == 0:
return 0
@cache
def dfs(day_index, transactions_left, holding_stock):
# ... implementation
Without this check, the function might fail or return incorrect results for edge cases like empty price arrays or zero allowed transactions.
4. State Parameter Order Confusion
Mixing up the order of parameters in recursive calls is a subtle but critical error:
Incorrect:
# Wrong parameter order in recursive call
max_profit = max(max_profit,
prices[day_index] + dfs(day_index + 1, 0, transactions_left))
# Should be: dfs(day_index + 1, transactions_left, 0)
Solution: Always maintain consistent parameter order: (day_index, transactions_left, holding_stock)
. Using descriptive variable names instead of single letters (i, j, k) helps prevent this mistake.
Consider the classic dynamic programming of fibonacci numbers, what is the recurrence relation?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!