Skip to content

261. Best Time to Buy and Sell Stock with Cooldown

🔗 LeetCode Problem: 309. Best Time to Buy and Sell Stock with Cooldown
📊 Difficulty: Medium
🏷️ Topics: Array, Dynamic Programming, State Machines

📋 Problem Statement

You are given an array prices where prices[i] is the price of a given stock on the i-th day.

Find the maximum profit you can achieve. You may complete as many transactions as you like (i.e., buy one and sell one share of the stock multiple times) with the following restrictions:

  • After you sell your stock, you cannot buy stock on the next day (i.e., cooldown 1 day).

Note: You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).

Examples

Example 1:

Input: prices = [1,2,3,0,2]
Output: 3
Explanation: transactions = [buy, sell, cooldown, buy, sell]
Day 0: Buy at price 1
Day 1: Sell at price 2, profit = 2-1 = 1
Day 2: Cooldown (cannot buy)
Day 3: Buy at price 0
Day 4: Sell at price 2, profit = 2-0 = 2
Total profit = 1 + 2 = 3

Example 2:

Input: prices = [1]
Output: 0
Explanation: Cannot complete any transaction with just one day.

Constraints

  • 1 <= prices.length <= 5000
  • 0 <= prices[i] <= 1000

💡 Implication of constraints: - ✅ At least one day of trading (minimum array size 1) - ✅ Can buy and sell multiple times but with cooldown constraint - ✅ Must sell before buying again (hold at most one share) - ✅ After selling, must wait 1 day before buying again - ✅ Medium input size allows O(n) solution


🧒 Understanding The Problem (By Hand)

Let Me Work Example 1 Step By Step

prices = [1, 2, 3, 0, 2]
          0  1  2  3  4  (day indices)

Rules: 
  - Can buy and sell multiple times
  - Hold at most one stock at a time
  - After selling, cooldown for 1 day (cannot buy next day)

Let me trace the optimal strategy:

Day 0: Buy at price 1 (holding stock)
Day 1: Sell at price 2 (profit = 1, enter cooldown)
Day 2: Cooldown (cannot buy, must wait)
Day 3: Buy at price 0 (holding stock again)
Day 4: Sell at price 2 (profit = 2)

Total profit = 1 + 2 = 3 ✓

Alternative strategies:
Strategy A: Buy day 0 (1), Sell day 2 (3) → profit = 2, cooldown day 3, no more good buys → Total = 2
Strategy B: Buy day 3 (0), Sell day 4 (2) → profit = 2, but missed earlier opportunity → Total = 2

What Did I Learn?

Key Insight 1: Cooldown creates a 3-state system instead of 2-state
Key Insight 2: After selling, we must track the cooldown state
Key Insight 3: From cooldown, we can only go to "can buy" state

State Transitions:
  Can Buy → Buy Stock (go to Holding)
  Can Buy → Skip (stay in Can Buy)
  Holding → Sell Stock (go to Cooldown)  
  Holding → Keep Holding (stay in Holding)
  Cooldown → Must wait (go to Can Buy)

🚫 Approach 1 — Brute Force Recursion

Thought Process

At each day, we have three possible states: - Can Buy (state = 0): We don't currently hold any stock and can buy - Holding (state = 1): We currently hold a stock and can sell - Cooldown (state = 2): We just sold and must wait 1 day

For each state, we have different choices: 1. When we can buy: Skip buying OR buy the stock 2. When we're holding: Skip selling OR sell the stock (enter cooldown) 3. When in cooldown: Must skip (automatically go to can buy next day)

We'll explore all possibilities recursively and take the maximum profit.

Algorithm / Pseudocode

solve(day, state, prices):
    if day == n: return 0  // No more days

    if state == 0:  // Can buy
        skip = solve(day+1, 0, prices)    // Skip buying, stay ready
        buy = -prices[day] + solve(day+1, 1, prices)  // Buy, now holding
        return max(skip, buy)

    elif state == 1:  // Holding stock
        skip = solve(day+1, 1, prices)   // Keep holding
        sell = prices[day] + solve(day+1, 2, prices)   // Sell, enter cooldown
        return max(skip, sell)

    else:  // state == 2, Cooldown
        return solve(day+1, 0, prices)   // Must wait, then can buy

Code

