堆疊與佇列 — Stack、Queue、Deque 完整教學與實作 | 資料結構與演算法

2026/06/17
堆疊與佇列 — Stack、Queue、Deque 完整教學與實作 | 資料結構與演算法

堆疊(Stack)佇列(Queue) 是兩種最基礎的線性資料結構,也是面試中必考的核心概念。前者遵循 LIFO(後進先出)原則,後者遵循 FIFO(先進先出)原則。從瀏覽器的上一頁按鈕、函式呼叫堆疊,到作業系統的任務排程、Kafka 訊息佇列,這兩種結構無所不在。

前言

想像你正在洗碗:你把碗一個個疊起來,洗的時候也從最上面開始拿——這就是 Stack(堆疊),後放的先取,即 LIFO。

再想像你在便利商店排隊結帳:先到的人先結帳,後到的人排在後面等——這就是 Queue(佇列),先進的先出,即 FIFO。

這兩個看似簡單的比喻,卻支撐著程式世界中無數重要的機制:

  • Stack 驅動函式呼叫、括號匹配、瀏覽器歷史、DFS 遍歷、撤銷(Undo)功能
  • Queue 驅動 BFS 廣度優先搜尋、作業系統行程排程、訊息佇列(Kafka/RabbitMQ)、列印任務

本篇文章涵蓋的學習要點:

  1. Stack 與 Queue 的核心差異 — LIFO vs FIFO 原理與圖解
  2. Deque 雙端佇列 — 結合兩者特性的萬用工具
  3. JavaScript/TypeScript 完整實作 — 從基礎到進階(MinStack、環形佇列、單調棧)
  4. C++ STL 對照std::stackstd::queuestd::deque
  5. 單調棧與單調佇列 — 解鎖 O(n) 滑動視窗最大值、Next Greater Element
  6. 面試必考題型 — LeetCode 20、155、232、239、84 詳解

一、核心概念

LIFO vs FIFO 原理對比

特性Stack(堆疊)Queue(佇列)
原則LIFO — 後進先出FIFO — 先進先出
生活類比疊盤子、書堆排隊買票
插入端頂端(top)尾端(rear/back)
刪除端頂端(top)前端(front)
主要應用函式呼叫堆疊、括號匹配、DFSBFS、任務排程、訊息佇列
JS 原生支援Array.push / Array.pop需自行實作(shift() 有效能問題)

用 ASCII 圖示幫助理解兩者的操作方向:

Stack(LIFO)— 垂直堆疊,從頂端存取:
  push(3)    push(5)    pop()
  --------   --------   --------
  |      |   |  [5]  |   |      |  <-- 5 被移除
  |  [3] |   |  [3]  |   |  [3] |
  |______|   |______|   |______|
    top=3      top=5      top=3

Queue(FIFO)— 水平管道,從前端取出:
  enqueue(A)       enqueue(B)        dequeue()
  front->[A]<-back  front->[A][B]<-back  front->[B]<-back
                                         A 被移除

Deque(雙端佇列)

Deque(Double-ended Queue) 結合了 Stack 與 Queue 的特性,允許在兩端進行 O(1) 的插入與刪除操作。它是實作**單調棧(Monotonic Stack)單調佇列(Monotonic Queue)**的核心基礎。

Deque(雙端操作):
  pushFront(X) --- [X][A][B][C] --- pushBack(Y) → [X][A][B][C][Y]
  popFront()   ← 從前端移除            popBack() → 從後端移除

Priority Queue(優先佇列)簡介

優先佇列(Priority Queue) 讓出隊順序由「優先級」決定,而非插入順序。底層通常以二元堆積(Binary Heap) 實作,支援 O(log n) 的插入與取最值操作。C++ 的 std::priority_queue、Java 的 PriorityQueue 均屬此類。本系列將在後續「堆積」章節深入介紹。


二、JavaScript / TypeScript 實作

Stack — 陣列實作

JavaScript 的 Array 天生支援 pushpop,Stack 的實作極為直觀:

class Stack<T> {
  private data: T[] = [];

  // 推入元素到頂端 — O(1) amortized
  push(item: T): void {
    this.data.push(item);
  }

  // 取出頂端元素 — O(1)
  pop(): T | undefined {
    return this.data.pop();
  }

  // 查看頂端元素但不移除 — O(1)
  peek(): T | undefined {
    return this.data[this.data.length - 1];
  }

  isEmpty(): boolean {
    return this.data.length === 0;
  }

