單調堆疊資料結構

雜湊、堆疊與佇列優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值 更新於 Oct 9, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — next greater/previous smaller/span/直方圖這類題目 — 堆疊本身保持有序,所以每個元素只被推入與彈出一次。 另見:stack.md — 單純的 LIFO 題目;monotonic_queue.md — 滑動視窗版的對應物;heap.md — 當你要的是全域極值而不是鄰近極值時;greedy.md — §2-10 裡那些「只推不彈」的偽堆疊,真正的歸屬在這裡。

LeetCode 題目清單

總覽

單調堆疊是一種特化的堆疊,內部元素永遠維持單調(嚴格遞增或嚴格遞減)的順序。它能有效率地解決 next greater/smaller 元素、直方圖面積,以及序列最佳化這類問題。

關鍵性質

  • 時間複雜度:大多數操作是 O(n)(每個元素只推入/彈出一次)
  • 空間複雜度:O(n),用來存堆疊
  • 核心想法:依序處理元素的同時,維持堆疊的單調性
  • 什麼時候用:找 next/previous greater/smaller 元素、直方圖題、序列最佳化

參考資料

題型分類

模式 1:Next/Previous Greater Element — LC 739

  • 描述:找出比目前元素大的下一個或前一個元素
  • 範例:LC 496(Next Greater Element I)、LC 503(Next Greater Element II)、LC 739(Daily Temperatures)
  • 模式:用遞減單調堆疊,遇到更大的元素就彈出

模式 2:Next/Previous Smaller Element — LC 84

  • 描述:找出比目前元素小的下一個或前一個元素
  • 範例:LC 84(Largest Rectangle)、LC 42(Trapping Rain Water)、LC 907(Sum of Subarray Minimums)、LC 4054(Count Shadow Pairs I — next strictly smaller)
  • 模式:用遞增單調堆疊,遇到更小的元素就彈出

模式 3:直方圖與面積問題 — LC 84

  • 描述:用高度資訊算面積、矩形或體積
  • 範例:LC 84(Largest Rectangle in Histogram)、LC 42(Trapping Rain Water)、LC 85(Maximal Rectangle)
  • 模式:用單調堆疊找出邊界,再算邊界之間的面積

模式 4:序列順序與驗證 — LC 456

  • 描述:驗證序列、找出特定樣式,或維持順序限制
  • 範例:LC 456(132 Pattern)、LC 901(Online Stock Span)、LC 1856(Maximum Subarray Min-Product)
  • 模式:用堆疊維持序列性質並驗證樣式

模式 5:最佳化與最大/最小 — LC 1793

  • 描述:在最大或最小限制下找最佳解
  • 範例:LC 1944(Number of Visible People)、LC 2104(Sum of Subarray Ranges)、LC 1793(Maximum Score)
  • 模式:用單調性質維持最佳候選

模式 6:環狀陣列 — LC 503

  • 描述:處理環狀或循環的陣列問題
  • 範例:LC 503(Next Greater Element II)、LC 457(Circular Array Loop)
  • 模式:把陣列走兩遍,或用模運算搭配單調堆疊

模板與演算法

模板比較表

模板類型 使用情境 堆疊順序 什麼時候用
遞減堆疊 Next/Previous Greater 遞減 找比目前元素大的元素
遞增堆疊 Next/Previous Smaller 遞增 找比目前元素小的元素
直方圖面積 矩形/面積問題 遞增 用高度算面積
環狀陣列 循環問題 視情況 處理環狀序列
樣式驗證 序列驗證 視情況 驗證特定樣式
最佳化堆疊 最大/最小問題 視情況 維持最佳候選

通用模板

python
def monotonic_stack_template(arr):
    """
    Universal template for monotonic stack problems
    Modify the condition and processing logic based on problem requirements
    """
    stack = []  # Store indices or values
    result = []
    
    for i, val in enumerate(arr):
        # Pop elements that violate monotonic property
        while stack and should_pop(stack, val, i):
            # Process the popped element
            popped = stack.pop()
            process_popped_element(popped, i, result)
        
        # Add current element to stack
        stack.append(i)  # or val depending on problem
    
    # Process remaining elements in stack
    while stack:
        popped = stack.pop()
        process_remaining_element(popped, result)
    
    return result

def should_pop(stack, current_val, current_idx):
    """Define when to pop based on problem requirements"""
    # For next greater: return arr[stack[-1]] <= current_val
    # For next smaller: return arr[stack[-1]] >= current_val
    pass

def process_popped_element(popped_idx, current_idx, result):
    """Process element when it's popped (found its next greater/smaller)"""
    pass

def process_remaining_element(popped_idx, result):
    """Process elements remaining in stack at the end"""
    pass
java
// Java Universal Template
public int[] monotonicStackTemplate(int[] arr) {
    Stack<Integer> stack = new Stack<>();
    int[] result = new int[arr.length];
    
    for (int i = 0; i < arr.length; i++) {
        // Pop elements that violate monotonic property
        while (!stack.isEmpty() && shouldPop(stack, arr, i)) {
            int poppedIdx = stack.pop();
            processElement(poppedIdx, i, result, arr);
        }
        
        // Add current element to stack
        stack.push(i);
    }
    
    // Process remaining elements
    while (!stack.isEmpty()) {
        int poppedIdx = stack.pop();
        processRemainingElement(poppedIdx, result);
    }
    
    return result;
}

private boolean shouldPop(Stack<Integer> stack, int[] arr, int currentIdx) {
    // Define condition based on problem requirements
    return arr[stack.peek()] <= arr[currentIdx]; // For next greater
}

模板 1:Next Greater Element(遞減堆疊) — LC 496

python
def next_greater_element(nums):
    """
    Find next greater element for each element
    LC 496, LC 503, LC 739
    """
    n = len(nums)
    result = [-1] * n
    stack = []  # Store indices
    
    for i in range(n):
        # Pop smaller or equal elements
        while stack and nums[stack[-1]] < nums[i]:
            idx = stack.pop()
            result[idx] = nums[i]  # Found next greater
        
        stack.append(i)
    
    return result
java
// Java Template 1
public int[] nextGreaterElement(int[] nums) {
    int n = nums.length;
    int[] result = new int[n];
    Arrays.fill(result, -1);
    Stack<Integer> stack = new Stack<>();
    
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
            result[stack.pop()] = nums[i];
        }
        stack.push(i);
    }
    
    return result;
}

模板 2:Next Smaller Element(遞增堆疊) — LC 84

彈出條件寫 > 的話,這個模板找到的是下一個更小或相等的元素。當題目說的是嚴格更小, 彈出條件就要改成 >= — 見 §2-19。

python
def next_smaller_element(nums):
    """
    Find next smaller element for each element
    Used in LC 84, LC 42
    """
    n = len(nums)
    result = [-1] * n
    stack = []  # Store indices
    
    for i in range(n):
        # Pop greater or equal elements
        while stack and nums[stack[-1]] > nums[i]:
            idx = stack.pop()
            result[idx] = nums[i]  # Found next smaller
        
        stack.append(i)
    
    return result

模板 3:Largest Rectangle in Histogram — LC 84

python
def largest_rectangle_area(heights):
    """
    Find largest rectangle area in histogram
    LC 84, LC 85
    """
    stack = []  # Store indices
    max_area = 0
    
    # Sentinel 0 flushes every bar left on the stack; `heights + [0]` rather than
    # heights.append(0), so the caller's list is not mutated
    for i, h in enumerate(heights + [0]):
        # Pop taller bars and calculate area
        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]
            width = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, height * width)
        
        stack.append(i)
    
    return max_area
java
// Java Template 3
public int largestRectangleArea(int[] heights) {
    Stack<Integer> stack = new Stack<>();
    int maxArea = 0;
    int n = heights.length;
    
    for (int i = 0; i <= n; i++) {
        int h = (i == n) ? 0 : heights[i];
        
        while (!stack.isEmpty() && heights[stack.peek()] > h) {
            int height = heights[stack.pop()];
            int width = stack.isEmpty() ? i : i - stack.peek() - 1;
            maxArea = Math.max(maxArea, height * width);
        }
        
        stack.push(i);
    }
    
    return maxArea;
}

模板 4:環狀陣列處理 — LC 503

python
def next_greater_circular(nums):
    """
    Find next greater element in circular array
    LC 503
    """
    n = len(nums)
    result = [-1] * n
    stack = []
    
    # Process array twice to handle circular nature
    for i in range(2 * n):
        # Pop smaller elements
        while stack and nums[stack[-1]] < nums[i % n]:
            idx = stack.pop()
            result[idx] = nums[i % n]
        
        # Only add indices from first pass
        if i < n:
            stack.append(i)
    
    return result

模板 5:堆疊中帶額外資訊

python
def monotonic_stack_with_info(nums):
    """
    Store additional information with stack elements
    Used for complex calculations
    """
    stack = []  # Store (index, value, additional_info)
    result = []
    
    for i, val in enumerate(nums):
        while stack and stack[-1][1] <= val:
            idx, old_val, info = stack.pop()
            # Process with additional information
            result.append(calculate_result(idx, i, old_val, val, info))
        
        # Calculate additional information for current element
        additional_info = calculate_info(val, stack)
        stack.append((i, val, additional_info))
    
    return result

模板 6:樣式驗證(132 Pattern) — LC 456

python
def find_132_pattern(nums):
    """
    Find 132 pattern in array
    LC 456
    """
    n = len(nums)
    if n < 3:
        return False
    
    stack = []  # Store potential k values (decreasing)
    second = float('-inf')  # The "2" in 132 pattern
    
    # Traverse from right to left
    for i in range(n - 1, -1, -1):
        if nums[i] < second:  # Found "1" < "2"
            return True
        
        # Pop smaller values and update second
        while stack and stack[-1] < nums[i]:
            second = stack.pop()
        
        stack.append(nums[i])
    
    return False

依模式分類的題目

以模式分類的題目清單

模式 1:Next/Previous Greater Element 題目

題目 LC # 關鍵技巧 難度 模板
Next Greater Element I 496 遞減堆疊 Easy 模板 1
Next Greater Element II 503 環狀陣列 Medium 模板 4
Daily Temperatures 739 計算距離 Medium 模板 1
Remove K Digits 402 貪婪 + 堆疊 Medium 模板 1
Remove Duplicate Letters 316 字典序 + 堆疊 Medium 模板 1
Sliding Window Maximum 239 單調雙端佇列 Hard 模板 1
Shortest Unsorted Array 581 兩趟堆疊 Medium 模板 1
Sum of Subarray Ranges 2104 next greater + smaller Medium 模板 1+2

模式 2:Next/Previous Smaller Element 題目

題目 LC # 關鍵技巧 難度 模板
Largest Rectangle in Histogram 84 計算面積 Hard 模板 3
Maximal Rectangle 85 二維直方圖 Hard 模板 3
Sum of Subarray Minimums 907 貢獻法 Medium 模板 2
Number of Valid Subarrays 1063 數較小元素 Medium 模板 2
Minimum Cost Tree From Leaf Values 1130 最佳合併 Medium 模板 2
Find the Most Competitive Subsequence 1673 選子序列 Medium 模板 2
Maximum Subarray Min-Product 1856 以最小值為樞紐 Medium 模板 2
Count Shadow Pairs I 4054 Next strictly smaller 的窗口,再扣掉相等值的串接 Medium 模板 2 的變形

模式 3:直方圖與面積題目