class Solution {
    // Function to find the maximum profit earned using recursion
    private int solve(int day, int state, int n, int[] prices) {
        // Base case: reached end of array
        if (day == n) {
            return 0;
        }

        int profit = 0;

        // State 0: Can buy stock
        if (state == 0) { 
            profit = Math.max(
                solve(day + 1, 0, n, prices),                    // Skip buying
                -prices[day] + solve(day + 1, 1, n, prices)      // Buy stock
            );
        }

        // State 1: Holding stock
        else if (state == 1) { 
            profit = Math.max(
                solve(day + 1, 1, n, prices),                    // Keep holding
                prices[day] + solve(day + 1, 2, n, prices)       // Sell stock (enter cooldown)
            );
        }

        // State 2: Cooldown
        else { 
            profit = solve(day + 1, 0, n, prices);              // Must wait, then can buy
        }

        return profit;
    }

    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;

        // Start at day 0, can buy (state 0)
        return solve(0, 0, n, prices);
    }
}

Dry Run

For prices = [1, 2, 3]:

solve(0, 0) → Can buy at day 0 (price=1)
├─ Skip: solve(1, 0)
│  ├─ Skip: solve(2, 0) 
│  │  ├─ Skip: solve(3, 0) → 0
│  │  └─ Buy: -3 + solve(3, 1) → -3 + 0 = -3
│  │  → max(0, -3) = 0
│  └─ Buy: -2 + solve(2, 1)
│     ├─ Hold: solve(3, 1) → 0  
│     └─ Sell: 3 + solve(3, 2) → 3 + 0 = 3
│     → max(0, 3) = 3
│  → max(0, -2+3) = 1
└─ Buy: -1 + solve(1, 1) 
   ├─ Hold: solve(2, 1) → max(solve(3,1), 3+solve(3,2)) = max(0, 3) = 3
   └─ Sell: 2 + solve(2, 2) → 2 + solve(3, 0) → 2 + 0 = 2
   → max(3, 2) = 3
→ max(1, -1+3) = max(1, 2) = 2

This creates exponential tree with overlapping subproblems...

Time & Space Complexity

⏰ Time: O(3^n) - Each call branches into 2-3 possibilities
💾 Space: O(n) - Recursion depth

This approach has exponential time complexity due to overlapping subproblems.


⚠️ Approach 2 — Memoization (Top-Down DP)

Thought Process (How did you arrive here)

🎯 The Optimization Insight:

Brute Force Problem: Same subproblems solved repeatedly
                    ↓
Key Observation: solve(day, state) returns same value every time
                    ↓
Solution: Store results in dp[day][state] table
         Check table before computing
                    ↓
Trade-off: Use O(n × 3) space to achieve O(n × 3) time

Algorithm / Pseudocode

1. Create dp[n][3] initialized with -1
2. Before computing solve(day, state), check if dp[day][state] != -1
3. If already computed, return dp[day][state]
4. Otherwise, compute, store, and return: dp[day][state] = result

Code

class Solution {
    // Function to find the maximum profit earned using memoization
    private int solve(int day, int state, int n, int[] prices, int[][] dp) {
        // Base case
        if (day == n) {
            return 0;
        }

        // Check if already computed
        if (dp[day][state] != -1) {
            return dp[day][state];
        }

        int profit = 0;

        // State 0: Can buy
        if (state == 0) { 
            profit = Math.max(
                solve(day + 1, 0, n, prices, dp),
                -prices[day] + solve(day + 1, 1, n, prices, dp)
            );
        }

        // State 1: Holding
        else if (state == 1) { 
            profit = Math.max(
                solve(day + 1, 1, n, prices, dp),
                prices[day] + solve(day + 1, 2, n, prices, dp)
            );
        }

        // State 2: Cooldown
        else { 
            profit = solve(day + 1, 0, n, prices, dp);
        }

        // Store and return result
        return dp[day][state] = profit;
    }

    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;

        // Initialize DP table
        int[][] dp = new int[n][3];
        for (int[] row : dp) {
            Arrays.fill(row, -1);
        }

        return solve(0, 0, n, prices, dp);
    }
}

Dry Run

For prices = [1, 2, 3, 0, 2]:

DP Table Construction:
═══════════════════════════════════════════════════════════════════
Day   Price   dp[day][0]   dp[day][1]   dp[day][2]   Meaning
                (canBuy)     (holding)    (cooldown)
═══════════════════════════════════════════════════════════════════
 0      1         3           2           X        Best from day 0
 1      2         3           3           2        Best from day 1  
 2      3         2           2           2        Best from day 2
 3      0         2           2           0        Best from day 3
 4      2         0           0           0        Best from day 4