  size(): number {
    return this.data.length;
  }
}

// 使用範例
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop());   // 輸出:3(LIFO,最後進的最先出)
console.log(stack.peek());  // 輸出:2(只查看,不移除)
console.log(stack.size());  // 輸出:1

Queue — 效能陷阱與正確實作

JavaScript 沒有內建的 Queue。初學者常用 Array.shift() 模擬出隊,但這有嚴重的效能問題:

// 效能陷阱版本:shift() 每次需要將所有元素向前移動,時間複雜度 O(n)
class NaiveQueue<T> {
  private data: T[] = [];

  enqueue(item: T): void {
    this.data.push(item);    // O(1) amortized
  }

  dequeue(): T | undefined {
    return this.data.shift(); // O(n)!不適合大量操作
  }
}

正確做法是使用 head 指標 追蹤隊列頭部,避免元素移位:

// 正確版本:head 指標讓 dequeue 達到 O(1)
class Queue<T> {
  private data: T[] = [];
  private head = 0; // 指向第一個有效元素的索引

  enqueue(item: T): void {
    this.data.push(item); // O(1) amortized
  }

  dequeue(): T | undefined {
    if (this.isEmpty()) return undefined;
    const item = this.data[this.head];
    this.head++;
    // 定期清理已消費的空間,防止記憶體洩漏
    if (this.head > 1000 && this.head > this.data.length / 2) {
      this.data = this.data.slice(this.head);
      this.head = 0;
    }
    return item;
  }

  peek(): T | undefined {
    return this.data[this.head];
  }

  isEmpty(): boolean {
    return this.head >= this.data.length;
  }

  size(): number {
    return this.data.length - this.head;
  }
}

// 使用範例
const queue = new Queue<string>();
queue.enqueue("Alice");
queue.enqueue("Bob");
queue.enqueue("Carol");
console.log(queue.dequeue()); // 輸出:Alice(FIFO,先進先出)
console.log(queue.peek());    // 輸出:Bob
console.log(queue.size());    // 輸出:2

Deque — 雙端佇列實作

// 注意:Array.unshift() 與 Array.shift() 均為 O(n)
// 以下為展示用途;生產環境建議以雙向鏈結串列實作兩端 O(1) 操作
class Deque<T> {
  private data: T[] = [];

  // 從前端插入 — Array 版本為 O(n)(展示用)
  pushFront(item: T): void {
    this.data.unshift(item);
  }

  // 從後端插入 — O(1) amortized
  pushBack(item: T): void {
    this.data.push(item);
  }

  // 從前端移除 — Array 版本為 O(n)(展示用)
  popFront(): T | undefined {
    return this.data.shift();
  }

  // 從後端移除 — O(1)
  popBack(): T | undefined {
    return this.data.pop();
  }

  peekFront(): T | undefined {
    return this.data[0];
  }

  peekBack(): T | undefined {
    return this.data[this.data.length - 1];
  }

  isEmpty(): boolean {
    return this.data.length === 0;
  }

  size(): number {
    return this.data.length;
  }
}

最小堆疊(MinStack)

最小堆疊在一般堆疊的基礎上,能以 O(1) 時間回傳目前堆疊中的最小值。關鍵在於使用一個同步輔助棧,記錄每個時刻的最小值快照:

// LeetCode 155 — Min Stack
// 輔助棧的大小與主棧同步:每次 push 都同步記錄當下的最小值
class MinStack {
  private data: number[] = [];
  private minData: number[] = []; // 輔助棧:記錄對應時刻的最小值

  push(val: number): void {
    this.data.push(val);
    // 輔助棧:將當前最小值快照壓入
    const currentMin = this.minData.length === 0
      ? val
      : Math.min(val, this.minData[this.minData.length - 1]);
    this.minData.push(currentMin);
  }

  pop(): void {
    this.data.pop();
    this.minData.pop(); // 兩棧同步彈出
  }

  top(): number {
    return this.data[this.data.length - 1];
  }

  // O(1) 取得當前最小值
  getMin(): number {
    return this.minData[this.minData.length - 1];
  }
}

// 操作範例
const minStack = new MinStack();
minStack.push(5);
minStack.push(3);
minStack.push(7);
console.log(minStack.getMin()); // 輸出:3
minStack.pop();                 // 移除 7
console.log(minStack.getMin()); // 輸出:3
minStack.pop();                 // 移除 3
console.log(minStack.getMin()); // 輸出:5(正確!因為輔助棧記錄了快照)

