@laigary.com~/interview/coding/901-online-stock-span.md$
$ cat ./coding/901-online-stock-span.md
[Coding]·2025-04-02·13 min read

901. Online Stock Span

901. Online Stock Span

每次餵進一個當日股價,回答「從今天往回數,連續有幾天的股價小於等於今天」。今天自己也算一天。

這個題目真的算是非常難,沒有寫過的話面試真的很難有機會寫出來。

思路

它是 739. Daily Temperatures 的鏡像 —— 739 往右找第一個更高的,這題往左數連續有幾天不比今天高。但有一個關鍵差異:

這題是 online 的。 資料一次來一個,看不到未來,而且答案必須當場回傳。739 可以選正向或反向掃,這題直接沒得選:只能由左往右,而且只能靠「過去」的資訊。

暴力解與它浪費在哪

最直覺的做法是把所有價格存成一個陣列,每次呼叫往回掃到第一個更高的價格 —— 單次 O(n)n 次呼叫就是 O(n2)

浪費在哪?看這條丟棄規則:

如果今天的價格 >= 昨天的,那昨天從此不必再單獨存在 —— 未來任何一天只要跨得過今天,就一定也跨得過昨天。

所以昨天可以被今天吸收掉。但不能直接丟掉,因為昨天代表的天數還算數 —— 它會被算進未來某一天的 span 裡。

最難的一步:想到要記錄天數

我卡最久的地方不是「該用單調棧」,而是想到棧裡要多存一個天數

看到「往回數連續幾天」很自然就只想去存價格,然後就卡住了 —— 因為 pop 掉之後那幾天就消失了。但被吞掉的日子雖然不必再單獨比較,它們還要被算進未來某一天的 span 裡

真正的關鍵認知是這一句:

pop 掉的東西不能就這樣消失,它帶著資訊。

想通之後,剩下的只是選一個載體來保存那個資訊 —— 把天數累加起來,或是記住位置之後再相減。兩種寫法下面都有。

棧裡存的不是價格,是「壓平的區間」

把被吞掉的天數併進今天,這就是解法的全部:

count = 1                                   # 今天自己
while 棧頂價格 <= 今天:
    count += 棧頂的天數                       # 把它的天數接收過來
    pop
push([今天的價格, count])

棧裡的每一項代表一段已經被壓平的區間price 是那一段的最高價(也就是那段最後一天的價格),count 是那一段涵蓋幾天。由底到頂,價格嚴格遞減。

739 存索引是為了算距離;這題把天數累加起來,連索引都不用維護。

等號的方向跟 739 相反

739 要的是嚴格更高才算答案;這題的 span 定義包含「小於或等於」,所以相等的價格要被吞掉。

while self.prices and self.prices[-1][0] <= price:      # 901:<= 要吞
while stack and temperatures[i] > temperatures[stack[-1]]:  # 739:> 才結算

兩題放在一起看的時候,等號特別容易記反。可靠的做法是每次都回到題目定義去推,而不是背 code。

解題方向

寫法一:累加天數

class StockSpanner:

    def __init__(self):
        self.prices = []

    def next(self, price: int) -> int:
        count = 1
        while self.prices and self.prices[-1][0] <= price:
            prev = self.prices.pop()
            count += prev[1]
        self.prices.append([price, count])

        return count

# Your StockSpanner object will be instantiated and called as such:
# obj = StockSpanner()
# param_1 = obj.next(price)

拿官方的 [100, 80, 60, 70, 60, 75, 85] 跑一次,棧的變化把整個想法說得很清楚:

next(100) → 1    [[100,1]]
next( 80) → 1    [[100,1], [80,1]]
next( 60) → 1    [[100,1], [80,1], [60,1]]
next( 70) → 2    [[100,1], [80,1], [70,2]]        吞掉 60
next( 60) → 1    [[100,1], [80,1], [70,2], [60,1]]
next( 75) → 4    [[100,1], [80,1], [75,4]]        吞掉 60 和 70,天數 1+1+2 = 4
next( 85) → 6    [[100,1], [85,6]]                吞掉 75 和 80,天數 1+4+1 = 6

next(75) 那一步:它一口氣吃掉 [60,1][70,2]count 從 1 累加成 4。那個 [70,2] 裡的 2 就是它當初從 60 接收過來的,所以歷史天數是層層傳遞下去的,不會漏也不會重複算。

最後 [100,1] 從頭到尾沒被動過 —— 100 是整段期間的最高價,沒有人跨得過它。

寫法二:記錄索引

另一個載體是位置。自己維護一個呼叫次數 i,棧裡放 (價格, 索引);彈完之後棧頂就是「左邊第一個比今天貴的那天」,兩個索引相減就是 span。

class StockSpanner:

    def __init__(self):
        self.stack = []          # (price, index)
        self.i = -1

    def next(self, price: int) -> int:
        self.i += 1
        while self.stack and self.stack[-1][0] <= price:
            self.stack.pop()

        if not self.stack:
            # 左邊沒有人比今天貴,從第 0 天到今天全部算進來
            span = self.i + 1
        else:
            # 棧頂是左邊第一個比今天貴的那天
            # 它和今天之間夾著的日子,價格全都不超過今天
            span = self.i - self.stack[-1][1]

        self.stack.append((price, self.i))
        return span

同一組輸入的棧變化:

next(100) → 1    [(100,0)]
next( 80) → 1    [(100,0), (80,1)]
next( 60) → 1    [(100,0), (80,1), (60,2)]
next( 70) → 2    [(100,0), (80,1), (70,3)]           i=3, 棧頂 80 在 1 → 3-1 = 2
next( 60) → 1    [(100,0), (80,1), (70,3), (60,4)]
next( 75) → 4    [(100,0), (80,1), (75,5)]           i=5, 棧頂 80 在 1 → 5-1 = 4
next( 85) → 6    [(100,0), (85,6)]                   i=6, 棧頂 100 在 0 → 6-0 = 6

注意這裡的 pop 一樣什麼都不用做。 天數不是被累加保存的,而是留在沒被彈掉的那個人身上 —— 棧頂的索引本來就標記了「上一個比今天貴的位置」,中間被吞掉幾天,相減自然就算出來了。

要選哪一種?

累加天數記錄索引
額外要維護的一個計數器 i
天數怎麼來主動累加,資訊在被彈的人身上被動相減,資訊在留下的人身上
739 的相似度較低高,兩題可以共用同一套直覺

累加版比較精簡;索引版的好處是它跟整個單調棧家族的寫法一致,不用為這題記一個特例。

補充

跟 739 的對照

739901
方向往右找第一個更高往左數連續不更高
資料一次給完整個陣列online,一次一個
棧裡存索引(要算距離)[價格, 天數](價格, 索引)
等號嚴格 > 才結算<= 就吞掉

同一個模板的其他形狀單調棧模板496. Next Greater Element I84. Largest Rectangle in Histogram42. Trapping Rain Water

複雜度

  • 時間 —— 單次 next 最壞是 O(n)(一路吞光整個棧),但均攤是 O(1):每個價格一輩子只會被 push 一次、pop 一次,所以 n 次呼叫的總成本是 O(n)
  • 空間 O(n) — 價格一路下跌時(例如 [50, 40, 30]),沒有人被吞掉,棧會留下每一天

其中 nnext 被呼叫的次數。

這題是均攤分析的好例子:看單次呼叫會覺得它是 O(n),但那個 O(n) 的代價是「之前 push 過 n 次」換來的,不可能連續發生。