跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 單調堆疊與單調佇列

單調堆疊與單調佇列

讓堆疊或佇列裡的值一直保持遞增或遞減:每個元素只進出一次,就能在 O(n) 內找出「下一個比我大的」或「每個視窗的最大值」。

題目
輸入
1/25
730741752713694725766737
堆疊(底 → 頂)[0] 73

長條下方的數字是那一天的答案:再過幾天會更熱。

目前這格比較中被移除在堆疊裡

把第 0 天放上去等更熱的一天。堆疊維持「越上面越冷」。

目前工作量:1(整趟 24;暴力法 11)

亮起來的是這一步執行的程式碼
// For each day, how many days until a warmer one (0 if never).
function dailyTemperatures(temps: number[]): number[] {
const answer = new Array(temps.length).fill(0);
const stack: number[] = []; // days still waiting, temps falling
for (let i = 0; i < temps.length; i++) {
while (stack.length && temps[stack[stack.length - 1]] < temps[i]) {
const j = stack.pop()!;
answer[j] = i - j;
}
stack.push(i);
}
return answer;
}
// The maximum of every window of k consecutive values.
function slidingMax(nums: number[], k: number): number[] {
const deque = new Array<number>(Math.min(k, nums.length)); // circular buffer, O(k)
let head = 0, size = 0;
const at = (offset: number) => deque[(head + offset) % deque.length];
const maxima: number[] = [];
for (let i = 0; i < nums.length; i++) {
if (size && at(0) <= i - k) {
head = (head + 1) % deque.length; size--;
}
while (size && nums[at(size - 1)] <= nums[i]) {
size--;
}
deque[(head + size) % deque.length] = i; size++;
if (i >= k - 1) maxima.push(nums[at(0)]);
}
return maxima;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
  • 視窗大小 k 須是正整數;環形 deque 輔助空間 O(k),輸出另佔 O(n)。錄下每步的陣列快照會增加時間與記憶體。

什麼時候用

  • 題目問「每個元素左邊或右邊第一個比它大(或小)的是誰」:下一個更熱的一天、下一個更高的股價、柱狀圖裡每根柱子能往兩邊延伸多遠。看到這種描述就想單調堆疊。
  • 固定長度的視窗一路滑過去,每個位置都要最大值或最小值:用單調佇列。視窗長度會變、但只往右縮放的題目(例如動態規劃的轉移)也適用。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
每日溫度(單調堆疊)
每天最多進堆疊一次、出堆疊一次
O(n)O(n)
滑動視窗最大值(單調佇列)
跟視窗大小 k 無關
O(n)O(n)
暴力法O(n²) / O(nk)O(n²) / O(nk)

空間:O(n),堆疊最多放 n 個;佇列最多放 k 個

Big O 實測:n 變大時步數怎麼長

數的是:總工作量(比較、放入、取出的次數)

Big On = 250n = 500n = 1,000n = 2,000成長倍數:實測(理論)
每日溫度:單調堆疊O(n)4999991,9993,999×8.0 (×8.0)
每日溫度:往後逐天找O(n²)31,125124,750499,5001,999,000×64 (×64)
滑動視窗:單調佇列O(n)9671,9563,9377,910×8.2 (×8.0)
滑動視窗:每個視窗重掃O(n²)5,42422,09989,199358,399×66 (×64)

視窗 k = n / 10,所以重掃的 O(nk) 在這裡就是 O(n²)。

和其他做法比

n = 250n = 500n = 1,000n = 2,000
每日溫度:單調堆疊4999991,9993,999
每日溫度:往後逐天找31,125124,750499,5001,999,000
滑動視窗:單調佇列9671,9563,9377,910
滑動視窗:每個視窗重掃5,42422,09989,199358,399

每日溫度用一路下降的溫度(暴力法最壞的情況:沒有一天等得到更熱的);滑動視窗用隨機數值、視窗 k = n / 10。堆疊和佇列裡每個索引最多進出各一次,所以 n 從 250 到 2,000 時工作量大約跟著翻 8 倍;暴力法則翻了 64 倍。

真實世界裡的它

  • 股票的「股價跨度」(往前連續幾天都不比今天高)、柱狀圖最大矩形、接雨水。
  • 監控系統的「過去 5 分鐘最高延遲」:資料一筆筆進來,用單調佇列隨時拿到視窗最大值。

取捨與陷阱

  • 堆疊裡要放索引而不是值:答案通常要距離(i − j),而且相同的值需要分得出是哪一天。
  • 等號決定相同值的去留:每日溫度要「嚴格更熱」才彈出(<);滑動視窗則是 <= 就丟,讓佇列裡保留最新的那個。寫反了,相同值的輸入就會出錯。
  • 看起來是兩層迴圈不代表 O(n²):要用「每個元素最多進出一次」來算總量(攤銷分析),而不是用內層迴圈的最壞次數乘以外層。

LeetCode 練習