單調棧(Monotonic Stack)

單調棧 是一種保持棧內元素單調遞增或遞減的特殊堆疊,能在 O(n) 時間內解決「每個元素的下一個更大/更小值」類問題。

核心思路:從左到右掃描,維護一個單調遞減棧(棧底最大)。當新元素比棧頂大時,棧頂元素找到了它的「下一個更大值」,此時彈出並記錄答案。

// 通用單調棧模板 — 找每個元素右側第一個更大值
function nextGreaterElements(nums: number[]): number[] {
  const n = nums.length;
  const result = new Array(n).fill(-1);
  const stack: number[] = []; // 儲存索引(重要:存索引才能計算距離)

  for (let i = 0; i < n; i++) {
    // 當前元素比棧頂元素大:棧頂找到了它的「下一個更大值」
    while (stack.length > 0 && nums[stack[stack.length - 1]] < nums[i]) {
      const idx = stack.pop()!;
      result[idx] = nums[i]; // 記錄答案
    }
    stack.push(i); // 壓入當前索引
  }
  // 棧中剩餘元素:右側無更大值,result 保持 -1

  return result;
}

// 範例:nums = [2, 1, 5, 3, 6]
// 輸出:       [5, 5, 6, 6, -1]

三、C++ STL 對照

C++ 標準庫提供了完整的 Stack、Queue 與 Deque 容器,是競程與系統程式設計的標準工具:

#include <stack>
#include <queue>
#include <deque>
#include <iostream>

void cppContainersDemo() {
  // std::stack — 預設底層為 std::deque,可明確指定為 vector
  std::stack<int, std::vector<int>> stk; // 效能敏感時指定 vector 底層
  stk.push(1);
  stk.push(2);
  stk.push(3);
  std::cout << stk.top() << "\n"; // 輸出:3
  stk.pop();
  std::cout << stk.top() << "\n"; // 輸出:2

  // std::queue — 底層為 std::deque,front()/back() 查看兩端
  std::queue<int> que;
  que.push(1);
  que.push(2);
  que.push(3);
  std::cout << que.front() << "\n"; // 輸出:1(先進)
  que.pop();
  std::cout << que.front() << "\n"; // 輸出:2

  // std::deque — 兩端操作均為 O(1),支援隨機存取
  std::deque<int> deq;
  deq.push_front(0);
  deq.push_back(1);
  deq.push_front(-1);
  // deq: [-1, 0, 1]
  std::cout << deq.front() << " " << deq.back() << "\n"; // 輸出:-1 1
  deq.pop_front();
  deq.pop_back();
  std::cout << deq.front() << "\n"; // 輸出:0
}

C++ 與 JavaScript 的主要差異:

操作C++JavaScript/TypeScript
Stack pushstk.push(x)arr.push(x)
Stack popstk.pop()arr.pop()
Stack peekstk.top()arr[arr.length-1]
Queue enqueueque.push(x)自行實作
Queue dequeueque.pop()自行實作(避免 shift()
Deque front pushdeq.push_front(x)arr.unshift(x)(O(n)!)

四、複雜度分析

操作StackQueue(head 指標版)Deque(鏈結串列底層)備註
push / enqueueO(1) amortizedO(1) amortizedO(1)陣列動態擴容時偶發 O(n)
pop / dequeueO(1)O(1)O(1)
peek / frontO(1)O(1)O(1)僅讀取,不移除
isEmptyO(1)O(1)O(1)
sizeO(1)O(1)O(1)需維護計數器
Array shift() dequeueO(n)陷阱! 避免使用
MinStack getMinO(1)輔助棧空間換時間

五、變體與延伸

單調佇列(Monotonic Queue)— 滑動視窗最大值

單調佇列 使用 Deque 維護滑動視窗內的最大(或最小)值,讓每次滑動的查詢達到 O(1) 攤銷時間:

// LeetCode 239 — Sliding Window Maximum
// 維護一個單調遞減 Deque(front 永遠是當前視窗最大值的索引)
function maxSlidingWindow(nums: number[], k: number): number[] {
  const result: number[] = [];
  const deque: number[] = []; // 儲存索引

  for (let i = 0; i < nums.length; i++) {
    // 步驟一:移除超出視窗範圍的舊索引(從 front 移除)
    while (deque.length > 0 && deque[0] < i - k + 1) {
      deque.shift();
    }

    // 步驟二:維護單調遞減 — 移除比當前元素小的索引(從 back 移除)
    // 這些被移除的元素永遠不可能成為視窗最大值
    while (deque.length > 0 && nums[deque[deque.length - 1]] < nums[i]) {
      deque.pop();
    }

    // 步驟三:加入當前索引
    deque.push(i);

    // 步驟四:視窗大小達到 k 後,front 即為當前視窗最大值
    if (i >= k - 1) {
      result.push(nums[deque[0]]);
    }
  }

  return result;
}
// 時間:O(n)(每個元素至多入隊一次、出隊一次)
// 空間:O(k)

// 範例:nums = [1,3,-1,-3,5,3,6,7], k = 3
// 輸出:       [3,3,5,5,6,7]

雙 Stack 實作 Queue

用兩個 Stack 模擬 Queue 的 FIFO 行為:pushStack 負責接收新元素,popStack 負責提供元素。當 popStack 為空時,才將 pushStack 的全部元素轉移(每個元素只轉移一次,攤銷後 pop 為 O(1)):

// LeetCode 232 — Implement Queue using Stacks
class MyQueue {
  private pushStack: number[] = []; // 接收新元素
  private popStack: number[] = [];  // 提供元素(已反轉順序)

  push(x: number): void {
    this.pushStack.push(x);
  }

  pop(): number {
    this.migrate();
    return this.popStack.pop()!;
  }

  peek(): number {
    this.migrate();
    return this.popStack[this.popStack.length - 1];
  }

  empty(): boolean {
    return this.pushStack.length === 0 && this.popStack.length === 0;
  }

  // 只有 popStack 為空時才轉移,保證每個元素只移動一次
  private migrate(): void {
    if (this.popStack.length === 0) {
      while (this.pushStack.length > 0) {
        this.popStack.push(this.pushStack.pop()!);
      }
    }
  }
}
// 攤銷複雜度:push O(1),pop/peek O(1) amortized,empty O(1)

// 操作示意:
// push(1), push(2), push(3)   → pushStack: [1,2,3], popStack: []
// peek()                      → migrate → pushStack: [], popStack: [3,2,1]
//                             → popStack.peek() = 1(正確 FIFO!)
// pop()                       → 移除 1,回傳 1

六、面試考點

括號匹配(LeetCode 20 — Valid Parentheses)

括號匹配是 Stack 最經典的應用。遇到左括號時推入棧,遇到右括號時驗證棧頂是否為對應的左括號:

// LeetCode 20 — Valid Parentheses(Easy)
function isValid(s: string): boolean {
  const stack: string[] = [];
  const pairs: Record<string, string> = {
    ')': '(',
    ']': '[',
    '}': '{'
  };

  for (const char of s) {
    if ('([{'.includes(char)) {
      stack.push(char);         // 左括號:推入棧
    } else {
      // 右括號:棧頂必須是對應的左括號,且棧不能為空
      if (stack.length === 0 || stack[stack.length - 1] !== pairs[char]) {
        return false;
      }
      stack.pop();
    }
  }

  return stack.length === 0; // 棧清空代表完全匹配
}

// 測試:
console.log(isValid("()[]{}"));   // 輸出:true
console.log(isValid("([)]"));     // 輸出:false
console.log(isValid("{[]}"));     // 輸出:true

延伸思路:相同的 Stack 邏輯可應用於 HTML 標籤匹配、程式碼 Linter 的巢狀作用域追蹤,任何「需要匹配對應關係」的問題都可優先考慮 Stack。


七、LeetCode 練習題

LeetCode 20 — Valid Parentheses(Easy)

見上方括號匹配範例,Stack 的入門必練題。

LeetCode 155 — Min Stack(Medium)

見上方 MinStack 實作,核心是同步輔助棧記錄最小值快照。

LeetCode 232 — Implement Queue using Stacks(Easy)

見上方雙 Stack 實作,理解攤銷分析的好題目。

LeetCode 239 — Sliding Window Maximum(Hard)

見上方單調佇列實作,單調 Deque 的代表題。

LeetCode 84 — Largest Rectangle in Histogram(Hard)

思路:對每個柱子,找左右兩側第一個比它矮的柱子,以確定最大矩形的寬度。使用單調遞增棧(棧底最小),當新柱子比棧頂矮時,棧頂柱子確定了右邊界,同時新的棧頂是左邊界。在首尾各插入高度 0 的哨兵柱,能簡化邊界處理:

// LeetCode 84 — Largest Rectangle in Histogram(Hard)
function largestRectangleInHistogram(heights: number[]): number {
  // 加入哨兵:左右各一根高度為 0 的柱子,確保所有柱子都能被彈出並計算
  const h = [0, ...heights, 0];
  const stack: number[] = []; // 單調遞增棧,儲存索引
  let maxArea = 0;

  for (let i = 0; i < h.length; i++) {
    // 當前柱子比棧頂矮:棧頂柱子確定了右邊界
    while (stack.length > 0 && h[stack[stack.length - 1]] > h[i]) {
      const height = h[stack.pop()!];
      // 彈出後,新棧頂是左邊界;當前 i 是右邊界
      const width = i - stack[stack.length - 1] - 1;
      maxArea = Math.max(maxArea, height * width);
    }
    stack.push(i);
  }

  return maxArea;
}
// 時間:O(n),空間:O(n)

// 範例說明:heights = [2, 1, 5, 6, 2, 3]
// 加哨兵:  [0, 2, 1, 5, 6, 2, 3, 0]
// 最大矩形:高度 5,寬度 2 → 面積 10

console.log(largestRectangleInHistogram([2, 1, 5, 6, 2, 3])); // 輸出:10

C++ 解法:

// C++ 解法 — Largest Rectangle in Histogram
#include <vector>
#include <stack>

int largestRectangleArea(std::vector<int> heights) {
  // 加入哨兵
  heights.insert(heights.begin(), 0);
  heights.push_back(0);

  std::stack<int> stk;
  int maxArea = 0;

  for (int i = 0; i < (int)heights.size(); ++i) {
    while (!stk.empty() && heights[stk.top()] > heights[i]) {
      int height = heights[stk.top()];
      stk.pop();
      int width = i - stk.top() - 1;
      maxArea = std::max(maxArea, height * width);
    }
    stk.push(i);
  }

  return maxArea;
}
// 時間:O(n),空間:O(n)

八、常見陷阱整理

在實作 Stack/Queue 相關題目時,以下是最容易犯的錯誤,務必牢記:

陷阱一:用 Array.shift() 模擬 dequeue

  • shift() 需要移動所有元素,時間複雜度為 O(n),資料量大時效能會急速下降
  • 改用 head 指標或鏈結串列實作

陷阱二:單調棧只儲存值而非索引

  • 解決「寬度計算」類問題(如柱狀圖最大矩形)時,必須儲存索引才能計算距離
  • 規則:需要計算距離或位置時,棧存索引;只需要比較大小時,棧可存值

陷阱三:滑動視窗 Deque 忘記移除超出範圍的舊索引

  • 單調佇列必須同時維護兩件事:①從 back 移除比當前元素小的索引;②從 front 移除超出視窗的索引
  • 漏掉任一步都會導致錯誤結果

陷阱四:MinStack 只記錄一個全域最小值

  • 當最小值被 pop 之後,無法得知次小值
  • 必須使用同步輔助棧,記錄每個時刻的最小值快照

陷阱五:雙 Stack 模擬 Queue 時,每次 pop 都強制 migrate

  • 只有在 popStack 為空時才能 migrate;若 popStack 仍有元素就轉移,會破壞 FIFO 順序

總結

本文從生活類比出發,系統介紹了 Stack(堆疊)Queue(佇列) 的核心差異、JavaScript/TypeScript 完整實作,以及 C++ STL 的對應容器。更進一步地,我們探討了:

  • Deque(雙端佇列):一個結合 Stack 與 Queue 特性的萬用工具
  • MinStack:以輔助棧換取 O(1) 查詢最小值的巧妙設計
  • 雙 Stack 模擬 Queue:理解攤銷分析的經典習題
  • 單調棧與單調佇列:解鎖 O(n) 解法的進階技巧,應用於 Next Greater Element、柱狀圖最大矩形、滑動視窗最大值等 Hard 題型

這兩種資料結構看似簡單,但在面試中以各種形式出現。真正的關鍵不是背誦程式碼,而是理解「什麼時候用 Stack,什麼時候用 Queue」——前者適合「需要反向處理、追蹤最近狀態」的場景,後者適合「按順序處理、保持到達順序」的場景。

下一篇將進入**雜湊表(Hash Table)**的世界,探索 JavaScript 的 MapSet 底層原理,以及如何用 O(1) 查找解決兩數之和、字母異位詞等常見面試題。如有任何問題或疑惑,歡迎透過 聯絡頁面 與我交流!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。