Answer: dp[0][0] = 3

Time & Space Complexity

⏰ Time: O(n × 3) = O(n) - Each state computed once
💾 Space: O(n × 3) + O(n) = O(n) - DP table + recursion stack

✅ Approach 3 — Tabulation (Bottom-Up DP)

Thought Process (How did you arrive here)

💡 Eliminating Recursion:

Memoization Limitation: Still uses O(n) recursion stack
                       ↓
Key Insight: Fill DP table iteratively from end to start
            Eliminate recursion overhead
                       ↓
Algorithm: Start from base case, work towards answer
           dp[day] depends only on dp[day+1]
                       ↓
Result: Same time complexity, cleaner implementation

Base Case Derivation (Step by Step)

🤔 The Big Question: What happens when we've processed all days? What should our DP table return?

Let's think about this intuitively:

Step 1: Understanding the "No More Days" Scenario

Imagine: prices = [1, 2, 3] and we're at day 3 (beyond last day)

Question: If there are no more days left, what's the maximum profit?
Answer: 0! We can't make any more moves.

Why 0 regardless of our state?
  - If we can buy: No days left to buy and sell → profit = 0
  - If we're holding: No days left to sell → stuck with stock → profit = 0
  - If we're in cooldown: No days left to utilize → profit = 0

Conclusion: dp[n][0] = dp[n][1] = dp[n][2] = 0

Step 2: Base Case in Code

From our recursive logic:
  if (day == n) return 0;  ← BASE CASE!

This means:
  solve(n, 0) = 0  (no more days to trade)
  solve(n, 1) = 0  (no more days to trade)
  solve(n, 2) = 0  (no more days to trade)

In tabulation terms:
  dp[n][0] = dp[n][1] = dp[n][2] = 0

Algorithm / Pseudocode

1. Create dp[n+1][3] (extra row for base case)
2. Initialize base case: dp[n][0] = dp[n][1] = dp[n][2] = 0
3. Iterate from day (n-1) down to 0:
   For each state (0 to 2):
     Apply same transition logic as recursion:
     - State 0: max(skip, buy)
     - State 1: max(hold, sell → cooldown)
     - State 2: must go to state 0
4. Return dp[0][0] (max profit starting from day 0, able to buy)

Code

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;

        // Create DP table with base case
        int[][] dp = new int[n + 1][3];

        // Base condition: no profit at the end
        dp[n][0] = dp[n][1] = dp[n][2] = 0;

        // Fill table from bottom-up
        for (int day = n - 1; day >= 0; day--) {
            // State 0: Can buy
            dp[day][0] = Math.max(
                dp[day + 1][0],                         // Skip buying
                -prices[day] + dp[day + 1][1]           // Buy stock
            );

            // State 1: Holding
            dp[day][1] = Math.max(
                dp[day + 1][1],                         // Keep holding
                prices[day] + dp[day + 1][2]            // Sell stock (enter cooldown)
            );

            // State 2: Cooldown
            dp[day][2] = dp[day + 1][0];                // Must wait, then can buy
        }

        return dp[0][0];
    }
}

Dry Run

Building DP table for prices = [1, 2, 3, 0, 2]:

Bottom-Up Table Construction:
═══════════════════════════════════════════════════════════════════

Step 1: Base case (day 5)
dp[5][0] = dp[5][1] = dp[5][2] = 0

Step 2: Day 4 (price = 2)
State 0: max(dp[5][0], -2 + dp[5][1]) = max(0, -2+0) = 0
State 1: max(dp[5][1], 2 + dp[5][2]) = max(0, 2+0) = 2
State 2: dp[5][0] = 0

Step 3: Day 3 (price = 0)  
State 0: max(dp[4][0], -0 + dp[4][1]) = max(0, 0+2) = 2
State 1: max(dp[4][1], 0 + dp[4][2]) = max(2, 0+0) = 2
State 2: dp[4][0] = 0

Step 4: Day 2 (price = 3)
State 0: max(dp[3][0], -3 + dp[3][1]) = max(2, -3+2) = 2
State 1: max(dp[3][1], 3 + dp[3][2]) = max(2, 3+0) = 3
State 2: dp[3][0] = 2