題目 LC # 關鍵技巧 難度 模板
Trapping Rain Water 42 計算水位 Hard 模板 2
Container With Most Water 11 也可用雙指標 Medium 模板 2
Maximal Rectangle 85 逐列直方圖 Hard 模板 3
Maximum Rectangle 221 DP + 直方圖 Medium 模板 3
Minimum Number of Taps 1326 區間覆蓋 Hard 模板 2
Constrained Subsequence Sum 1425 DP + 單調雙端佇列 Hard 模板 2

模式 4:序列順序與驗證題目

題目 LC # 關鍵技巧 難度 模板
132 Pattern 456 樣式偵測 Medium 模板 6
Online Stock Span 901 單調堆疊 Medium 模板 1
Score of Parentheses 856 巢狀結構 Medium 模板 5
Valid Parenthesis String 678 平衡驗證 Medium 模板 5
Minimum Add to Make Parentheses Valid 921 平衡計數 Medium 模板 5
Validate Stack Sequences 946 模擬序列 Medium 模板 5
Maximum Nesting Depth of Parentheses 1614 追蹤深度 Easy 模板 5
Minimum Remove to Make Valid Parentheses 1249 平衡 + 移除 Medium 模板 5

模式 5:最佳化與最大/最小題目

題目 LC # 關鍵技巧 難度 模板
Maximum Score of Good Subarray 1793 雙指標 + 堆疊 Hard 模板 2
Number of Visible People in Queue 1944 視線問題 Medium 模板 1
Car Fleet 853 抵達時間的滾動最大值 — 那個堆疊從來不彈出 Medium §2-10
Count Robot Groups 4045 同一套貪婪,但沒有終點 — 改比速度 Medium §2-10
Car Fleet II 1776 每台車各自的碰撞時間 — 真正需要彈出的堆疊 Hard §2-10
Buildings With Ocean View 1762 由右往左掃 Medium 模板 1
Find the Winner of Circular Game 1823 約瑟夫問題 Medium 模板 4
Maximum Width Ramp 962 索引差 Medium 模板 1
Steps to Make Array Non-decreasing 2289 堆疊扛著一個 dp 值 Medium 模板 11
Pancake Sorting 969 反轉操作 Medium 模板 1

模式 6:環狀陣列題目

題目 LC # 關鍵技巧 難度 模板
Next Greater Element II 503 走兩遍陣列 Medium 模板 4
Circular Array Loop 457 環偵測 Medium 模板 4
Design Circular Queue 622 環狀緩衝區 Medium 模板 4
Design Circular Deque 641 雙端環狀 Medium 模板 4

進階/混合模式題目

題目 LC # 關鍵技巧 難度 模板
Sum of Total Strength of Wizards 2281 多個堆疊 Hard 多個
Number of Ways to Rearrange Sticks 1866 組合數學 + 堆疊 Hard 模板 5
Basic Calculator 224 運算式求值 Hard 模板 5
Basic Calculator II 227 運算子優先序 Medium 模板 5
Basic Calculator III 772 完整運算式解析 Hard 模板 5
Evaluate Reverse Polish Notation 150 後序求值 Medium 模板 5
Decode String 394 巢狀解碼 Medium 模板 5
Find Duplicate Subtrees 652 樹的序列化 Medium 模板 5
Exclusive Time of Functions 636 模擬呼叫堆疊(不是單調堆疊) Medium stack.md 模板 6
Minimum Window Subsequence 727 雙指標 + 堆疊 Hard 模板 5

題目難度分布

  • Easy(8 題):基本的 next greater/smaller、簡單驗證
  • Medium(28 題):最常見的難度,涵蓋各種模式
  • Hard(16 題):複雜的面積計算、進階最佳化

模板使用頻率

  • 模板 1(Next Greater):15 題
  • 模板 2(Next Smaller):12 題
  • 模板 3(直方圖):8 題
  • 模板 4(環狀):6 題
  • 模板 5(驗證/複雜):11 題
  • 多個模板混用:8 題

模式選擇策略

決策流程圖

text
Problem Analysis for Monotonic Stack:

1. Does the problem involve finding next/previous elements?
   ├── YES: Next/Previous GREATER elements?
   │   ├── YES: Use Template 1 (Decreasing Stack)
   │   │   ├── Array is circular? → Use Template 4 (Circular)
   │   │   └── Standard case → Template 1
   │   └── NO: Next/Previous SMALLER elements?
   │       ├── YES: Use Template 2 (Increasing Stack)
   │       └── NO: Continue to step 2
   └── NO: Continue to step 2

2. Does the problem involve heights/areas/rectangles?
   ├── YES: Rectangle area calculation?
   │   ├── YES: Use Template 3 (Histogram)
   │   └── NO: Water trapping/volume?
   │       └── YES: Use Template 2 (Next Smaller)
   └── NO: Continue to step 3

3. Does the problem involve sequence validation/patterns?
   ├── YES: Parentheses/brackets?
   │   ├── YES: Use Template 5 (Validation)
   │   └── NO: Specific pattern (like 132)?
   │       └── YES: Use Template 6 (Pattern Detection)
   └── NO: Continue to step 4

4. Does the problem involve optimization/max-min constraints?
   ├── YES: Multiple criteria optimization?
   │   ├── YES: Use Template 5 (Complex Info)
   │   └── NO: Simple max/min tracking?
   │       └── YES: Use Template 1 or 2
   └── NO: Continue to step 5

5. Does the problem involve circular arrays or cyclic behavior?
   ├── YES: Use Template 4 (Circular Processing)
   └── NO: Consider if monotonic stack is the right approach
       └── May need different data structure/algorithm

逐步分析題目

  1. 先看清核心需求

    • 查詢 next/previous 元素 → 模板 1、2、4
    • 計算面積/矩形 → 模板 3
    • 樣式驗證 → 模板 5、6
    • 最佳化問題 → 模板 1、2、5
  2. 決定堆疊順序

    • 要找較大的元素 → 遞減堆疊(彈出較小的)
    • 要找較小的元素 → 遞增堆疊(彈出較大的)
    • 面積計算 → 通常是遞增堆疊
    • 樣式偵測 → 視樣式而定
  3. 決定處理方向

    • 由左往右:最常見,也最自然
    • 由右往左:找「next」元素時有時比較好寫
    • 環狀:把陣列處理多遍
  4. 決定堆疊裡放什麼

    • 索引:需要位置資訊時
    • 值:只需要比大小時
    • Tuple:需要額外資訊時

模板選擇速查

題型 模板 堆疊內容 處理順序
Next Greater 模板 1 索引 由左往右
Next Smaller 模板 2 索引 由左往右
Previous Greater 模板 1 索引 由左往右
Previous Smaller 模板 2 索引 由左往右
直方圖面積 模板 3 索引 由左往右
環狀陣列 模板 4 索引 走兩遍
樣式偵測 模板 6 值 由右往左
複雜驗證 模板 5 Tuple 視情況

總結與速查

複雜度速查

操作 時間 空間 備註
推入堆疊 O(1) - 每個元素只推入一次
彈出堆疊 O(1) - 每個元素只彈出一次
整體演算法 O(n) O(n) 攤還線性時間
Next Greater/Smaller O(n) O(n) 單趟掃過陣列
直方圖面積 O(n) O(n) 帶堆疊的線性掃描
環狀陣列 O(n) O(n) 走兩趟,複雜度不變

模板速查

模板 模式 關鍵程式碼
模板 1 Next Greater while stack and nums[stack[-1]] < nums[i]
模板 2 Next Smaller while stack and nums[stack[-1]] > nums[i]
模板 3 直方圖 while stack and heights[stack[-1]] > h
模板 4 環狀 for i in range(2 * n)
模板 5 驗證 在堆疊中存額外資訊
模板 6 樣式偵測 由右往左,同時追蹤條件

常見模式與技巧

Next Greater Element 模式

python
# Standard next greater element
def next_greater_elements(nums):
    stack, result = [], [-1] * len(nums)
    for i, num in enumerate(nums):
        while stack and nums[stack[-1]] < num:
            result[stack.pop()] = num
        stack.append(i)
    return result

子陣列的貢獻法

python
# Count contribution of each element
def sum_subarray_mins(arr):
    n = len(arr)
    left = [-1] * n    # Previous smaller element
    right = [n] * n    # Next smaller element
    
    # Calculate left boundaries
    stack = []
    for i in range(n):
        while stack and arr[stack[-1]] >= arr[i]:
            stack.pop()
        left[i] = stack[-1] if stack else -1
        stack.append(i)
    
    # Calculate contribution
    result = 0
    for i in range(n):
        result += arr[i] * (i - left[i]) * (right[i] - i)
    return result % (10**9 + 7)

直方圖面積計算

python
# Largest rectangle with height as key
def largest_rectangle_area(heights):
    stack = []
    max_area = 0
    for i, h in enumerate(heights + [0]):
        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]
            width = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, height * width)
        stack.append(i)
    return max_area

解題步驟

  1. 步驟 1:判斷模式類型

    • 找關鍵字:next/previous、greater/smaller、面積、矩形
    • 確認有沒有環狀/循環的需求
    • 判斷是否需要驗證或樣式偵測
  2. 步驟 2:挑對模板

    • 用決策流程圖
    • 想清楚堆疊順序(遞增還是遞減)
    • 決定堆疊裡要存什麼資訊
  3. 步驟 3:實作核心邏輯

    • 準備好堆疊與結果容器
    • 寫出 while 迴圈,彈出條件要對
    • 妥善處理彈出的元素
    • 處理最後留在堆疊裡的元素
  4. 步驟 4:處理邊界情況

    • 空陣列
    • 只有一個元素
    • 所有元素都相同
    • 嚴格遞增/遞減的序列
  5. 步驟 5:最佳化與驗證

    • 確認時間複雜度是 O(n)
    • 檢查空間複雜度
    • 用範例輸入驗證
    • 必要時處理整數溢位

常見錯誤與提醒

常見錯誤

  • 堆疊順序搞反:next greater 題卻用遞增堆疊(應該用遞減)
  • 索引與值混淆:需要算距離時卻只存了值
  • 處理不完整:忘了處理最後留在堆疊裡的元素
  • 邊界問題:沒有妥善處理堆疊為空的情況
  • 環狀邏輯:環狀陣列處理不正確(漏了第二趟)
  • 條件寫錯:比較運算子用錯(< vs <=、> vs >=)

最佳實務

  • 需要位置資訊時,一律存索引
  • 用哨兵值(例如 0)簡化邊界處理
  • 除非真的需要由右往左,否則由左往右處理
  • 變數名要清楚:用 stack、result、current_idx,不要用 s、res、i
  • 在 while 條件加註解,說明維持的單調性質
  • 先處理邊界情況,再寫主要演算法

面試技巧

  1. 辨識模式

    • 聽到「next greater/smaller」這類關鍵字就要有反應
    • 面積/矩形題常常用得上單調堆疊
    • 序列驗證題可能需要以堆疊為基礎的做法
  2. 解題流程

    • 先從暴力解開始,把題目搞懂
    • 判斷單調性質能不能把解法優化
    • 畫例子,把堆疊的行為視覺化
  3. 面試時的溝通

    • 解釋為什麼這題適合用單調堆疊
    • 用例子把堆疊的狀態走一遍給面試官看
    • 討論時間/空間複雜度的取捨
  4. 實作提醒

    • 從模板骨架開始寫
    • 把心力放在 while 條件寫對
    • 用簡單的例子測試(像 [2,1,2,4,3,1])
  5. 預期會有的追問

    • 有重複值怎麼辦?
    • 如果要的是 previous 而不是 next 呢?
    • 空間複雜度還能再優化嗎?
    • 怎麼延伸到二維問題?

