นากกายสีมิ้นต์ต้นฉบับยืนบนขั้นบันไดราคาที่สูงขึ้นและชี้เส้นทางสีอำพันจากต่ำไปสูง

LeetCode #121 Best Time to Buy and Sell Stock: จำราคาต่ำสุดครั้งเดียว

น้องกายEngineering

โจทย์ best-time-to-buy-and-sell-stock ให้ราคาหุ้นรายวัน เราซื้อได้หนึ่งครั้งแล้วขายภายหลังหนึ่งครั้ง ต้องหากำไรสูงสุด ถ้าไม่มีทางกำไรให้ตอบศูนย์ คำว่า “ขายภายหลัง” สำคัญ: เราห้ามเลือกจุดสูงก่อนจุดต่ำ

วิธีที่ 1: ลองทุกคู่

เลือกวันซื้อ i แล้วลองวันขาย j > i ทุกวัน เก็บส่วนต่างสูงสุด เวลา O(n²), พื้นที่ O(1) ข้อดีคืออธิบายตรงโจทย์และใช้เป็น oracle ทดสอบวิธีเร็วบน input เล็ก ข้อเสียคือช้าเมื่อข้อมูลยาว

วิธีที่ 2: suffix maximum

สร้างอาร์เรย์ที่แต่ละตำแหน่งเก็บราคาสูงสุดทางขวา แล้วคำนวณกำไรสำหรับทุกวันซื้อ เวลา O(n), พื้นที่ O(n) เหมาะเมื่อ suffix maximum ถูกใช้กับคำถามอื่นด้วย แต่ถ้าต้องการแค่กำไรตัวเดียว พื้นที่เกินจำเป็น

วิธีที่ 3: one-pass minimum (แนะนำหลัก)

เดินซ้ายไปขวา เก็บราคาต่ำสุดที่เคยเห็นและกำไรสูงสุดจากการขายวันนี้ เวลา O(n), พื้นที่ O(1) ลำดับอัปเดตควรคำนวณกำไรก่อนหรือหลัง minimum ก็ได้เมื่อยอมซื้อขายวันเดียวกำไรศูนย์ แต่ต้องรักษาความหมายว่าราคาซื้อมาจากอดีตหรือปัจจุบัน

วิธีที่ 4: state machine / DP

เก็บ state “ถือหุ้น” และ “ไม่ถือหุ้นหลังขาย” ทำได้ O(n)/O(1) เช่นกัน มีประโยชน์เมื่อจะต่อยอดไปหลายธุรกรรม ค่าธรรมเนียม หรือ cooldown แต่สำหรับข้อ 121 มักซับซ้อนเกินโจทย์

import unittest


def max_profit(prices):
    lowest = float("inf")
    best = 0
    for price in prices:
        best = max(best, price - lowest)
        lowest = min(lowest, price)
    return best


def max_profit_bruteforce(prices):
    return max(
        0,
        max(
            (prices[j] - prices[i] for i in range(len(prices))
             for j in range(i + 1, len(prices))),
            default=0,
        ),
    )


class StockTests(unittest.TestCase):
    def test_examples_and_edges(self):
        cases = [
            ([7, 1, 5, 3, 6, 4], 5),
            ([7, 6, 4, 3, 1], 0),
            ([2], 0),
            ([2, 2, 2], 0),
        ]
        for prices, expected in cases:
            with self.subTest(prices=prices):
                self.assertEqual(max_profit(prices), expected)
                self.assertEqual(max_profit(prices), max_profit_bruteforce(prices))


if __name__ == "__main__":
    unittest.main()

เลือกวิธีไหนดี

สถานการณ์วิธีเหตุผล
เรียนอัลกอริทึมครั้งแรกทุกคู่เห็นข้อจำกัดลำดับวันชัด
สัมภาษณ์ข้อ 121one-pass minimumO(n), O(1), พิสูจน์ง่าย
ต้องใช้ข้อมูลอนาคตหลายคำถามsuffix maximumreuse อาร์เรย์ได้
กำลังต่อยอดกติกาซื้อขายstate machineเพิ่ม state ได้เป็นระบบ

เคสที่ต้องพูดให้ครบคือราคาลดตลอด, ราคาเท่ากัน, อาร์เรย์สั้น และค่าต่ำสุดเกิดหลังจุดสูงสุด วิธี one-pass ถูกเพราะประเมินกำไรตามลำดับเวลาเสมอ

Recap

คำตอบ default คือเก็บ minimum-so-far และ best-profit ในรอบเดียว อธิบาย invariant ว่า minimum มาจาก prefix ที่เห็นแล้ว และใช้ brute force เป็น test oracle บนข้อมูลสุ่มขนาดเล็กได้ครับ