DSA Best Time to Buy and Sell Stock
Best Time to Buy and Sell Stock picks one buy day and one later sell day for the largest profit. One pass tracks the lowest price so far and tests each day as a sell day against it.
The problem
Best Time to Buy and Sell Stock picks one buy day and one later sell day. The profit is the sell price minus the buy price. The task is the largest profit from a single pair of days.
Buying is never forced. A list of prices that only falls answers zero. At most one buy and one sell are allowed.
The cases used in this article
cases = [
([7, 1, 5, 3, 6, 4], 5),
([7, 6, 4, 3, 1], 0),
([9, 4, 7, 3, 8, 5, 6], 5),
([1, 2], 1),
([2, 4, 1], 2),
]
for prices, expected in cases:
print(prices, 'best profit:', expected)Intuition
The first idea pairs the cheapest price with the highest price. Subtract the minimum from the maximum. That pair ignores order, and order is the whole problem.
The highest price can sit before the cheapest price. Selling before buying is impossible. The trap below makes that pair look better than any legal pair.
The cheapest-minus-highest trap
prices = [9, 2, 8, 1]
print('highest price:', max(prices), 'on day', prices.index(max(prices)) + 1)
print('lowest price:', min(prices), 'on day', prices.index(min(prices)) + 1)
print('max minus min:', max(prices) - min(prices))
print('that sell day comes before that buy day, so the pair is illegal')Brute force
The brute force tries every legal pair. The buy day comes first. Every later day is a possible sell day.
Trying every pair of days
def best_brute(prices):
best = 0
pairs = 0
for buy in range(len(prices)):
for sell in range(buy + 1, len(prices)):
pairs += 1
profit = prices[sell] - prices[buy]
if profit > best:
best = profit
return best, pairs
for prices in [[7, 1, 5, 3, 6, 4], [7, 6, 4, 3, 1], [2, 4, 1]]:
best, pairs = best_brute(prices)
print(prices, 'best:', best, 'pairs tried:', pairs)The answer is correct. The pair count is the problem. Doubling the days roughly quadruples the pairs.
The optimal solution: track the lowest price so far
The key idea is one pass with a running low. For each price, the best sell on that day pairs it with the lowest price seen before it.
The one-pass solution
def max_profit(prices):
if not prices:
return 0
lowest = prices[0]
best = 0
for price in prices:
if price < lowest:
lowest = price
if price - lowest > best:
best = price - lowest
return best
cases = [[7, 1, 5, 3, 6, 4], [7, 6, 4, 3, 1], [9, 4, 7, 3, 8, 5, 6], [1, 2], [2, 4, 1]]
for prices in cases:
print(prices, 'best profit:', max_profit(prices))Walkthrough
Take the prices [7, 1, 5, 3, 6]. Each day updates the low first. The same day then tests a sale against that low.
The low and the best profit after each day
prices = [7, 1, 5, 3, 6]
lowest = prices[0]
best = 0
for day in range(len(prices)):
price = prices[day]
if price < lowest:
lowest = price
if price - lowest > best:
best = price - lowest
print('day', day + 1, 'price', price, 'lowest', lowest, 'best', best)Day 2 drops the low to 1. Day 3 tests 5 - 1 and sets the best profit to 4. Day 5 tests 6 - 1 and raises the best profit to 5.
Complexity
Time is O(n). Each price is visited once, with one low update and one profit test.
Space is O(1). The pass stores only the lowest price and the best profit.
Counting work: pairs against one pass
def pair_count(n):
return n * (n - 1) // 2
for n in [10, 20, 40]:
print('days', n, 'brute pairs:', pair_count(n), 'one-pass visits:', n)Edge cases and pitfalls
A single price allows no pair. The answer is zero, because a sell day after the buy day does not exist. An empty list also answers zero in the guarded version.
Updating the best profit before the low is a subtle slip on the first day only. The guarded order in this article updates the low first. Both orders agree after the start, since a price minus itself is zero.
Edge cases, run
def max_profit(prices):
if not prices:
return 0
lowest = prices[0]
best = 0
for price in prices:
if price < lowest:
lowest = price
if price - lowest > best:
best = price - lowest
return best
print('empty:', max_profit([]))
print('single:', max_profit([5]))
print('only falls:', max_profit([5, 4, 3, 2]))
print('flat:', max_profit([3, 3, 3]))
print('rises at once:', max_profit([1, 5, 2]))Exercise
For prices [5, 4, 3, 2], what is the best profit?