相關主題

  • 堆疊:單調堆疊是堆疊這個資料結構的特化應用
  • 雙端佇列:滑動視窗最大值用的是單調雙端佇列
  • 雙指標:某些面積計算題的替代解法
  • 動態規劃:有些最佳化問題會把 DP 和單調堆疊搭在一起
  • 二分搜尋:在有序結構中找邊界
  • 線段樹:區間最大/最小值的進階查詢

LC 範例

2-1) Daily Temperatures (LC 739) — 單調遞減堆疊

堆疊存索引;遇到更暖的一天就彈出。

java
// LC 739 - Daily Temperatures
// IDEA: Monotonic decreasing stack — pop when current > stack top
// time = O(N), space = O(N)
public int[] dailyTemperatures(int[] temperatures) {
    int n = temperatures.length;
    int[] ans = new int[n];
    Deque<Integer> stack = new ArrayDeque<>(); // stores indices
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
            int idx = stack.pop();
            ans[idx] = i - idx;
        }
        stack.push(i);
    }
    return ans;
}

2-2) Largest Rectangle in Histogram (LC 84) — 單調遞增堆疊

來了比較矮的柱子就彈出;用堆疊算出寬度後求面積。

java
// LC 84 - Largest Rectangle in Histogram
// IDEA: Monotonic increasing stack — pop and compute area on shorter bar
// time = O(N), space = O(N)
public int largestRectangleArea(int[] heights) {
    Deque<Integer> stack = new ArrayDeque<>();
    int maxArea = 0;
    for (int i = 0; i <= heights.length; i++) {
        int h = (i == heights.length) ? 0 : heights[i];
        while (!stack.isEmpty() && h < heights[stack.peek()]) {
            int height = heights[stack.pop()];
            int width = stack.isEmpty() ? i : i - stack.peek() - 1;
            maxArea = Math.max(maxArea, height * width);
        }
        stack.push(i);
    }
    return maxArea;
}

為什麼行得通 — 每次彈出都負責一個極大矩形(Python) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

leetcode_python/Greedy/largest-rectangle-in-histogram.py

核心想法: 把某根柱子固定為矩形的高度,它最好的矩形就是往左右兩邊一路延伸、直到被更矮的柱子擋住為止的那麼寬:

text
   left boundary  = previous SMALLER bar  -> the index just under it on the stack
   right boundary = next SMALLER bar      -> the index i that pops it

         left            popped            i
          |    >= h   [   h   ]   >= h     |
   width = i - stack[-1] - 1     (stack empty after the pop -> width = i)

遞增堆疊在彈出的那一刻就把兩個邊界都交給你了 — 右邊界是正在執行彈出的那根柱子,左邊界是彈出後留在頂端的那個 — 所以每根柱子的極大矩形都恰好算一次,總共 O(n)。

為什麼每根柱子只檢查它的極大矩形就夠了: 取最佳矩形,範圍是 [l, r]。它的高度是 min(heights[l..r]),出現在某根柱子 k。柱子 k 的極大範圍一定包含 [l, r](裡面沒有比它矮的),所以 k 被彈出時算出的面積 ≥ 最佳解。同高度但比較窄的矩形 — 明明能拿 1 × 3 卻只算 1 × 2 — 都被支配了,完全不需要列舉。

python
# python
# LC 84 - Largest Rectangle in Histogram (two-phase: main loop + drain)
# IDEA: increasing stack of indices; a pop fixes the popped bar's height, the current
#       index is its right boundary, the new stack top is its left boundary
# time = O(n), space = O(n)
def largestRectangleArea(heights):
    stack, max_area, n = [], 0, len(heights)
    for i in range(n):
        while stack and heights[stack[-1]] > heights[i]:
            h = heights[stack.pop()]
            width = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, h * width)
        stack.append(i)
    # Drain: nothing shorter ever arrived on their right, so they extend to n
    while stack:
        h = heights[stack.pop()]
        width = n if not stack else n - stack[-1] - 1
        max_area = max(max_area, h * width)
    return max_area

為什麼結尾需要 while stack: 清空迴圈: 一根柱子只有在被更矮的柱子彈出時才會計分。遞增的直方圖([1, 2, 3])從頭到尾都不會彈出任何東西;而不論輸入為何,主迴圈結束時還留在堆疊裡的柱子,右邊都沒有更矮的柱子。它們的右邊界就是陣列尾端 n。這個清空迴圈正是 模板 3 那個哨兵 0 的手寫版 — 兩者擇一,不要同時用。

在 heights = [2,1,5,6,2,3] 上的圖解追蹤(答案應該是 10;i = 6 是清空階段):

text
i=0 h=2 : push                          stack=[0]
i=1 h=1 : pop 0 (h=2) left=-1 w=1  area=2   best=2
          push                          stack=[1]
i=2 h=5 : push                          stack=[1,2]
i=3 h=6 : push                          stack=[1,2,3]
i=4 h=2 : pop 3 (h=6) left=2  w=1  area=6   best=6
          pop 2 (h=5) left=1  w=2  area=10  best=10   <- bars 2..3 at height 5
          push                          stack=[1,4]
i=5 h=3 : push                          stack=[1,4,5]
i=6 end : pop 5 (h=3) left=4  w=1  area=3
          pop 4 (h=2) left=1  w=4  area=8
          pop 1 (h=1) left=-1 w=6  area=6   best=10

變形 — 存 (start, height) 的堆疊。 值得再記一種寫法,因為它省掉了 stack[-1] 的邊界運算和堆疊為空的分支:每個項目記錄的是這個高度能往左延伸到哪裡。彈出某個項目的較矮柱子會繼承那個項目的 start,因為它至少也能延伸到那麼遠:

python
# python
# LC 84 - Largest Rectangle in Histogram ((start, height) stack)
# IDEA: stack entries are (leftmost index this height reaches, height), heights increasing;
#       a popped entry's rectangle is [start, i), and the popper inherits its start
# time = O(n), space = O(n)
def largestRectangleArea(heights):
    st, max_area = [], 0
    for i, h in enumerate(heights):
        start = i
        while st and st[-1][1] > h:
            start, prev_h = st.pop()
            max_area = max(max_area, prev_h * (i - start))
        st.append((start, h))
    n = len(heights)
    for start, h in st:              # survivors reach the right end
        max_area = max(max_area, h * (n - start))
    return max_area

陷阱

  • 彈出後堆疊為空,代表左邊沒有更矮的柱子:寬度是 i,不是 i - 1。
  • 彈出條件用 > 還是 >=:兩者算出的最大值都正確。用 > 時,被彈出柱子下方若有等高柱子,這根柱子的寬度會被低估,但下方那根等高柱子之後被彈出時會拿到完整的寬度。只是不要把每根柱子各自的寬度拿去解需要精確範圍的題目(LC 907 就需要 >=/> 這組不對稱的條件)。
  • 暴力解搭配滾動最小值是 O(n²);以最小值做分治平均是 O(n log n),但遇到已排序的輸入會退化成 O(n²)。這兩個都不是面試官想聽的答案。

2-3) Next Greater Element I (LC 496) — 單調堆疊 + HashMap

先把 nums2 的 next greater 全部算好,再回答 nums1 的查詢。

java
// LC 496 - Next Greater Element I
// IDEA: Monotonic decreasing stack on nums2; store results in map
// time = O(M + N), space = O(M)
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
    Map<Integer, Integer> map = new HashMap<>(); // val -> next greater val
    Deque<Integer> stack = new ArrayDeque<>();
    for (int num : nums2) {
        while (!stack.isEmpty() && num > stack.peek()) {
            map.put(stack.pop(), num);
        }
        stack.push(num);
    }
    int[] ans = new int[nums1.length];
    for (int i = 0; i < nums1.length; i++) {
        ans[i] = map.getOrDefault(nums1[i], -1);
    }
    return ans;
}

2-4) Trapping Rain Water (LC 42) — 單調堆疊

來了比較高的柱子就彈出;接到的水 =(較小高度的差)* 寬度。

java
// LC 42 - Trapping Rain Water
// IDEA: Monotonic stack — pop when taller bar found, water fills between boundaries
// time = O(N), space = O(N)
public int trap(int[] height) {
    Deque<Integer> stack = new ArrayDeque<>();
    int water = 0;
    for (int i = 0; i < height.length; i++) {
        while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
            int bottom = stack.pop();
            if (stack.isEmpty()) break;
            int left = stack.peek();
            int width = i - left - 1;
            int boundedHeight = Math.min(height[left], height[i]) - height[bottom];
            water += width * boundedHeight;
        }
        stack.push(i);
    }
    return water;
}

2-5) Next Greater Element II (LC 503) — 環狀單調堆疊

把陣列走兩遍(或用模運算)來處理環狀的 next greater 查詢。

java
// LC 503 - Next Greater Element II (circular array)
// IDEA: Monotonic stack — traverse 2n indices with modulo for circular effect
// time = O(N), space = O(N)
public int[] nextGreaterElements(int[] nums) {
    int n = nums.length;
    int[] ans = new int[n];
    Arrays.fill(ans, -1);
    Deque<Integer> stack = new ArrayDeque<>();
    for (int i = 0; i < 2 * n; i++) {
        while (!stack.isEmpty() && nums[i % n] > nums[stack.peek()]) {
            ans[stack.pop()] = nums[i % n];
        }
        if (i < n) stack.push(i);
    }
    return ans;
}

2-6) Online Stock Span (LC 901) — 單調遞減堆疊

把先前 <= 目前價格的都彈掉;span = 距離上一個更高價過了幾天。

java
// LC 901 - Online Stock Span
// IDEA: Monotonic decreasing stack storing [price, span] pairs
// time = O(1) amortized per call, space = O(N)
class StockSpanner {
    Deque<int[]> stack = new ArrayDeque<>(); // [price, span]
    public int next(int price) {
        int span = 1;
        while (!stack.isEmpty() && stack.peek()[0] <= price) {
            span += stack.pop()[1];
        }
        stack.push(new int[]{price, span});
        return span;
    }
}

2-7) Sum of Subarray Minimums (LC 907) — 單調堆疊

對每個元素,找出它是最小值的左右邊界;用單調堆疊做。

java
// LC 907 - Sum of Subarray Minimums
// IDEA: Monotonic stack — for each element find left & right span as minimum
// time = O(N), space = O(N)
public int sumSubarrayMins(int[] arr) {
    int n = arr.length;
    int MOD = 1_000_000_007;
    int[] left = new int[n], right = new int[n];
    Deque<Integer> stack = new ArrayDeque<>();
    // left[i] = distance to previous smaller element
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) stack.pop();
        left[i] = stack.isEmpty() ? i + 1 : i - stack.peek();
        stack.push(i);
    }
    stack.clear();
    // right[i] = distance to next smaller or equal element
    for (int i = n-1; i >= 0; i--) {
        while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) stack.pop();
        right[i] = stack.isEmpty() ? n - i : stack.peek() - i;
        stack.push(i);
    }
    long ans = 0;
    for (int i = 0; i < n; i++) ans = (ans + (long) arr[i] * left[i] * right[i]) % MOD;
    return (int) ans;
}

貢獻法 — 把 left[i] / right[i] 視覺化(Python) 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

leetcode_python/Math/sum-of-subarray-minimums.py

核心想法: 每個子陣列都恰好有一個最小值,所以不要去列舉子陣列,而是反過來問*「有多少個子陣列的最小值是 arr[i]?」* — 然後把 arr[i] * count 全部加起來。

對每個索引 i,這個數量會拆成兩個互相獨立的選擇:

