堆疊佇列與雙端佇列

cppds Chapter 3 — Linear Structures(對應講義 05)
LIFO|FIFO|括號配對|進位轉換|中序→後序|模擬|迴文
向下捲動開始互動
CONTENTS · 內容目錄
PROLOGUE · 開場

線性結構:資料的「排隊學」 cppds §3.1–3.2

堆疊(stack)、佇列(queue)、雙端佇列(deque)都是線性結構:元素之間只有「前後」關係, 新增與移除發生在端點。差別只在「哪一端進、哪一端出」:這個小差別決定了它們完全不同的用途。

本章你會學到的技術 1. 三種 ADT:stack(LIFO)、queue(FIFO)、deque(兩端皆可)。
2. C++ 實作:vector 自己刻,並理解每個操作的成本。
3. 經典應用:括號配對、進位轉換、中序轉後序、約瑟夫問題、模擬、迴文。
Stack:同一端進出(LIFO)
Queue:尾進頭出(FIFO)
按下方按鈕,把同一串資料 4→7→2 分別放進 stack 與 queue,再全部取出,觀察順序差異。
一句話記住
Stack:後進先出:復原(undo)、呼叫堆疊、括號配對
Queue:先進先出:排隊、工作佇列、BFS
Deque:兩端皆可:滑動視窗、迴文檢查
為什麼叫「線性」?
元素按加入順序有唯一的前後位置; 操作只在端點發生。不同結構 = 不同的「進出端」組合。
PART 01 · 堆疊

Stack:後進先出的「盤子塔」 cppds §3.3–3.5

堆疊只在同一端(top)加入與移除。想像疊盤子:最後放上去的盤子一定最先被拿走 : LIFO(last-in first-out)。它天生就是「反轉順序」的機器。

先把 ADT 說清楚

Stack ADT 有六個操作:push(item)pop()(移除並回傳 top)、 peek()(只看不拿)、isEmpty()size(),加上建構子。 講義用一張操作序列表把行為完全定死(top 在最右邊):

操作Stack 內容(top 在右)回傳
s.isEmpty()[]true
s.push(4)[4]
s.push("dog")[4, dog]
s.peek()[4, dog]dog
s.push(true)[4, dog, true]
s.size()[4, dog, true]3
s.push(8.4)[4, dog, true, 8.4]
s.pop()[4, dog, true]8.4
s.pop()[4, dog]true

表裡混放了數字、字串、布林,這是 ADT 層次的示意。 實作成 C++ template 之後,一個 Stack 物件只裝一種型別:Stack<double> 裝 double、 Stack<string> 裝字串。

