Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

堆疊與佇列

堆疊與佇列

從瀏覽器的上一頁到地圖最短路徑,兩個最常用的資料結構如何用 LIFO 與 FIFO 撐起整個計算世界

為什麼瀏覽器的「上一頁」永遠記得你剛剛在哪裡?

想像你在網路上連點了十幾個頁面,從首頁逛到某篇文章,再點進作者介紹,又跳到一張地圖。這時候你按下「上一頁」,瀏覽器精準地把你帶回上一個畫面;再按一次,又回到更早的那一頁。它怎麼總是「記得」你來時的路?

答案藏在一種叫做堆疊(stack) 的資料結構裡。每進入一個新頁面,瀏覽器就把它「疊」上去;每按一次上一頁,就把最上面那一個「拿」下來。最後放上去的,最先被取走。

與堆疊相對的,是日常生活中更熟悉的佇列(queue):排隊買飲料時,先到的人先買到,後到的人乖乖排在後面。這兩種看似簡單的結構,是電腦科學裡使用頻率最高的工具之一,從函數呼叫、括號檢查,到搜尋一張地圖的最短路徑,背後都有它們的身影。

演算法與資料結構概念示意圖

堆疊:後進先出(LIFO)

堆疊的核心規則只有一句話:後進先出(Last In, First Out, LIFO)。最晚放進去的元素,會最先被拿出來。你可以把它想像成一疊盤子——你只能從最上面拿,也只能往最上面放。

堆疊提供兩個基本操作:

  • push(推入):把一個元素放到堆疊頂端。
  • pop(彈出):把頂端的元素取出並移除。

通常還會附帶一個 peek(或 top) 操作,只看頂端是什麼但不取走。理想情況下,這三個操作都是 $O(1)$ 的常數時間,因為我們永遠只碰「頂端」這一個位置。

下面用文字模擬一個堆疊的變化過程。假設我們依序 push 進 A、B、C,再 pop 兩次:

push(A):  [A]              ← 頂端在右
push(B):  [A, B]
push(C):  [A, B, C]
pop():    [A, B]      回傳 C
pop():    [A]         回傳 B

注意彈出的順序是 C、B,恰好與推入順序 A、B、C 相反。這個「反轉」的特性,正是堆疊許多應用的關鍵。

佇列:先進先出(FIFO)

佇列的規則同樣只有一句話:先進先出(First In, First Out, FIFO)。最早進入的元素,最早離開。這完全對應排隊的直覺:先排的人先走。

佇列的兩個基本操作分別發生在「兩端」:

  • enqueue(入列):把元素加到佇列的尾端。
  • dequeue(出列):從佇列的前端取出並移除元素。

同樣依序加入 A、B、C,再取出兩次:

enqueue(A):  front[A]back
enqueue(B):  front[A, B]back
enqueue(C):  front[A, B, C]back
dequeue():   front[B, C]back     回傳 A
dequeue():   front[C]back        回傳 B

這次取出的順序是 A、B,與加入順序完全一致。堆疊「反轉」,佇列「保序」——這是兩者最本質的差異。

動手看一個例子:括號是否成對?

堆疊有一個非常經典的應用:檢查一段程式碼或數學式中的括號是否正確配對。例如 ([]{}) 是合法的,而 ([)] 則不是——雖然每種括號的左右數量都相等,但交錯了。

它的核心想法是:遇到左括號就 push,遇到右括號就檢查堆疊頂端是不是「對應的」左括號,若是就 pop。為什麼用堆疊?因為最近一個還沒被關閉的左括號,一定是最先需要被關閉的——這正是 LIFO。

def is_balanced(s: str) -> bool:
    """檢查字串中的括號是否正確配對"""
    pairs = {')': '(', ']': '[', '}': '{'}
    stack = []
    for ch in s:
        if ch in '([{':
            stack.append(ch)            # push 左括號
        elif ch in ')]}':
            # 堆疊為空,或頂端不匹配 → 失敗
            if not stack or stack.pop() != pairs[ch]:
                return False
    return not stack                    # 最後堆疊須為空