text
        left choices              right choices
           <---->                    <----->
   ┌───────────────────────────────────────────────┐
   │  ...  PSE   .   .   .   [i]   .   .   .   NSE   │      arr
   └───────────────────────────────────────────────┘
              ^                          ^
        previous smaller           next smaller-or-equal
        element (strict >=)        element (strict >)

   left[i]  = i - PSE     ← # of left endpoints that keep arr[i] as min
   right[i] = NSE - i     ← # of right endpoints that keep arr[i] as min

   count(i) = left[i] * right[i]
   contribution = arr[i] * left[i] * right[i]
  • 一個子陣列要讓 arr[i] 當最小值,就必須起點落在 (PSE, i]、終點落在 [i, NSE)。
  • 兩個範圍互相獨立 → 相乘。

處理重複值(避免重複計算): 左邊那趟用 >=、右邊那趟用 >(刻意不對稱)。這樣相等的值只會被算在其中一側。

python
# python
# LC 907 - Sum of Subarray Minimums (contribution method)
# time = O(n), space = O(n)
MOD = 10**9 + 7
n = len(arr)
left  = [0] * n   # left[i]  = distance to previous smaller element
right = [0] * n   # right[i] = distance to next smaller-or-equal element

# --- LEFT pass: distance to Previous Smaller Element (pop on >=) ---
mono_st = []
for i in range(n):
    val = arr[i]
    # Pop elements that are greater than OR EQUAL to current val
    while mono_st and arr[mono_st[-1]] >= val:
        mono_st.pop()   # these can't be the left boundary of arr[i]

    # If stack empty -> val is the smallest so far, boundary is index -1
    #   left choices = i - (-1) = i + 1
    # Else -> boundary is the surviving stack top (the PSE)
    #   left choices = i - mono_st[-1]
    left[i] = i + 1 if not mono_st else i - mono_st[-1]
    mono_st.append(i)

# --- RIGHT pass: distance to Next Smaller Element (pop on >) ---
mono_st = []
for i in range(n - 1, -1, -1):
    val = arr[i]
    while mono_st and arr[mono_st[-1]] > val:   # strict > here
        mono_st.pop()
    right[i] = n - i if not mono_st else mono_st[-1] - i
    mono_st.append(i)

ans = 0
for i in range(n):
    ans = (ans + arr[i] * left[i] * right[i]) % MOD

堆疊為空時為什麼 left[i] = i + 1: 堆疊為空代表左邊沒有任何元素比 arr[i] 小 — arr[i] 主宰了整個前綴。想像中的左邊界落在索引 -1,所以左邊的選擇涵蓋索引 0..i,也就是 i - (-1) = i + 1。

在 arr = [3, 1, 2, 4] 上的圖解追蹤:

text
i=0 val=3 : stack empty              -> left[0] = 0-(-1) = 1   stack=[0]
i=1 val=1 : arr[0]=3 >= 1 -> pop 0
            stack empty              -> left[1] = 1-(-1) = 2   stack=[1]
i=2 val=2 : arr[1]=1 >= 2? no        -> left[2] = 2-1     = 1   stack=[1,2]
i=3 val=4 : arr[2]=2 >= 4? no        -> left[3] = 3-2     = 1   stack=[1,2,3]

left  = [1, 2, 1, 1]
right = [1, 3, 2, 1]   (symmetric backward pass with strict >)

contribution = 3*1*1 + 1*2*3 + 2*1*2 + 4*1*1 = 3 + 6 + 4 + 4 = 17  ✓

2-8) Remove K Digits (LC 402) — 單調遞增堆疊

維持遞增堆疊;來了比較小的數字就把前面的移掉。

java
// LC 402 - Remove K Digits
// IDEA: Greedy + monotonic increasing stack — remove larger digits greedily
// time = O(N), space = O(N)
public String removeKdigits(String num, int k) {
    Deque<Character> stack = new ArrayDeque<>();
    for (char c : num.toCharArray()) {
        while (k > 0 && !stack.isEmpty() && stack.peek() > c) {
            stack.pop(); k--;
        }
        stack.push(c);
    }
    while (k-- > 0) stack.pop(); // remove from top if k still > 0
    // reconstruct result in correct order (bottom to top of stack)
    Deque<Character> result = new ArrayDeque<>(stack);
    StringBuilder sb = new StringBuilder();
    boolean leadingZero = true;
    while (!result.isEmpty()) {
        char c = result.pollFirst();
        if (leadingZero && c == '0') continue;
        leadingZero = false;
        sb.append(c);
    }
    return sb.length() == 0 ? "0" : sb.toString();
}

2-9) Maximal Rectangle (LC 85) — 直方圖 + 單調堆疊

逐列算出直方圖高度;每一列套用 LC 84 的最大矩形邏輯。

java
// LC 85 - Maximal Rectangle
// IDEA: For each row build histogram; apply largestRectangleArea (LC 84) logic
// time = O(M*N), space = O(N)
public int maximalRectangle(char[][] matrix) {
    if (matrix.length == 0) return 0;
    int n = matrix[0].length, maxArea = 0;
    int[] heights = new int[n];
    for (char[] row : matrix) {
        for (int j = 0; j < n; j++)
            heights[j] = row[j] == '0' ? 0 : heights[j] + 1;
        maxArea = Math.max(maxArea, largestRectangle(heights));
    }
    return maxArea;
}
private int largestRectangle(int[] heights) {
    Deque<Integer> stack = new ArrayDeque<>();
    int max = 0;
    for (int i = 0; i <= heights.length; i++) {
        int h = i == heights.length ? 0 : heights[i];
        while (!stack.isEmpty() && h < heights[stack.peek()]) {
            int height = heights[stack.pop()];
            int width = stack.isEmpty() ? i : i - stack.peek() - 1;
            max = Math.max(max, height * width);
        }
        stack.push(i);
    }
    return max;
}

2-10) 車隊家族(LC 853 / 4045 / 1776)— 什麼時候「堆疊」其實是貪婪 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

leetcode_python/Stack/car-fleet.py、leetcode_python/Stack/count-robot-groups.py

這一節講的是反面教材。 LC 853 一向被當成堆疊題教,這個 repo 的 README 也把它標成 **stack** — 但它的堆疊從來不彈出,所以它其實是穿著堆疊外衣的兩變數貪婪。這三題裡只有 LC 1776 真的需要堆疊。能說出是哪一題、以及為什麼,才是這節真正的價值。

三題共通的性質:前方說話算話

text
nobody can pass the entity ahead of it
  -> you can only ever merge with the entity DIRECTLY ahead
  -> a merged group moves at the FRONT member's speed (the rear one slows down)

so: scan RIGHT -> LEFT (front -> back), and the survivors appear in order

最後那一行,就是為什麼 853 和 4045 只需要一個「目前最前面的存活者」變數:存活者一旦確定就不會再被回頭修改 — 而堆疊只有在你必須回頭找更舊的候選者時,才配叫做堆疊。

LC 853 Car Fleet — 那個從來不彈出的堆疊

這題有終點 target,所以每台車都可以塌縮成一個數字 — 假設前方完全沒人擋路時,它抵達終點的時間:

text
t_i = (target - position[i]) / speed[i]

後車會併入前面的車隊,條件是它抵達的時間不會更晚,也就是 t_i <= max(前方所有人的 t)。於是由前往後排序,數「新的最大值出現幾次」就好:

python
# python
# LC 853 - Car Fleet  (the textbook "stack" version)
# time = O(n log n), space = O(n)
def carFleet(target, position, speed):
    st = []
    for p, s in sorted(zip(position, speed), reverse=True):   # front -> back
        t = (target - p) / s
        if not st or t > st[-1]:      # cannot catch the fleet ahead -> new fleet
            st.append(t)
        # else: t <= st[-1], it catches up and is absorbed — nothing to record
    return len(st)

注意這個迴圈從來沒有彈出過任何東西。所以 st 一定遞增,st[-1] 就只是目前看過最大的 t,而 len(st) 就只是那個最大值被更新過幾次。兩個變數就能做完全一樣的事:

python
# python
# LC 853 - the same algorithm, stack removed
# time = O(n log n) (the sort), space = O(1)
def carFleet(target, position, speed):
    cnt, mx = 0, 0.0
    for p, s in sorted(zip(position, speed), reverse=True):   # front -> back
        t = (target - p) / s
        if t > mx:                    # arrives later than anyone ahead -> its own fleet
            cnt += 1
            mx = t
    return cnt
java
// java
// LC 853 - Car Fleet, running-max greedy
// time = O(N log N), space = O(N) for the index array (O(1) beyond the sort)
public int carFleet(int target, int[] position, int[] speed) {
    int n = position.length;
    Integer[] idx = new Integer[n];
    for (int i = 0; i < n; i++) idx[i] = i;
    Arrays.sort(idx, (a, b) -> position[b] - position[a]);   // front -> back
    int cnt = 0;
    double mx = 0.0;
    for (int i : idx) {
        double t = (double) (target - position[i]) / speed[i];
        if (t > mx) { cnt++; mx = t; }
    }
    return cnt;
}

兩種寫法在隨機測資上結果一致 — 本來就該一致,因為它們是同一個演算法。挑你在壓力下寫得出來的那一種就好;只是不要聲稱那個堆疊有在做事。

LC 4045 Count Robot Groups — 同一套貪婪,只是沒有終點

機器人會一直往前跑,而且多了一個合併門檻 distance。沒有終點就代表不存在抵達時間,所以每個機器人沒有單一數字可以比較 — 狀態是這個群組「最右側機器人」的 (position, speed) 這一對。機器人 i 會被吸收,只要下面任一個條件成立:

text
position[i+1] - position[i] <= distance   -> already touching at t = 0
speed[i] > cur_s                          -> closing on the front group, and with
                                             infinite time ANY positive closing rate
                                             eventually eats ANY gap
python
# python
# LC 4045 - Count Robot Groups   (position is given sorted, so no sort is needed)
# IDEA: right -> left; `cur_s` is the speed of the frontmost group that survived.
# time = O(n), space = O(1)
def countGroups(position, speed, distance):
    n = len(position)
    cur_s = speed[n - 1]           # the frontmost robot is always a group on its own
    cnt = 1
    for i in range(n - 2, -1, -1):
        # touching test -> the NEIGHBOUR;  closing test -> the FRONT group
        if position[i + 1] - position[i] <= distance or speed[i] > cur_s:
            continue               # absorbed
        cnt += 1
        cur_s = speed[i]           # a new frontmost survivor
    return cnt

陷阱:這兩個測試用的是不同的參考對象。 合併時採用的是最右側機器人的狀態,所以有人從後面加入時 cur 完全不動 — 但 t = 0 的那些合併是同時發生的,而且是透過鄰居一路串起來的:

text
position = [18,19,22,24], distance = 3     gaps: 1, 3, 2  -> every pair touches
all four collapse at t = 0, even though 18 is 6 away from the group's position 24

把 t = 0 的接觸測試拿去和 cur_p 比(而不是和 position[i+1] 比),這些串接就會無聲無息地消失。而速度測試單獨拿去和 cur 比是安全的:如果機器人 i+1 之所以存活是因為它比前方更快,而 i 又比它更快,那 i 一定也比前方更快 — 追得上鄰居,就一定追得上前方那個群組。

853 與 4045 逐項對照