輸入值後按 push;pop 會移除 top。照上面那張表操作一輪試試。
C++ 實作 CODE
class Stack { // dscpp/stack.hpp(vector 尾端 = top) vector<T> items; bool isEmpty() { return items.empty(); } void push(T item) { items.push_back(item); } T pop() { T top = items.back(); items.pop_back(); return top; } T peek() { return items.back(); } int size() { return items.size(); } };
操作成本(vector 尾端當 top)
push / pop / peekO(1)*
size / isEmptyO(1)
* push 是攤銷 O(1)。

同一個 ADT、另一種實作:Stack2 的代價

把 top 改放在 vector 的前端也做得出一個功能完全正確的 stack: push 用 insert(begin())、pop 用 erase(begin())。 介面一模一樣,使用者根本分不出來。這就是抽象化的威力,也是它的陷阱: 每次 push 和 pop 都要把整排元素搬一格,O(1) 變 O(n)。

數字會說話:對兩種實作各做 n 次 push,數一數總共搬移了幾個元素。
Stack2(top 在前端)CODE
void push(T item) { items.insert(items.begin(), item); // O(n)! } T pop() { T top = items.front(); items.erase(items.begin()); // O(n)! return top; }
邏輯等價、效能天差地遠。這個例子會在第 2 章(演算法分析)被正式量化。
QUIZ · Stack 行為

依序執行 s.push(4); s.push(7); s.pop(); s.push(2); s.peek(); 之後,peek() 回傳什麼?stack 內容(由底至頂)是什麼?

(A) 回傳 2;內容 [4, 2]
(B) 回傳 7;內容 [4, 7, 2]
(C) 回傳 4;內容 [2, 4]

講義練習:用 stack 反轉字串

講義在這裡留了一題:寫一個 revString(myStr),用 stack 把字串反過來。 期待輸出:revString("NSYSU") 得到 "USYSN"。 原題挖了兩個空(push 迴圈與 pop 拼接),完整解答如下:

revString:進去一趟就反了 CODE
string revString(string myStr) { stack<char> s; for (char ch : myStr) s.push(ch); // ① 逐字推上去 string rStr = ""; while (!s.empty()) { rStr = rStr + s.top(); // ② 彈出順序 = 反序 s.pop(); } return rStr; }
QUIZ · 為什麼 stack 天生會反轉?

revString 只是「全部 push、再全部 pop」就完成反轉。背後的原因是?

(A) LIFO:最後進去的最先出來,順序自然顛倒
(B) stack 內部把字串排序了
(C) top() 會從兩端輪流取
PART 02 · 括號配對

括號配對:stack 的第一個殺手級應用 cppds §3.6–3.7 · 講義補充

課堂投影片跳過這兩節(RISE skip):屬自學補充, 但它是 stack 最經典的應用,編譯器每天都在做這件事,建議別跳。

編譯器怎麼知道 (() 少了一個右括號?由內往外配對的結構剛好是 LIFO: 每個右括號要配的是「最近一個還沒配對的左括號」:這就是 stack 的 top。

符號 stack
輸入符號字串後按「開始」。開符號 push、閉符號 pop 配對。
虛擬碼 CODE
parChecker(symbolString): Stack<char> s for (char symbol : symbolString): if symbol 是開符號 '(' '[' '{': s.push(symbol) else: // 閉符號 if s.isEmpty(): return false if !matches(s.pop(), symbol): return false return s.isEmpty() // 有剩 = 沒配完
三種失敗方式
① 閉符號來了但 stack 空(())( 的第 3 步)。
② pop 出來的開符號種類不合[))。
③ 掃完字串 stack 還有剩((())。

一般化:三種括號混用

實際的程式語言同時用 ()[]{}, 各自要維持自己的開閉關係。像 { { ( [ ] [ ] ) } ( ) } 是平衡的, ( [ ) ] 就不是:每個閉符號要配的不只是「某個開符號」,而是 stack 頂端那一個、而且種類要對。演算法只多一件事:pop 出來後用 matches() 檢查種類。

bool matches(char symLeft, char symRight) { string allLefts = "([{{"; string allRights = ")]}}"; return allLefts.find(symLeft) == allRights.find(symRight); } // 開閉符號在各自字串裡的位置相同,就是一對

完整的 balanceChecker() 在講義 §3.7: 跟 parChecker 只差在 pop 之後多呼叫一次 matches()。上面的動畫輸入框直接支援三種括號, 拿 [{()] 餵餵看。

PART 03 · 進位轉換

十進位 → 任意進位:「除基取餘」與順序反轉 cppds §3.8

反覆「除以基底、記下餘數」會由低位往高位產生數字,但我們書寫時要從高位開始: 又是一個「產生順序與使用順序相反」的問題,stack 出場。

餘數 stack
選數字與基底,按「開始」逐步觀察:先除到 0,再 pop 出答案。
虛擬碼 CODE
baseConverter(decNumber, base): digits = "0123456789ABCDEF" Stack<int> remStack while decNumber > 0: remStack.push(decNumber % base) decNumber = decNumber / base // 整數除法 newString = "" while !remStack.isEmpty(): newString += digits[remStack.pop()] return newString
為什麼是 stack?
第一個算出的餘數是最低位,最後算出的是最高位。 pop 的順序剛好把它反過來:高位先出。
QUIZ · 進位轉換

baseConverter(25, 2) 的結果是?(可用上面的動畫驗證)

(A) 11001
(B) 10011
(C) 11010
PART 04 · 表達式

中序、前序、後序:把括號「編譯」掉 cppds §3.9

A + B * C 的時候,誰先算?人靠兩條默契:優先權(乘除高於加減) 和結合律(同級由左到右)。括號則能推翻默契。這種「運算子夾在中間」的寫法叫 中序(infix),對人友善,對機器是負擔:電腦得先讀懂整套默契才知道順序。

把運算子搬到運算元前面就是前序(prefix),搬到後面就是後序 (postfix)。神奇的是,搬完之後括號和優先權表都不需要了,順序資訊全部藏在位置裡:

中序前序後序
A + B+ A BA B +
A + B * C+ A * B CA B C * +
(A + B) * C* + A B CA B + C *
A + B * C + D+ + A * B C DA B C * + D +
(A + B) * (C + D)* + A B + C DA B + C D + *
A + B + C + D+ + + A B C DA B + C + D +
手算技巧:全括號法 先把式子完全加括號(一個運算子一對括號):A + B * C 變成 (A + (B * C))。 然後把每個運算子搬到它那對括號的右括號位置、拆掉括號,就是後序; 搬到左括號位置就是前序。考試手算用這招又快又不會錯。

機器轉換用 stack:運算元照抄、運算子進 stack 等待。 關鍵觀察是後序式裡運算子的出現順序,可能跟中序相反(+ 先讀到卻最後輸出), 「需要反轉的暫存」正是 stack 的主場。演算法四步:

  1. 開一個空的 opStack、一個空的輸出串列。
  2. 把中序字串切成 token(C++ 用 stringstream 逐個抽出來)。
  3. 由左到右掃:運算元直接輸出;( 進 stack;) 一路 pop 輸出直到遇見 (; 一般運算子先把 stack 上「優先權大於等於自己」的全部 pop 輸出,再把自己 push 進去。
  4. 掃完把 stack 剩下的全部 pop 輸出。
infix tokens:
opStack
postfix 輸出:
按「開始」把中序轉成後序。
postfix 求值(operand stack):
operandStack
用 7 8 + 3 2 + / 走一次後序求值。
infix → postfix CODE
infixToPostfix(infixExpr): Stack<string> opStack; postfixList = [] for (token : infixExpr): if token 是運算元: postfixList.push_back(token) else if token == "(": opStack.push(token) else if token == ")": pop 並輸出,直到遇見 "(" else: // 運算子 while !opStack.isEmpty() && prec[opStack.peek()] >= prec[token]: 輸出 pop opStack.push(token)
postfix 求值 CODE
postfixEval(postfixExpr): Stack<int> operandStack for (token : postfixExpr): if token 是數字: operandStack.push(toInt(token)) else: right = operandStack.pop(); left = operandStack.pop() operandStack.push(doMath(token, left, right)) return operandStack.pop()
優先權(prec)
^4(右結合)
* /3
+ −2
(1(最低)
後序求值的一個細節:pop 的順序 看到運算子就 pop 兩次:第一次 pop 出來的是右運算元、第二次才是左運算元。 加法乘法無所謂,除法減法就是生死線:7 8 + 3 2 + / 是 15 / 5 = 3, 順序寫反會變成 5 / 15。輔助函式 doMath(op, op1, op2) 把四則收在一處, 完整程式在 dscpp/expression.hpp。
QUIZ · 中序轉後序

( A + B ) * C 的後序表達式是?

(A) A B + C *
(B) A B C + *
(C) + A B * C
QUIZ · 講義練習:加入次方運算子

要讓轉換器支援右結合的 ^(次方),pop 條件要怎麼改?5 * 3 ^ ( 4 - 2 ) 的後序是?

(A) 遇到 ^ 時只 pop「嚴格大於」自己的;答案 5 3 4 2 - ^ *
(B) 照舊 pop「大於等於」;答案 5 3 ^ 4 2 - *
(C) 把 ^ 的優先權設成最低
PART 05 · 佇列

Queue:先進先出的公平隊伍 cppds §3.10–3.12

佇列在 rear 加入、front 移除,先來的先被服務(FIFO)。 作業系統的工作排程、印表機隊伍、BFS 的「下一層名單」全是 queue。ADT 操作只有五個: enqueue(item)dequeue()isEmpty()size() 加建構子。 講義的操作序列表(front 在右,注意方向):

操作Queue 內容(front 在右)回傳
q.enqueue(4)[4]
q.enqueue("dog")[dog, 4]
q.enqueue(true)[true, dog, 4]
q.size()[true, dog, 4]3
q.enqueue(8.4)[8.4, true, dog, 4]
q.dequeue()[8.4, true, dog]4
q.dequeue()[8.4, true]dog
enqueue 加到 rear(右)、dequeue 從 front(左)取出。
C++ 實作 CODE
class Queue { // dscpp/queue.hpp(vector 前端 = rear) vector<T> items; bool isEmpty() { return items.empty(); } void enqueue(T item) { items.insert(items.begin(), item); } T dequeue() { T front = items.back(); items.pop_back(); return front; } int size() { return items.size(); } };
成本注意!
enqueue(insert 前端)O(n)
dequeue(pop_back)O(1)
教學版把 rear 放在 vector 前端,enqueue 要整體搬移。工業級請用環形緩衝或 std::deque
PART 06 · 模擬

燙手山芋(約瑟夫問題):用 queue 模擬傳遞 cppds §3.13

一群人圍圈傳山芋,數到第 num 下的人出局。queue 的「dequeue 再 enqueue」剛好模擬 把手上的東西傳給下一個人:front 的人傳完就排到隊伍最尾。

Bill、David、Susan、Jane、Kent、Brad 圍圈,num=7。按「開始」看誰活到最後。
速度
虛擬碼 CODE
hotPotato(nameList, num): Queue<string> simQueue for (name : nameList): simQueue.enqueue(name) while simQueue.size() > 1: for i = 0; i < num; i++: simQueue.enqueue(simQueue.dequeue()) // 傳一次 simQueue.dequeue() // 淘汰 front return simQueue.dequeue()
複雜度
每輪傳遞num 次
總計O(n · num)
QUIZ · 燙手山芋

六人 [Bill, David, Susan, Jane, Kent, Brad]、num = 7 時,最後留下來的是誰?(動畫可驗證)

(A) Susan
(B) Bill
(C) Brad
PART 07 · 模擬

印表機模擬:用機率與 queue 回答容量問題 cppds §3.14 · 講義補充

課堂投影片跳過本節(RISE skip):屬自學補充。 它是 queue 在「容量規劃」上的完整實戰,也是全章唯一用到隨機模擬的地方。

實驗室的印表機可以切兩種模式:草稿模式一分鐘 10 頁,高品質模式一分鐘只剩 5 頁。 切高品質的話,學生會不會等到天荒地老?這是個真實的容量規劃問題, 而且沒辦法用公式直接算,因為「什麼時候有人送印、一次印幾頁」都是隨機的。 講義的做法:寫一個模擬,讓隨機事件跑一小時,統計平均等待時間。

先把機率定出來

實驗室平均 10 個人,每人每小時印兩次,所以每小時約 20 個列印工作。換算成秒:

20 件/小時 × 1 小時/60 分 × 1 分/60 秒 = 1 件/180 秒

於是每一秒擲一顆 180 面的骰子,擲到 180 就生出一個工作(頁數 1 到 20 均勻隨機)。 可能連續好幾秒都有工作、也可能好幾分鐘沒動靜,這正是模擬的本性。

三個類別、一個主迴圈

照真實世界的物件切:Task(記創建時間戳與頁數)、Printer(記頁速與剩餘工作量)、 print queue(就用我們的 Queue ADT)。主迴圈每秒一個 tick:

  1. 擲骰子決定要不要生新工作,生了就 enqueue(帶上目前秒數當時間戳)。
  2. 印表機空閒且 queue 非空:dequeue 下一個工作,目前秒數減時間戳就是它等了多久,記進等待清單。
  3. 印表機 tick 一秒:剩餘時間減一,歸零就變空閒。
Task CODE
class Task { int timestamp, pages; public: Task(int time) { timestamp = time; pages = rand() % 20 + 1; } int getPages() { return pages; } int waitTime(int now) { return now - timestamp; } };
Printer CODE
class Printer { int pageRate; Task *currentTask; double timeRemaining; public: void tick() { if (currentTask != NULL) { timeRemaining -= 1; if (timeRemaining <= 0) currentTask = NULL; } } void startNext(Task *t) { currentTask = t; timeRemaining = t->getPages() * 60.0 / pageRate; } };
主迴圈 CODE
for (sec = 0; sec < 3600; sec++) { if (newPrintTask()) // rand()%180 == 0 printQueue.push(new Task(sec)); if (!labPrinter.busy() && !printQueue.empty()) { Task *next = printQueue.front(); printQueue.pop(); waitingTimes.push_back(next->waitTime(sec)); labPrinter.startNext(next); } labPrinter.tick(); }
run5 頁/分 平均等待(秒)10 頁/分 平均等待(秒)
按下方按鈕各跑 10 次 3600 秒的模擬(固定亂數種子,可重現),跟講義同規模。
結論怎麼讀? 講義跑了 10 次 3600 秒:5 頁/分的平均等待起伏很大,動輒好幾分鐘還常留下未完成的工作; 10 頁/分則穩定壓在半分鐘以內、隊伍清空。等六分鐘拿一份報告,學生下一堂課都要遲到了, 所以「切高品質」這個提案大概不可行。注意這個結論的效力完全建立在假設上 (10 個人、每人每小時印兩次、1 到 20 頁均勻分布)。講義接著丟出三個 what-if,每一個都只要改參數重跑就能回答:
  • 選課人數增加、實驗室平均多 20 個人呢?(到達率 1/180 要重算)
  • 週六沒課,學生不趕時間呢?(同樣的等待時間,結論可能翻盤)
  • 程式越寫越短、平均頁數下降呢?(頁數分布不再是 1 到 20 均勻)
模擬的價值就在這裡:改參數比改現實便宜太多,但模擬的可信度永遠不會超過它的假設。
QUIZ · 模擬的機率設定

為什麼是「每秒以 1/180 的機率生一個工作」?

(A) 每小時 20 件換算成秒就是平均 180 秒一件
(B) 180 是印表機印一頁的秒數
(C) 隨便訂的,跑起來像就好
PART 08 · 雙端佇列

Deque 與迴文檢查:兩端都能進出 cppds §3.15–3.18

雙端佇列(deque,唸作 deck)兩端都能加入與移除,一個人就能扮演 stack 或 queue。 代價是使用者要自己維持紀律,結構不再幫你防呆。六個操作:addFrontaddRearremoveFrontremoveRearisEmptysize。 講義的操作表(front 在右,方向跟 queue 的表一致):

操作Deque 內容(front 在右)回傳
d.add_rear(4)[4]
d.add_rear("dog")[dog, 4]
d.add_front("cat")[dog, 4, cat]
d.add_front(true)[dog, 4, cat, true]
d.add_rear(8.4)[8.4, dog, 4, cat, true]
d.remove_rear()[dog, 4, cat, true]8.4
d.remove_front()[dog, 4, cat]true

教學版把 front 放在 vector 尾端,所以 addFront/removeFront 是 O(1),addRear/removeRear 是 O(n):兩端的成本並不對稱。 STL 的 std::deque 用分段連續的儲存做到兩端都 O(1),實戰直接用它。

radar 是迴文嗎?兩端同時取出比較。
C++ 實作 CODE
class Deque { // dscpp/deque.hpp(vector 尾端 = front) vector<T> items; void addFront(T item) { items.push_back(item); } void addRear(T item) { items.insert(items.begin(), item); } T removeFront() { T f = items.back(); items.pop_back(); return f; } T removeRear() { T r = items.front(); items.erase(items.begin()); return r; } };
迴文檢查 CODE
palChecker(aString): Deque<char> charDeque for (ch : aString): charDeque.addRear(ch) while charDeque.size() > 1: first = charDeque.removeFront() last = charDeque.removeRear() if first != last: return false return true
EXERCISES · 練習

動手驗證:三個經典考點 cppds §3 綜合

EXERCISE 1 · stack 輸出序列

把 1, 2, 3 依序 push 進 stack,途中任何時刻都可以 pop。下列哪個輸出序列不可能出現?

(A) 3 1 2
(B) 2 1 3
(C) 1 3 2
EXERCISE 2 · 中序轉後序+求值

10 + 3 * 5 / ( 16 - 4 ) 的後序是 10 3 5 * 16 4 - / +。用後序求值,答案是?(整數除法)

(A) 11
(B) 11.25
(C) 13
EXERCISE 3 · deque 成本

用「vector 尾端當 front」的 Deque 實作,下列哪組操作都是 O(1)

(A) addFrontremoveFront
(B) addRearremoveRear
(C) 四個操作全部
REFERENCE · 總覽

三種線性 ADT 一次比較 cppds §3 總覽

ADT紀律經典應用
StacktoptopLIFO括號配對、進位轉換、undo、呼叫堆疊、DFS
QueuerearfrontFIFO排程、模擬(燙手山芋、印表機)、BFS
Deque兩端兩端使用者自律迴文、滑動視窗、工作竊取排程

教學版實作成本(以 vector 為底)

操作vector 尾端vector 前端
加入 / 移除O(1)(攤銷)O(n)(整體搬移)
本章的選擇Stack top、Queue front、Deque frontQueue rear、Deque rear
關鍵概念複習 ① stack = 反轉機器;queue = 公平機器;deque = 萬用但要自律。
② 「產生順序 ≠ 使用順序」→ stack;「先來先服務」→ queue。
③ ADT 與實作分離:介面不變,底層可以換 vector、linked list 或環形緩衝。