Step 5: Day 1 (price = 2)
State 0: max(dp[2][0], -2 + dp[2][1]) = max(2, -2+3) = 2
State 1: max(dp[2][1], 2 + dp[2][2]) = max(3, 2+2) = 4
State 2: dp[2][0] = 2

Step 6: Day 0 (price = 1)
State 0: max(dp[1][0], -1 + dp[1][1]) = max(2, -1+4) = 3 ← ANSWER
State 1: max(dp[1][1], 1 + dp[1][2]) = max(4, 1+2) = 4
State 2: dp[1][0] = 2

═══════════════════════════════════════════════════════════════════
Final Answer: dp[0][0] = 3

Time & Space Complexity

⏰ Time: O(n × 3) = O(n)
💾 Space: O(n × 3) = O(n)

🎯 Approach 4 — Space Optimized (Final)

Thought Process (How did you arrive here)

🚀 Ultimate Optimization:

Tabulation Observation: dp[day] only depends on dp[day+1]
                       ↓
Space Insight: Don't need entire n×3 table
               Only need "current" and "next" day values
                       ↓
Optimization: Use 6 variables instead of 2D array
              next_canBuy, next_holding, next_cooldown
              curr_canBuy, curr_holding, curr_cooldown
                       ↓
Result: Same time O(n), space reduced to O(1)

Algorithm / Pseudocode

1. Initialize: next_canBuy=0, next_holding=0, next_cooldown=0 (base case)
2. For day from n-1 down to 0:
   - curr_canBuy = max(next_canBuy, -price + next_holding)
   - curr_holding = max(next_holding, price + next_cooldown)
   - curr_cooldown = next_canBuy
   - Update next values for next iteration
3. Return curr_canBuy

Code

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;

        // Space optimization: only need next day's values
        int next_canBuy = 0, next_holding = 0, next_cooldown = 0;
        int curr_canBuy = 0, curr_holding = 0, curr_cooldown = 0;

        for (int day = n - 1; day >= 0; day--) {
            // Current day: can buy
            curr_canBuy = Math.max(next_canBuy, -prices[day] + next_holding);

            // Current day: holding
            curr_holding = Math.max(next_holding, prices[day] + next_cooldown);

            // Current day: cooldown  
            curr_cooldown = next_canBuy;

            // Update for next iteration
            next_canBuy = curr_canBuy;
            next_holding = curr_holding;
            next_cooldown = curr_cooldown;
        }

        return curr_canBuy;
    }
}

Time & Space Complexity

⏰ Time: O(n)
💾 Space: O(1)

Quick Revision

Quick Implementation

public class Solution {
  public int maxProfit(int[] a) {
    // return recursive(a, 0, 0);

    // Integer[][] memo = new Integer[a.length + 1][3];
    // return topDown(a, 0, 0, memo);

    return bottomUp(a);
  }

  private int bottomUp(int[] a) {
    int n = a.length;
    int[][] dp = new int[n + 1][3];

    for (int i = n - 1; i >= 0; i--) {
      for (int state = 0; state <= 2; state++) {

        int profit = 0;
        if (state == 0) {
          int skipIt = dp[i + 1][0];
          int buyIt = -a[i] + dp[i + 1][1];

          profit = Math.max(skipIt, buyIt);
        } else if (state == 1) {
          int skipIt = dp[i + 1][1];
          int sellIt = a[i] + dp[i + 1][2];

          profit = Math.max(skipIt, sellIt);
        } else {
          profit = dp[i + 1][0];
        }

        dp[i][state] = profit;

      }
    }

    return dp[0][0];
  }

  private int topDown(int[] a, int index, int state, Integer[][] memo) {
    if (index == a.length) {
      return 0;
    }

    if (memo[index][state] != null) {
      return memo[index][state];
    }

    int profit = 0;
    if (state == 0) {
      int skipIt = topDown(a, index + 1, 0, memo);
      int buyIt = -a[index] + topDown(a, index + 1, 1, memo);

      profit = Math.max(skipIt, buyIt);
    } else if (state == 1) {
      int skipIt = topDown(a, index + 1, 1, memo);
      // GOTCHA: state moved to COOLDOWN
      int sellIt = a[index] + topDown(a, index + 1, 2, memo);

      profit = Math.max(skipIt, sellIt);
    } else {
      profit = topDown(a, index + 1, 0, memo);
    }

    return memo[index][state] = profit;
  }