LC 853 Car Fleet LC 4045 Count Robot Groups
時間範圍 終點在 target 沒有終點 — 機器人永遠跑下去
每個實體的狀態 一個純量:t = (target - p) / s (position, speed) 這一對;不存在純量
合併判定 t_i <= max(前方的 t) — 比時間 speed[i] > cur_s — 比速度
合併門檻 貼到零距離 間距 <= distance,外加 t = 0 的接觸規則
輸入順序 位置沒排序 → 必須先排序 position 已排序 → 不用排序
合併後採用 較慢的那台(也就是前車)的速度 最右側機器人的位置與速度
複雜度 O(n log n)/排序之外 O(1) O(n)/O(1)
需要堆疊嗎? 不需要 — 只推不彈 不需要 — 兩個變數

為什麼 853 有純量、4045 沒有,是這節最該帶走的一個想法:

  • 853 有期限,所以「後車追不追得上前車?」就變成「它抵達得會不會更晚?」— 一台車一個數字的比較。兩台車可以一路互相接近,卻仍然算成兩個車隊,因為它們相遇的地點會在 target 之後。是期限造出了那個純量,也是那個純量讓滾動最大值成立。
  • 4045 沒有期限,所以只有「有沒有在拉近」這件事重要:時間無限長的話,speed[i] > cur_s 本身就已經是答案了 — 根本沒有算式要算,因為沒有人會抵達任何地方。

LC 1776 Car Fleet II — 家族裡真正需要堆疊的那一題

同一條路、同樣「前方說話算話」,但問題換了:要回報每一台車的碰撞時間,而不是一個數量。這就打破了「單一存活者」這個不變式,因為前面那台車自己也會在某個已知時間 ans[j] 被吸收掉:

text
if car i reaches car j LATER than j's own collision time
    -> j is already gone when i gets there
    -> i's real target is whatever is further ahead
    -> back up to an older candidate        <- THIS is a pop
python
# python
# LC 1776 - Car Fleet II: ans[i] = when car i collides with the car ahead (-1 = never)
# IDEA: right -> left monotonic stack of cars still catchable. Pop a candidate that
#       cannot be caught, or that dies before we reach it.
# time = O(n), space = O(n)
def getCollisionTimes(cars):
    n = len(cars)
    ans = [-1.0] * n
    st = []                                     # indices, front -> back
    for i in range(n - 1, -1, -1):
        p, s = cars[i]
        while st:
            j = st[-1]
            pj, sj = cars[j]
            # (a) not faster than j -> can never reach it
            # (b) reaching j takes longer than j survives -> aim further ahead
            if s <= sj or (ans[j] > 0 and (pj - p) / (s - sj) >= ans[j]):
                st.pop()
            else:
                break
        if st:
            j = st[-1]
            ans[i] = (cars[j][0] - p) / (s - cars[j][1])
        st.append(i)
    return ans

# cars = [[1,2],[2,1],[4,3],[7,2]] -> [1.0, -1.0, 3.0, -1.0]
# cars = [[3,4],[5,4],[6,3],[9,1]] -> [2.0, 1.0, 1.5, -1.0]

帶進面試房間的判準

題目要你給的是 前方的行為是 該用什麼結構
存活群組的數量(853、4045) 永久的 — 存活者不會被回頭修改 一兩個變數(貪婪)
每個元素各自的時間或值(1776、2289) 暫時的 — 前方自己也會在已知時間死掉 真正的堆疊,而且會彈出

所以在面試官面前誠實的講法是:「853 和 4045 是同一套由右往左的貪婪 — LC 853 教科書解法的那個堆疊從來不彈出,所以我會改成維護一個滾動最大值。1776 才是堆疊真正派上用場的地方,因為已經算出來的答案有可能被推翻。」

2-11) Asteroid Collision (LC 735) — 堆疊模擬

向右飛的留在堆疊上;向左飛的一直和頂端相撞,直到穩定。

java
// LC 735 - Asteroid Collision
// IDEA: Stack — simulate collisions between right (+) and left (-) asteroids
// time = O(N), space = O(N)
public int[] asteroidCollision(int[] asteroids) {
    Deque<Integer> stack = new ArrayDeque<>();
    for (int a : asteroids) {
        boolean alive = true;
        while (alive && a < 0 && !stack.isEmpty() && stack.peek() > 0) {
            if (stack.peek() < -a) { stack.pop(); }      // stack top destroyed
            else if (stack.peek() == -a) { stack.pop(); alive = false; } // both destroyed
            else alive = false;                             // incoming destroyed
        }
        if (alive) stack.push(a);
    }
    int[] res = new int[stack.size()];
    for (int i = res.length - 1; i >= 0; i--) res[i] = stack.pop();
    return res;
}

2-12) Sum of Subarray Ranges (LC 2104) — 雙單調堆疊(貢獻法)

sum(ranges) = sum(subarray maxs) − sum(subarray mins)。最大值與最小值各跑一趟單調堆疊;每彈出一個元素,就算出它以最大/最小值的身分主宰了多少個子陣列。

核心想法

text
range(subarray) = max − min
sum(all ranges) = sum(all subarray maxs) − sum(all subarray mins)

對每個元素 nums[mid],找出它的左、右主宰邊界:

  • 左邊界 L — 前一個會讓 nums[mid] 失去最大/最小身分的元素索引(沒有就是 -1)
  • 右邊界 R — 下一個會取代它的元素索引(沒有就是 n)

nums[mid] 擔任最大/最小值的子陣列數量:

text
count = (mid − L) × (R − mid)
contribution = nums[mid] × count

哨兵迴圈讓 i 從 0 跑到 n(含)。當 i == n 時,用 n 當右邊界把堆疊裡剩下的索引全部清掉。

對重複值安全的邊界規則(避免相等元素被重複計算):

  • 最大值那趟:nums[mid] < nums[i] 時彈出(嚴格);左邊界是上一個大於或等於的元素。
  • 最小值那趟:nums[mid] > nums[i] 時彈出(嚴格);左邊界是上一個小於或等於的元素。

圖解追蹤 — [1, 3, 2] 的最大值那趟

text
Decreasing stack (max contribution)

i=0: push 0         stack=[0]
i=1: nums[0]=1 < nums[1]=3 → pop mid=0
       left=-1, right=1
       contrib = 1 * (0-(-1)) * (1-0) = 1*1*1 = 1
     push 1          stack=[1]
i=2: nums[1]=3 > nums[2]=2, no pop
     push 2          stack=[1,2]
i=3 (sentinel): flush
     pop mid=2: left=1, right=3  → 2*(2-1)*(3-2) = 2
     pop mid=1: left=-1, right=3 → 3*(1-(-1))*(3-1) = 12

max_sum = 1 + 2 + 12 = 15

min pass (increasing stack) → min_sum = 10

answer = 15 − 10 = 5  ✓
verify: [1]=0,[3]=0,[2]=0,[1,3]=2,[3,2]=1,[1,3,2]=2 → sum = 5

模式(Python)

python
# python
# LC 2104 - Sum of Subarray Ranges
# IDEA: sum(ranges) = sum(subarray maxs) - sum(subarray mins)
#       Contribution method via monotonic stack — one pass per role
# time = O(N), space = O(N)
def subArrayRanges(nums):
    n = len(nums)

    def contribution(is_max):
        stack = []
        total = 0
        for i in range(n + 1):          # sentinel: i == n flushes remaining
            while stack and (
                i == n or
                (nums[stack[-1]] < nums[i] if is_max else nums[stack[-1]] > nums[i])
            ):
                mid = stack.pop()
                left  = stack[-1] if stack else -1   # previous boundary index
                right = i                            # current index = right boundary
                total += nums[mid] * (mid - left) * (right - mid)
            stack.append(i)
        return total

    return contribution(True) - contribution(False)

模式(Java)

java
// java
// LC 2104 - Sum of Subarray Ranges
// IDEA: sum(ranges) = sum(subarray maxs) - sum(subarray mins)
//       Contribution method: for each element count subarrays where it's max/min
// time = O(N), space = O(N)
public long subArrayRanges(int[] nums) {
    return contribution(nums, true) - contribution(nums, false);
}

private long contribution(int[] nums, boolean isMax) {
    int n = nums.length;
    Deque<Integer> stack = new ArrayDeque<>();
    long total = 0;

    for (int i = 0; i <= n; i++) {          // i == n is the sentinel flush
        while (!stack.isEmpty()) {
            int mid = stack.peek();
            boolean shouldPop = (i == n) ||
                (isMax ? nums[mid] < nums[i] : nums[mid] > nums[i]);
            if (!shouldPop) break;
            stack.pop();
            int left  = stack.isEmpty() ? -1 : stack.peek(); // prev boundary
            int right = i;                                    // next boundary
            total += (long) nums[mid] * (mid - left) * (right - mid);
        }
        stack.push(i);
    }
    return total;
}

雙堆疊邏輯總結

趟次 堆疊類型 彈出條件 算出什麼
最大值那趟 單調遞減 nums[mid] < nums[i] 所有子陣列最大值的總和
最小值那趟 單調遞增 nums[mid] > nums[i] 所有子陣列最小值的總和
兩趟都有 i = n 的哨兵 一律清空 處理靠右邊界的元素

相似題目

題目 LC# 關鍵差異
Sum of Subarray Ranges 2104 max_sum − min_sum;兩趟單調堆疊
Sum of Subarray Minimums 907 只算最小值的貢獻;單趟遞增堆疊
Maximum Subarray Min-Product 1856 最小值貢獻 × 子陣列和;前綴和 + 堆疊
Sum of Total Strength of Wizards 2281 最小值 × 和的和;前綴和的前綴和 + 堆疊
Largest Rectangle in Histogram 84 面積 = 高 × 寬;遇到較矮的柱子彈出
Number of Visible People in Queue 1944 每個元素彈出的次數就是答案

2-13) Longest Absolute File Path (LC 388) — 以巢狀深度為索引的堆疊 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

模板 7:深度堆疊。 這個堆疊不是按值單調,而是按深度單調:stack[d] 永遠存著深度 d 的累積路徑長度。處理深度為 d 的那一行之前,先彈到 stack.size() == d,就把剛結束的兄弟分支全部丟掉了。

核心想法

text
"dir\n\tsub1\n\t\tfile.ext\n\tsub2"

line          depth   pop until size==depth   stack (path lengths, '/' included)
dir             0     []                      [4]            "dir/"
  sub1          1     [4]                     [4, 9]         "dir/sub1/"
    file.ext    2     [4, 9]                  (file → no push, len = 9 + 8 = 17)
  sub2          1     pop 9 → [4]             [4, 9]
  • depth = 開頭 \t 的數量;剩下的部分就是名稱。
  • 目錄會推入 parentLen + name.length() + 1(+1 是 / 分隔符)。
  • 檔案(名稱含 .)永遠不推入 — 只用 parentLen + name.length() 更新答案。
java
// java
// LC 388 - Longest Absolute File Path
// IDEA: Stack indexed by nesting depth — stack.peek() = length of the current
//       directory prefix (with trailing '/'); pop until size == depth to leave sibling branches
// time = O(N), space = O(D)  // N = input length, D = max depth
public int lengthLongestPath(String input) {
    Deque<Integer> stack = new ArrayDeque<>(); // prefix length per depth
    int maxLen = 0;
    for (String line : input.split("\n")) {
        int depth = line.lastIndexOf('\t') + 1;  // tabs are leading & contiguous
        String name = line.substring(depth);
        while (stack.size() > depth) stack.pop();          // leave finished branches
        int parentLen = stack.isEmpty() ? 0 : stack.peek();
        int curLen = parentLen + name.length();
        if (name.indexOf('.') >= 0) {
            maxLen = Math.max(maxLen, curLen);             // file → candidate answer
        } else {
            stack.push(curLen + 1);                        // dir → +1 for '/'
        }
    }
    return maxLen;
}
python
# python
# LC 388 - Longest Absolute File Path
# IDEA: stack[d] = length of the directory prefix at depth d (trailing '/' counted);
#       pop until len(stack) == depth so sibling branches are discarded
# time = O(N), space = O(D)
def lengthLongestPath(input: str) -> int:
    stack = []          # prefix length per depth
    best = 0
    for line in input.split('\n'):
        depth = len(line) - len(line.lstrip('\t'))
        name = line[depth:]
        while len(stack) > depth:
            stack.pop()
        parent = stack[-1] if stack else 0
        cur = parent + len(name)
        if '.' in name:
            best = max(best, cur)      # file
        else:
            stack.append(cur + 1)      # dir, +1 for '/'
    return best