我們用 ([)] 走一次流程:

字元 動作 堆疊狀態 說明
( push ['('] 左括號入堆疊
[ push ['(', '['] 左括號入堆疊
) pop ['('] 取出 [,但 ) 應對 (不匹配 → 回傳 False

程式在第三步就正確判定 ([)] 不合法。同理,([]{}) 會一路順利配對到最後,堆疊清空,回傳 True。整個演算法只掃描字串一次,時間複雜度 $O(n)$,空間複雜度最壞 $O(n)$。

函數呼叫:藏在語言背後的堆疊

堆疊還有一個你每天都在用、卻看不見的應用:程式語言的呼叫堆疊(call stack)。當函數 A 呼叫函數 B、B 又呼叫 C,系統會把每一層的「現場」(區域變數、回傳位址)push 進呼叫堆疊。C 執行完畢就 pop 回 B,B 完成再 pop 回 A。

這也解釋了一個常見的錯誤訊息:堆疊溢位(stack overflow)。當遞迴沒有正確的終止條件,函數一層層呼叫自己而不返回,呼叫堆疊不斷增長,最終耗盡記憶體而崩潰。理解了堆疊,這個錯誤就不再神秘。

佇列與廣度優先搜尋(BFS)

佇列最有代表性的應用是廣度優先搜尋(Breadth-First Search, BFS)——從一個起點出發,像漣漪一樣「由近而遠」逐層探索一張圖或迷宮,常用來尋找最短路徑(以邊數計算)。

BFS 的關鍵在於:先發現的節點要先被展開。把「待探索」的節點放進佇列,每次從前端 dequeue 一個出來處理,並把它尚未拜訪過的鄰居 enqueue 到尾端。因為佇列保持先進先出,距離起點越近的節點越早被處理,自然就保證了「逐層擴散」。

from collections import deque

def bfs(graph, start):
    """從 start 出發,回傳拜訪節點的順序"""
    visited = {start}
    queue = deque([start])      # deque 適合兩端操作
    order = []
    while queue:
        node = queue.popleft()  # dequeue:取出前端
        order.append(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)   # enqueue:加到尾端
    return order

若把這裡的佇列換成堆疊(改用 push/pop),整個演算法就會變成深度優先搜尋(DFS)——一路鑽到底再回頭。光是「換一種資料結構」,搜尋的行為就徹底不同,這正是資料結構選擇的威力。

重點回顧

  • 堆疊是 LIFO(後進先出),操作為 push/pop,最晚放入的最先取出;典型應用有函數呼叫、括號匹配、運算式求值、瀏覽器上一頁。
  • 佇列是 FIFO(先進先出),操作為 enqueue/dequeue,最早放入的最先取出;典型應用有 BFS、工作排程、緩衝區(buffer)。
  • 兩者的基本操作理想上都是 $O(1)$,差別只在「從哪一端取出」。
  • 同一個演算法骨架,把堆疊換成佇列就從 DFS 變 BFS,凸顯資料結構選擇的決定性影響。
  • 遞迴失控會造成呼叫堆疊的堆疊溢位,這是堆疊概念在實務中的直接體現。

深入探討(研究所視角)

用陣列還是串列實作?兩種取捨

堆疊與佇列是抽象資料型別(Abstract Data Type, ADT)——它們定義「行為」,但不規定「實作」。實務上最常見的兩條路是動態陣列(dynamic array)鏈結串列(linked list),各有取捨。

堆疊用陣列實作相當自然:維護一個陣列與一個 top 索引,push 就是寫入 arr[top]top += 1,pop 反之。陣列的記憶體連續,快取局部性(cache locality) 良好,實際執行速度往往優於串列。代價是容量固定,滿了需要擴容(resizing)——通常採取「容量加倍」策略。單次擴容是 $O(n)$ 的複製,但因為擴容不常發生,攤還分析(amortized analysis) 顯示每次 push 的攤還成本仍是 $O(1)$。

用鏈結串列實作則天生支援動態大小,不需擴容,且最壞情況下每次操作都是嚴格 $O(1)$(適合對延遲敏感的即時系統)。但每個節點都要額外存放指標,記憶體開銷較大,且節點散落各處,快取命中率較差。

佇列用陣列實作會遇到一個額外難題:dequeue 從前端移除元素後,若把後面所有元素往前搬,單次操作就退化為 $O(n)$。若改成只移動 front 指標而不搬資料,陣列前段的空間就「浪費」掉了,front 與 rear 會不斷向右漂移,直到撞上陣列邊界——這就引出了下面的環狀佇列。

環狀佇列(circular queue)

環狀佇列用「取模」的技巧,把一維陣列在邏輯上首尾相接成一個環,讓被 dequeue 釋放出的前段空間能被後續的 enqueue 重複利用,達到固定容量下的 $O(1)$ 操作而無需搬移資料。

核心是兩個索引 frontrear,每次前進都對容量取模:

$$\text{rear} \leftarrow (\text{rear} + 1) \bmod N$$

其中 $N$ 為陣列容量。如此一來索引走到陣列末端時會自動「繞回」開頭。一個微妙的問題是:佇列為空佇列為滿時,frontrear 可能指向同一位置,難以區分。常見的兩種解法是:(1) 額外維護一個 count 計數器記錄目前元素數量;(2) 刻意「浪費」一個格子,規定當 (rear + 1) mod N == front 即視為滿,這樣空與滿就能用不同條件區分。

class CircularQueue:
    def __init__(self, capacity: int):
        self.data = [None] * capacity
        self.capacity = capacity
        self.front = 0
        self.count = 0              # 用計數器區分空與滿

    def enqueue(self, x) -> bool:
        if self.count == self.capacity:
            return False            # 已滿
        rear = (self.front + self.count) % self.capacity
        self.data[rear] = x
        self.count += 1
        return True

    def dequeue(self):
        if self.count == 0:
            return None             # 已空
        x = self.data[self.front]
        self.front = (self.front + 1) % self.capacity
        self.count -= 1
        return x

環狀佇列在系統層面無所不在:作業系統的鍵盤輸入緩衝區、網路封包的接收緩衝、串流音訊的 ring buffer、生產者—消費者(producer-consumer)模型中的有界緩衝區,幾乎都採用這個結構。它的優勢在於固定且可預測的記憶體用量——對嵌入式系統與即時系統而言至關重要。

延伸的變體與連結

從這兩個基礎結構出發,可以延伸出許多重要變體:

  • 雙端佇列(deque, double-ended queue):兩端都能 push/pop,是堆疊與佇列的超集。Python 的 collections.deque 即是高效實作,前述 BFS 範例正是用它。
  • 優先佇列(priority queue):出列順序由「優先級」而非到達順序決定,通常以堆積(heap) 實作,是 Dijkstra 最短路徑、Huffman 編碼等演算法的核心。
  • 單調堆疊/單調佇列(monotonic stack/queue):維護元素單調性,能在 $O(n)$ 時間解決「下一個更大元素」「滑動視窗最大值」等問題,是競賽與面試的高頻技巧。

理論上,堆疊與佇列也是計算理論的基石:下推自動機(pushdown automaton)正是「有限狀態機加上一個堆疊」,其辨識能力恰好對應上下文無關文法(context-free grammar)——這也是為什麼編譯器的語法剖析(parsing)離不開堆疊。從最樸素的「一疊盤子」,一路通向形式語言理論的深處,這正是基礎資料結構之美。

AI 共讀助教正在陪你讀:堆疊與佇列
嗨!我是這篇文章的共讀助教,只根據〈堆疊與佇列〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。