  private int recursive(int[] a, int index, int state) {
    // step 2: base case
    if (index == a.length) {
      return 0;
    }

    int profit = 0;
    // step 1: state = 0 or 1 or 2
    // 0 means we can buy (not mandatory. can skip also)
    // 1 means we can sell (not mandatory. can skip also)
    // 2 means we can do nothing (no buy or sell on the current day)
    if (state == 0) {
      int skipIt = recursive(a, index + 1, 0);
      int buyIt = -a[index] + recursive(a, index + 1, 1);

      profit = Math.max(skipIt, buyIt);
    } else if (state == 1) {
      int skipIt = recursive(a, index + 1, 1);
      // GOTCHA: state moved to COOLDOWN
      int sellIt = a[index] + recursive(a, index + 1, 2);

      profit = Math.max(skipIt, sellIt);
    } else {
      profit = recursive(a, index + 1, 0);
    }

    return profit;
  }

  public static void main(String[] args) {
    Solution s = new Solution();
    System.out.println(s.maxProfit(new int[] { 1, 2, 3, 0, 2 }) == 3);
  }
}

📝 Edge Cases & Testing

Edge Cases

  • Single day: [5] → Return 0 (can't buy and sell for profit)
  • Two days increasing: [1, 5] → Return 4 (buy day 1, sell day 2, no cooldown issue)
  • Two days decreasing: [5, 1] → Return 0 (don't buy at all)
  • Three days with cooldown: [1, 2, 1] → Return 1 (buy day 1, sell day 2, cooldown day 3)
  • All same prices: [3, 3, 3] → Return 0 (no profit opportunity)
  • Monotonic increasing: [1, 2, 3, 4] → Return 3 (buy first, sell last, cooldown doesn't matter)
  • Monotonic decreasing: [4, 3, 2, 1] → Return 0 (never buy)

Test Suite

Basic Tests:

assertEquals(3, maxProfit([1,2,3,0,2]));      // Example 1
assertEquals(0, maxProfit([1]));              // Example 2
assertEquals(2, maxProfit([2,1,4]));          // Simple profitable case

Edge Tests:

assertEquals(0, maxProfit([5]));              // Single element
assertEquals(4, maxProfit([1,5]));            // Two elements up
assertEquals(0, maxProfit([5,1]));            // Two elements down
assertEquals(0, maxProfit([3,3,3,3]));        // All same
assertEquals(1, maxProfit([1,2,1]));          // Cooldown effect test

Cooldown Specific Tests:

assertEquals(3, maxProfit([1,2,1,3]));        // Buy-sell-cooldown-buy-sell
assertEquals(2, maxProfit([2,1,2,1,2]));      // Multiple small transactions
assertEquals(4, maxProfit([1,2,4]));          // Hold through vs sell early

Stress Tests:

// Large input with max profit
int[] largeUp = new int[5000];
for(int i = 0; i < 5000; i++) largeUp[i] = i;
// Expected: can't get full n-1 profit due to cooldowns
assertTrue(maxProfit(largeUp) > 0 && maxProfit(largeUp) < 4999);

// Large input with no profit  
int[] largeDown = new int[5000];
for(int i = 0; i < 5000; i++) largeDown[i] = 5000-i;
assertEquals(0, maxProfit(largeDown));

Key Insights

  1. State modeling: Three states perfectly capture the cooldown constraint
  2. State transitions: Cooldown creates forced transition (cooldown → can buy)
  3. DP progression: Recursion → memoization → tabulation → space optimization
  4. Cooldown impact: Prevents immediate re-buying, requiring strategic timing
  5. Pattern foundation: This 3-state pattern handles time-based constraints elegantly

Comparison with Other Stock Problems

Aspect Stock II (Unlimited) Stock with Cooldown
State Space 2D: [day][canBuy] 2D: [day][state] (3 states)
Complexity O(n) time, O(1) space O(n) time, O(1) space
Key Constraint Hold at most 1 stock Hold at most 1 stock + cooldown
Greedy Solution ✅ Sum positive diffs ❌ Need DP
States Can Buy, Holding Can Buy, Holding, Cooldown

Advanced Patterns

  1. Forced transitions: Cooldown state has no choice (must wait)
  2. Time dependencies: Current action affects future availability
  3. Strategic timing: May hold longer to avoid cooldown penalties
  4. State explosion: Each constraint adds dimensions to state space

The space-optimized solution achieves optimal O(n) time and O(1) space complexity while elegantly handling the cooldown constraint through a 3-state machine!