陷阱

  • 答案是最長的到檔案的路徑,所以碰到目錄時絕對不要更新最大值。
  • 不要切掉字串之後才用 line.count('\t') 算深度 — 深度只能來自開頭的 tab。
  • 空輸入/完全沒有檔案 → 回傳 0。

2-14) Longest Valid Parentheses (LC 32) — 索引堆疊搭配基準哨兵 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

模板 8:索引堆疊 + 哨兵基準。 不要存字元,改存索引,並且先塞一個 -1 當作「目前這段合法區塊前一格的索引」。遇到 ) 彈出之後,新的堆疊頂端就是最後一個沒配對到的索引,所以 i - stack.peek() 就是以 i 結尾的合法長度 — 完全不用另外記長度。

遇到 ) 的兩種情況

text
pop, then:
  stack empty  → this ')' is unmatched → push i as the NEW base
  stack !empty → length = i - stack.top()

在 s = ")()())" 上的圖解追蹤

text
i=0 ')'  pop -1 → empty → push 0        stack=[0]        best=0
i=1 '('  push 1                          stack=[0,1]
i=2 ')'  pop 1 → top=0 → 2-0 = 2         stack=[0]        best=2
i=3 '('  push 3                          stack=[0,3]
i=4 ')'  pop 3 → top=0 → 4-0 = 4         stack=[0]        best=4
i=5 ')'  pop 0 → empty → push 5          stack=[5]        best=4  ✓
java
// java
// LC 32 - Longest Valid Parentheses
// IDEA: Stack of indices seeded with -1 (base). On ')' pop; if empty this ')' becomes
//       the new base, else answer candidate = i - stack.peek()
// time = O(N), space = O(N)
public int longestValidParentheses(String s) {
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(-1);                 // base = index before the current valid block
    int best = 0;
    for (int i = 0; i < s.length(); i++) {
        if (s.charAt(i) == '(') {
            stack.push(i);
        } else {
            stack.pop();
            if (stack.isEmpty()) stack.push(i);                 // unmatched ')' → new base
            else best = Math.max(best, i - stack.peek());
        }
    }
    return best;
}
python
# python
# LC 32 - Longest Valid Parentheses
# IDEA: index stack with -1 sentinel; i - stack[-1] = length of valid run ending at i
# time = O(N), space = O(N)
def longestValidParentheses(s: str) -> int:
    stack = [-1]          # base index
    best = 0
    for i, c in enumerate(s):
        if c == '(':
            stack.append(i)
        else:
            stack.pop()
            if not stack:
                stack.append(i)               # new base
            else:
                best = max(best, i - stack[-1])
    return best

陷阱

  • 忘了先塞 -1,所有從索引 0 開始的區段都會算錯。
  • 堆疊裡存的是索引,絕不是字元 — 整個技巧就靠索引運算。
  • O(1) 空間的替代解:走兩趟(左→右,再右→左),用 open/close 計數,當 close > open(反向時是 open > close)就歸零。這個解和 1D DP(dp[i] = 以 i 結尾的最長合法區段)都完整寫好、測過,放在 stack_examples.md §15。

2-15) Maximum Binary Tree (LC 654) — 用單調遞減堆疊建出笛卡兒樹 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

模板 9:用單調堆疊建樹。 直覺的「找最大值,再左右遞迴」是 O(n²)。用遞減堆疊可以一趟建出同一棵樹:被 num 彈掉的元素都比 num 小、而且都在它左邊 → 它們變成 num 的左子樹;活下來的堆疊頂端比 num 大 → num 變成它的右子節點。根就是堆疊最底部那個。

text
nums = [3,2,1,6,0,5]

3 → stack[3]
2 → 3>2, 3.right = 2            stack[3,2]
1 → 2>1, 2.right = 1            stack[3,2,1]
6 → pop 1,2,3 (each becomes 6.left in turn, last popped wins) → stack empty
                                 stack[6]      root = 6
0 → 6.right = 0                 stack[6,0]
5 → pop 0 → 5.left = 0; top 6 → 6.right = 5   stack[6,5]
java
// java
// LC 654 - Maximum Binary Tree
// IDEA: Monotonic DECREASING stack of nodes. Nodes popped by num become num's left
//       subtree (last popped = direct left child); surviving top adopts num as right child
// time = O(N), space = O(N)   // beats the O(N^2) divide & conquer build
public TreeNode constructMaximumBinaryTree(int[] nums) {
    Deque<TreeNode> stack = new ArrayDeque<>(); // values decreasing: bottom -> top
    for (int num : nums) {
        TreeNode cur = new TreeNode(num);
        while (!stack.isEmpty() && stack.peek().val < num) {
            cur.left = stack.pop();          // last popped ends up as the left child
        }
        if (!stack.isEmpty()) stack.peek().right = cur;
        stack.push(cur);
    }
    return stack.isEmpty() ? null : stack.peekLast(); // bottom of stack = global max = root
}
python
# python
# LC 654 - Maximum Binary Tree
# IDEA: monotonic decreasing stack of nodes; popped nodes chain into cur.left,
#       remaining top takes cur as its right child; stack[0] is the root
# time = O(N), space = O(N)
def constructMaximumBinaryTree(nums):
    stack = []                      # node values decreasing
    for num in nums:
        cur = TreeNode(num)
        while stack and stack[-1].val < num:
            cur.left = stack.pop()  # overwritten each pop -> keeps the LAST popped
        if stack:
            stack[-1].right = cur
        stack.append(cur)
    return stack[0] if stack else None

為什麼 cur.left 會被一直覆寫: 每彈出一次就重新指定一次 cur.left,而被彈出的節點彼此早就串好了(先被彈出的是前一個節點的右子節點),所以迴圈結束後 cur.left 正好指向整個被彈出區塊的根。

相關: LC 1008(Construct BST from Preorder Traversal)用的是鏡像的想法 — 遞增堆疊,較大的值成為最後一個被彈出節點的右子節點。

2-16) Min Stack (LC 155) — 輔助的非遞增堆疊 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

模板 10:平行的「最小值堆疊」。 另外維護一個值非遞增的堆疊;它的頂端永遠是目前存活元素中的最小值。這就是單調堆疊在設計題裡的樣子。

java
// java
// LC 155 - Min Stack
// IDEA: second stack keeps a non-increasing sequence of minima; push a new min when
//       val <= current min (the '=' is REQUIRED so duplicates survive matching pops)
// time = O(1) per op, space = O(N)
class MinStack {
    private final Deque<Integer> stack = new ArrayDeque<>();
    private final Deque<Integer> mins  = new ArrayDeque<>(); // non-increasing

    public void push(int val) {
        stack.push(val);
        if (mins.isEmpty() || val <= mins.peek()) mins.push(val);
    }
    public void pop() {
        int v = stack.pop();
        if (v == mins.peek()) mins.pop();
    }
    public int top()    { return stack.peek(); }
    public int getMin() { return mins.peek(); }
}
python
# python
# LC 155 - Min Stack
# IDEA: auxiliary non-increasing stack of minima; '<=' on push keeps duplicate minima
# time = O(1) per op, space = O(N)
class MinStack:
    def __init__(self):
        self.stack = []
        self.mins  = []          # non-increasing

    def push(self, val: int) -> None:
        self.stack.append(val)
        if not self.mins or val <= self.mins[-1]:
            self.mins.append(val)

    def pop(self) -> None:
        if self.stack.pop() == self.mins[-1]:
            self.mins.pop()

    def top(self) -> int:
        return self.stack[-1]

    def getMin(self) -> int:
        return self.mins[-1]

經典 bug: 只在 val < mins.peek()(嚴格)時才推入新的最小值。碰到 push(0); push(0); pop();,那唯一存下的 0 會被移掉,getMin() 就回傳錯的值。要用 <=。 省空間的變形: 在單一堆疊裡存 (val, minSoFar) 這種 pair — 操作一樣是 O(1),而且面試壓力下比較好講清楚。

2-17) 既有模板的各種變形

LC # 題目 基礎模板 變化點
1475 Final Prices With a Special Discount in a Shop 模板 2(next smaller) 是 next smaller 或相等 — 條件改成 prices[stack[-1]] >= prices[i] 才彈出,而且折扣是 price - prices[i],不是索引距離
1019 Next Greater Node In Linked List 模板 1(next greater) 一樣的遞減堆疊,但輸入是鏈結串列 — 先走一趟轉成陣列(或邊走邊推入 (index, val)),因為答案陣列需要隨機存取
768 Max Chunks To Make Sorted II 模板 1(遞減彈出) 堆疊裡放的是各區塊的最大值,不是原始元素;答案 = 最後的堆疊大小
769 Max Chunks To Make Sorted 模板 1(退化版) 值是 0..n-1 的排列,所以用一個 running max 就能取代堆疊:只要 runningMax == i 就切一塊
1047 / 1209 Remove All Adjacent Duplicates In String (I / II) 模板 5(堆疊帶資訊) 堆疊存 (char, count);count 到 k 就彈出 — LC 1047 就是 k = 2 的特例
4054 Count Shadow Pairs I 模板 2(next smaller) 是 next strictly smaller — 彈出改用 >=,然後還要扣掉窗口內相等的值,因為配對條件同樣是嚴格的(§2-19)

Max Chunks To Make Sorted II (LC 768) — 區塊最大值堆疊

java
// java
// LC 768 - Max Chunks To Make Sorted II
// IDEA: monotonic increasing stack of chunk MAXIMA. A value smaller than the top must
//       merge every chunk it is smaller than; the merged chunk keeps the largest max
// time = O(N), space = O(N)
public int maxChunksToSorted(int[] arr) {
    Deque<Integer> stack = new ArrayDeque<>(); // chunk maxima, increasing bottom -> top
    for (int num : arr) {
        if (!stack.isEmpty() && num < stack.peek()) {
            int maxOfMerged = stack.pop();
            while (!stack.isEmpty() && num < stack.peek()) stack.pop();
            stack.push(maxOfMerged);           // merged chunk keeps the old max
        } else {
            stack.push(num);                   // starts a new chunk
        }
    }
    return stack.size();
}
python
# python
# LC 768 - Max Chunks To Make Sorted II
# IDEA: increasing stack of chunk maxima; merging keeps the largest max
# time = O(N), space = O(N)
def maxChunksToSorted(arr):
    stack = []                       # chunk maxima, increasing
    for num in arr:
        if stack and num < stack[-1]:
            merged_max = stack.pop()
            while stack and num < stack[-1]:
                stack.pop()
            stack.append(merged_max)
        else:
            stack.append(num)
    return len(stack)

# LC 769 - Max Chunks To Make Sorted (values are a permutation of 0..n-1)
# time = O(N), space = O(1)
def maxChunksToSortedI(arr):
    chunks, running_max = 0, -1
    for i, num in enumerate(arr):
        running_max = max(running_max, num)
        if running_max == i:         # prefix holds exactly the values 0..i
            chunks += 1
    return chunks

2-18) Steps to Make Array Non-decreasing (LC 2289) — 把 DP 值扛在單調堆疊上 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

leetcode_python/Stack/steps-to-make-array-non-decreasing.py

模板 11:堆疊扛的是 dp 值,不只是位置。 上面每個模板都在彈出的那一刻算出一個最終數字(寬度、面積、距離)。這題不一樣:被彈出的元素會把自己的 dp 值往上交給彈出它的那個元素,答案因此一路串起來:dp[i] = max(我彈掉的所有人的 dp) + 1。答案就是 max(dp),也就是最長的那條鏈。

核心想法

一輪會同時刪掉所有滿足 nums[i-1] > nums[i] 的 nums[i]。逐輪模擬是 O(n²)(n 最大到 1e5),所以換個角度,對每個元素問:

text
dp[i] = the round in which nums[i] is deleted     (0 = never deleted)
answer = max(dp)

nums[i] 最終會被它左邊最近、而且嚴格大於它的元素刪掉 — 但那個「兇手」要等到兩者之間的元素全部消失,才碰得到 nums[i]。而這段等待時間,正好就是堆疊交接下來的東西:

text
keep a DECREASING stack of indices, then for each i:

  while nums[i] >= nums[stack[-1]]:        # this top can never kill me
      cur = max(cur, dp[stack.pop()])      #   -> inherit its deadline

  if stack:      dp[i] = cur + 1           # top is > nums[i]: my killer.
                                           #   it reaches me one round after
                                           #   the last of the popped ones died
  else:          dp[i] = 0                 # nothing bigger on the left -> I survive

用「吃掉」這個比喻看,有兩件事會變得很明顯:

  • 被彈掉的,就是我活得比它久的。 任何 <= nums[i] 的元素,死期都不會晚於 nums[i],所以它的死期是我的死期的下界。
  • 是 +1,不是 + 被彈掉的個數。 同一輪的刪除是同時發生的,所以一整批被彈掉的元素可能在同一輪就消失;只有最慢的那個(取 max,不是 sum)會拖住我的兇手,而且只拖剛好一輪。

讀懂堆疊:steps_to_remove 是什麼意思

把堆疊想成「值/死期」的配對,會比想成索引好記得多:

text
stack = [ [val, steps_to_remove], ... ]

val             = the element's value
steps_to_remove = WHICH ROUND this element is deleted in   (0 = never)

第二個數字不是工作量、不是距離、也不是嵌套深度 — 它是日曆上的輪數。照著這個階梯讀:

steps_to_remove 意思 為什麼
0 永遠不會被刪 左邊沒有任何嚴格比它大的元素(它是前綴最大值),所以沒有人刪得掉它
1 第 1 輪被刪 它的左鄰居本來就嚴格比它大 — 規則立刻觸發,不需要任何人先讓路
2 第 2 輪被刪 它和兇手之間那一段需要 1 輪才會消失;第 2 輪開始時它才被暴露出來
3 第 3 輪被刪 那一段需要 2 輪;之後兇手才會變成它的左鄰居
k 第 k 輪被刪 它前面那一段要 k - 1 輪才清得掉,下一輪才輪到它

所以 steps_to_remove = k 永遠是在說*「我前面還有 k - 1 輪,然後才換我」* — 這正是遞推式為什麼是 max(被彈掉的) + 1,也是為什麼答案要對所有死期取 max:最後一個排定的刪除發生完,陣列就是非遞減的了。

有一個結論值得講清楚,因為它是最常見的 off-by-one:死期是 0 的元素不是「在第 0 輪被刪掉」的元素,而是根本沒有死期的元素 — 這也是為什麼程式在 堆疊為空 的那個分支指派 0,而且從不把它算進 res。

圖解追蹤 — nums = [5,3,4,4,7]

text
i  nums[i]  pops (dp inherited)          stack after       dp[i]                 res
0    5      -                            [0]               0   (nothing bigger)   0
1    3      none (3 < 5)                 [0,1]             0+1 = 1                1
2    4      pop 1 -> cur=max(0,dp1)=1    [0,2]             1+1 = 2                2
3    4      pop 2 -> cur=max(0,dp2)=2    [0,3]             2+1 = 3                3
4    7      pop 3 (cur=3), pop 0         [4]               0   (stack emptied)     3

dp = [0,1,2,3,0]  ->  answer 3
check: round1 deletes 3 -> [5,4,4,7]; round2 deletes the first 4 -> [5,4,7];
       round3 deletes the second 4 -> [5,7]  ✓

逐步演練 — 完整的 LC 範例,(val, steps) 配對堆疊

nums = [5,3,4,4,7,3,6,11,8,5,11],答案應該是 3。同一個演算法,改用配對寫法,讓死期直接顯示在堆疊上。只要 stack[-1][0] <= val 就彈出,繼承 max(被彈掉的 steps),堆疊還有東西就 +1(空了就是 0):

# val 彈出前的堆疊 彈出 steps 彈出後的堆疊 res
0 5 [] — 0 — 堆疊為空,前綴最大值,永遠不死 [(5,0)] 0
1 3 [(5,0)] — 0 + 1 = 1 — 已經是 5 > 3,下一輪就死 [(5,0),(3,1)] 1
2 4 [(5,0),(3,1)] (3,1) 1 + 1 = 2 — 得先活過那個 3,然後才輪到 5 收它 [(5,0),(4,2)] 2
3 4 [(5,0),(4,2)] (4,2) 2 + 1 = 3 — 因為 <= 被彈出;繼承死期 2 [(5,0),(4,3)] 3
4 7 [(5,0),(4,3)] (4,3)、(5,0) 0 — 堆疊被清空,新的前綴最大值 [(7,0)] 3
5 3 [(7,0)] — 0 + 1 = 1 [(7,0),(3,1)] 3
6 6 [(7,0),(3,1)] (3,1) 1 + 1 = 2 [(7,0),(6,2)] 3
7 11 [(7,0),(6,2)] (6,2)、(7,0) 0 — 又被清空 [(11,0)] 3
8 8 [(11,0)] — 0 + 1 = 1 [(11,0),(8,1)] 3
9 5 [(11,0),(8,1)] — 0 + 1 = 1 — 已經是 8 > 5;那個 8 對這個 5 的死期毫無影響 [(11,0),(8,1),(5,1)] 3
10 11 [(11,0),(8,1),(5,1)] (5,1)、(8,1)、(11,0) 0 [(11,0)] 3

結果 max(steps) = 3。

現在把這些死期當成一張時程表讀回去,和題目敘述印出來的那幾輪對照:

text
idx     0  1  2  3  4  5  6  7  8  9 10
nums    5  3  4  4  7  3  6 11  8  5 11
steps   0  1  2  3  0  1  2  0  1  1  0
                                          <- every nonzero entry is a scheduled funeral

round 1  deletes steps==1  -> idx 1,5,8,9  (3, 3, 8, 5)
         [5,3,4,4,7,3,6,11,8,5,11] -> [5,4,4,7,6,11,11]

round 2  deletes steps==2  -> idx 2,6      (the first 4, and 6)
         [5,4,4,7,6,11,11] -> [5,4,7,11,11]

round 3  deletes steps==3  -> idx 3        (the second 4)
         [5,4,7,11,11] -> [5,7,11,11]      non-decreasing, stop

3 rounds  ==  max(steps)   ✓

有兩列值得停下來看,因為直覺通常就斷在這裡:

  • 第 3 列(第二個 4)。 它是因為 <= 被彈掉的,不是 <。即使左邊就有一個 5,它自己的死期也不是 1,因為第一個 4 會一直擋在中間直到第 2 輪 — 所以是 3。這一列就是彈出條件寫成嚴格 > 會算錯的那一列,也是整個答案的來源。
  • 第 9 列(8 後面的那個 5)。 堆疊上還活著四個元素,但這個 5 在第 1 輪就死了:它的左鄰居 8 本來就比它大,不需要先清掉任何東西。堆疊很深不代表死期很晚 — 死期只取決於元素和兇手之間卡著什麼。

而第 4、7、10 列展示了這個不變式的另一半:每次堆疊被清空,新進來的元素就是一個前綴最大值,死期為 0,而它後面所有的死期都已經記進 res 了 — 陣列實際上是從這個元素重新開始的。

>= 與 > 的陷阱

刪除規則只在 nums[i-1] > nums[i] 時觸發,所以相等的元素永遠不會被刪掉 — 這也代表它不可能是兇手,必須和比較小的元素一樣被彈出。把彈出條件寫成嚴格的 >,就是這題專門要抓的 bug:

text
nums = [5,3,4,4,7]
pop on >=  ->  dp = [0,1,2,3,0]  ->  3   ✓
pop on >   ->  dp = [0,1,2,1,0]  ->  2   ✗   (idx 3 wrongly adopts idx 2 as its killer)

掃描方向會翻轉這個運算子,這點值得記住:

掃描方向 彈出條件 彈出的是什麼
由左往右 nums[i] >= nums[stack[-1]](非嚴格) 殺不死我的元素 — 比較小的和相等的
由右往左 nums[i] > nums[stack[-1]](嚴格) 我吃掉的元素 — 只有嚴格比較小的

模式(Python)

python
# python
# LC 2289 - Steps to Make Array Non-decreasing
# IDEA: monotonic DECREASING stack of indices; dp[i] = round in which nums[i] dies.
#       A popped element hands its dp up: dp[i] = max(popped dp) + 1
# time = O(n), space = O(n)
def totalSteps(nums):
    n = len(nums)
    dp = [0] * n          # dp[i] = round nums[i] is removed (0 = never)
    stack = []            # indices, values monotonically DECREASING
    res = 0

    for i in range(n):
        cur = 0
        # NOTE !!! `>=` — an equal element is never deleted, so it is not a killer
        while stack and nums[i] >= nums[stack[-1]]:
            cur = max(cur, dp[stack.pop()])
        if stack:                       # a strictly greater element on the left = my killer
            dp[i] = cur + 1
            res = max(res, dp[i])
        stack.append(i)                 # else dp[i] stays 0: never removed

    return res

同一套 dp 由右往左掃 — 每個元素從自己吃掉的元素算出答案,於是「我左邊有沒有更大的元素?」這個分支就消失了:

python
# python
# LC 2289 - variant: right -> left, steps = max(steps + 1, dp[j])
# time = O(n), space = O(n)
def totalSteps_rtl(nums):
    n = len(nums)
    dp = [0] * n
    stack, res = [], 0
    for i in range(n - 1, -1, -1):
        steps = 0
        while stack and nums[i] > nums[stack[-1]]:   # strict: only what I eat
            # one more round than eaten so far, but j may itself be dying until dp[j]
            steps = max(steps + 1, dp[stack.pop()])
        dp[i] = steps
        res = max(res, steps)
        stack.append(i)
    return res

模式(Java)

java
// java
// LC 2289 - Steps to Make Array Non-decreasing
// IDEA: decreasing stack of indices; dp[i] = round nums[i] is removed.
//       dp[i] = max(dp of popped) + 1 when a strictly greater element remains on the left
// time = O(N), space = O(N)
public int totalSteps(int[] nums) {
    int n = nums.length, res = 0;
    int[] dp = new int[n];                          // dp[i] = round nums[i] dies (0 = never)
    Deque<Integer> stack = new ArrayDeque<>();      // indices, values decreasing

    for (int i = 0; i < n; i++) {
        int cur = 0;
        while (!stack.isEmpty() && nums[i] >= nums[stack.peek()]) {
            cur = Math.max(cur, dp[stack.pop()]);   // inherit the deadline
        }
        if (!stack.isEmpty()) {
            dp[i] = cur + 1;
            res = Math.max(res, dp[i]);
        }
        stack.push(i);
    }
    return res;
}

改用 (值, 步數) 的配對堆疊完全等價,還可以省掉 dp 陣列 — 推入 (num, cur) 而不是索引,彈出條件改成 stack[-1][0] <= num;上面逐步演練用的就是這種寫法。當索引沒有別的用途時就用它。

相似題目

題目 LC # 關鍵差異
Steps to Make Array Non-decreasing 2289 被彈出元素的 dp 會被繼承:dp[i] = max(被彈掉的 dp) + 1
Online Stock Span 901 同樣是「把一個值透過彈出一路帶上來」,但彙總方式是把彈掉的 span 相加,不是 max+1
Car Fleet II 1776 由右往左的堆疊,每台車的碰撞時間由被彈掉的車推導而來 — 一樣的串接,只是 dp 是實數(§2-10)
Car Fleet 853 要的是存活者的數量,不是誰在第幾輪死掉 — 所以它的堆疊從來不彈出,兩個變數就夠了(§2-10)
Count Robot Groups 4045 和 853 同一套貪婪,但沒有終點,所以沒有抵達時間可比 — 改比速度(§2-10)
Asteroid Collision 735 同樣是「大的吃掉小的」模擬;答案是存活者,所以不需要帶 dp
Minimum Cost Tree From Leaf Values 1130 一樣是比較小就彈出,但每次彈出彙總的是成本,不是輪數
Largest Rectangle in Histogram 84 當作對照:那裡的彈出算出的是最終值(面積),沒有人會去繼承它
Count Shadow Pairs I 4054 同樣是由右往左的 >= 彈出、同樣的嚴格性理由,但彈出時什麼都不帶 — 被數的是它們留下的那個窗口(§2-19)

2-19) Count Shadow Pairs I (LC 4054) — 模板 2 的變形:Next STRICTLY Smaller 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

leetcode_python/Stack/count-shadow-pairs-i.py

變化點:模板 2 的彈出條件是 >,會停在相等的值。這題的窗口必須停在下一個嚴格更小的元素,所以彈出條件要改成 >= — 而且因為配對條件 nums[i] < nums[j] 同樣是嚴格的,落在窗口裡面那些相等的值還得再扣回來。

核心想法

一組 (i, j) 會被任何嚴格落在中間、且 nums[k] < nums[i] 的 k 殺掉。這個條件只提到 nums[i] — 從來沒提 nums[j] — 所以固定 i,改問它往右最多還能伸到哪裡:

  • 令 nxt[i] = i 右邊第一個值嚴格小於 nums[i] 的索引(沒有的話就是 n);
  • 落在 (i, nxt[i]) 裡的每個 j 都滿足 nums[j] >= nums[i],所以中間不可能有人殺掉這組配對 — j 只需要再通過 nums[j] > nums[i],也就是不能相等;
  • j = nxt[i] 本身永遠配不成:它的值更小,nums[i] < nums[j] 不成立;
  • nxt[i] 之後的 j 也都配不成,因為此時 k = nxt[i] 已經嚴格落在中間了。

於是整題塌縮成每個索引一個窗口的算術:

text
pairs(i) = (nxt[i] - i - 1)  -  #{ j in (i, nxt[i]) : nums[j] == nums[i] }
            window size          the equal-value chain

而那些相等的值會串接起來,這正是讓第二項維持每個索引 O(1) 的關鍵:若 ne 是 i 右邊最近一個 nums[ne] == nums[i] 的索引,且 ne < nxt[i],那麼 nxt[ne] == nxt[i] — 值相同,中間又沒有更小的 — 所以

text
eq[i] = 1 + eq[ne]      if ne < nxt[i]
      = 0               otherwise

這就是貢獻法,只是 LC 907 求的是總和,這裡求的是個數:同一個窗口,算的是 window - equals 而不是 arr[i] * left * right。

視覺化追蹤 — nums = [6,7,6,6,7]

由右往左,所以堆疊裡永遠已經放好 i 後面的所有東西:

text
i=4  v=7   stack []        -> nxt=5  eq=0   pairs = (5-4-1) - 0 = 0
i=3  v=6   pop 7 (>= 6)    -> nxt=5  eq=0   pairs = (5-3-1) - 0 = 1   (3,4)
i=2  v=6   pop 6 (>= 6)    -> nxt=5  eq=1   pairs = (5-2-1) - 1 = 1   (2,4)
                idx 3 is equal and inside the window -> eq = 1 + eq[3] = 1
i=1  v=7   top 6 stays     -> nxt=2  eq=0   pairs = (2-1-1) - 0 = 0
i=0  v=6   pop 7, pop 6    -> nxt=5  eq=2   pairs = (5-0-1) - 2 = 2   (0,1) (0,4)
                idx 2 is equal and inside the window -> eq = 1 + eq[2] = 2
                                                              total  = 4

模板 2 沒有做的兩個修正

這兩件事其實是同一個詞 — 嚴格 — 在兩個不同的地方各讀一次,而且只做其中一個仍然會算錯:

在哪裡出問題 弄錯的話
彈出用 >=,不是 > nxt[i] 必須是第一個嚴格更小的值 用 > 彈出會停在相等的值,於是每個窗口都被縮短 — [6,7,6,6,7] 會回傳 2 而不是 4
扣掉相等值的串接 nums[i] < nums[j] 是嚴格的,而窗口只保證 >= 只算窗口大小,會把 [6,7,6,6,7] 裡的 (2,3) 也算進去,而那兩端都是 6

掃描方向決定了運算子怎麼寫,和 §2-18 完全一樣:這裡是由右往左,把所有 >= 當前值的東西都彈掉,剩在頂端的自然就是第一個嚴格更小的。

在 Java 裡答案放不進 int。 n 最大到 10^5,而嚴格遞增的陣列會讓每一組配對都成立,所以個數會達到 n(n-1)/2 = 4,999,950,000。

模式(Python)

python
# python
# LC 4054 - Count Shadow Pairs I
# IDEA: nxt[i] = next STRICTLY smaller (so the stack pops on >=); every j in
#       (i, nxt[i]) is a candidate, less the ones equal to nums[i], which chain
# time = O(n), space = O(n)
def shadowPairs(nums):
    n = len(nums)
    nxt = [n] * n            # first idx > i whose value is strictly smaller
    eq = [0] * n             # how many nums[j] == nums[i] sit inside (i, nxt[i])
    last_same = {}           # value -> the smallest idx > i seen so far
    stack = []               # indices, values increasing towards the top
    res = 0

    # right -> left, so the stack already holds everything after i
    for i in range(n - 1, -1, -1):
        while stack and nums[stack[-1]] >= nums[i]:    # NOTE: >= , not >
            stack.pop()
        nxt[i] = stack[-1] if stack else n

        ne = last_same.get(nums[i], n)
        eq[i] = 1 + eq[ne] if ne < nxt[i] else 0

        res += (nxt[i] - i - 1) - eq[i]

        stack.append(i)
        last_same[nums[i]] = i

    return res

模式(Java)

java
// java
// LC 4054 - Count Shadow Pairs I
// IDEA: the same nxt[] / eq[] pass, right -> left. NOTE the return type: the
//       count reaches ~5e9 on a strictly increasing array, so int overflows
// time = O(n), space = O(n)
public long shadowPairs(int[] nums) {
    int n = nums.length;
    int[] nxt = new int[n];                    // first idx > i with a strictly smaller value
    int[] eq = new int[n];                     // equal values sitting inside (i, nxt[i])
    Map<Integer, Integer> lastSame = new HashMap<>();
    Deque<Integer> stack = new ArrayDeque<>(); // indices, values increasing towards the top
    long res = 0;

    for (int i = n - 1; i >= 0; i--) {
        while (!stack.isEmpty() && nums[stack.peek()] >= nums[i]) stack.pop();   // >= , not >
        nxt[i] = stack.isEmpty() ? n : stack.peek();

        int ne = lastSame.getOrDefault(nums[i], n);
        eq[i] = ne < nxt[i] ? 1 + eq[ne] : 0;

        res += (nxt[i] - i - 1) - eq[i];

        stack.push(i);
        lastSame.put(nums[i], i);
    }
    return res;
}

從另一端數 — 單趟掃描,不用陣列

值得再留一個解,因為它回答的是另一個問題:不是問每個 i 能往右伸多遠,而是問每個 j 能收掉多少個還活著的候選。這樣兩個陣列都不需要了,只要每個還存活的相異值一個分組,而且是由左往右掃 — 所以彈出條件又翻回 >,因為相等的值現在正是被拿來計數的那一組,必須留著。

python
# python
# LC 4054 - Count Shadow Pairs I
# IDEA: stack of [value, freq] groups, increasing, plus `total` = sum of freqs.
#       Anything > x is dead the moment x arrives; whatever is left is <= x, so x
#       closes `total` pairs, less its own equal group (the pair test is strict)
# time = O(n), space = O(k)   k = distinct surviving values
def shadowPairs(nums):
    stack = []               # [value, freq], values strictly increasing
    total = 0                # live candidates = sum of the freqs on the stack
    cnt = 0

    for x in nums:
        while stack and stack[-1][0] > x:      # NOTE: > here — the equals must stay
            total -= stack.pop()[1]

        if stack and stack[-1][0] == x:
            cnt += total - stack[-1][1]        # skip its own group: needs a strict <
            stack[-1][1] += 1
        else:
            cnt += total                       # everything left is strictly smaller
            stack.append([x, 1])

        total += 1

    return cnt

相似題目

題目 LC # 關鍵差異
Number of Valid Subarrays 1063 同一個窗口,但不必做任何修正:它數的是第一個元素就是最小值的子陣列,所以相等的值仍然合法,nxt[i] - i 本身就是答案
Sum of Subarray Minimums 907 同一個窗口,但是加總而不是計數,而那裡的嚴格性是花在避免重複計算相等的最小值(§2-7)
Number of Visible People in a Queue 1944 同樣是數沒有被遮住的配對,但遮擋條件要讀兩端(中間的 max 必須小於 min(h[i], h[j])),所以單憑 i 無法固定窗口
Steps to Make Array Non-decreasing 2289 因為同樣的理由用同樣的 >= 彈出,但彈出時往上帶的是 dp 值,不是個數(§2-18)
Final Prices With a Special Discount 1475 反過來的鏡像:它要的本來就是 next smaller 或相等,所以那裡的 >= 是原本的語意,不是修正

2-20) 值得知道的經典堆疊題(非單調)

這些用的是普通堆疊(沒有單調不變式),但常常和上面那些模式一起出現。

題目 LC # 關鍵技巧 難度
Simplify Path 71 用 / 切開;元件推入,.. 彈出,./空字串跳過 Medium
Backspace String Compare 844 每個字串一個堆疊,或從後往前用雙指標做到 O(1) 空間 Easy
Remove All Adjacent Duplicates In String 1047 推入字元,和頂端相同就彈出 Easy
Remove All Adjacent Duplicates in String II 1209 堆疊存 (char, count),count 到 k 就彈出 Medium
Flatten Nested List Iterator 341 堆疊裡放 iterator/list;在 hasNext() 中惰性攤平 Medium
Binary Search Tree Iterator 173 受控的迭代中序 — 堆疊存左脊 Medium
Maximum Frequency Stack 895 freq map + 從頻率對應到值堆疊的 map Hard
Baseball Game 682 直接用堆疊模擬 +、D、C Easy