01

線性結構與堆疊 Stack

線性結構的共同特徵:兩端、加入與移除的位置決定了結構的種類。

先讀原文開場,旁邊就是白話

這是一本英文書。左邊放原文、右邊放白話導讀——你既讀得懂,也順手碰了原文。

原文 · 基礎資料結構 CHAPTER THREE BASIC DATA STRUCTURES 3. 1 Objectives • To understand the abstract data types stack, queue, deque, and list. • To be able to implement the ADTs stack, queue, and deque using Python lists. • To understand the performance of the implementations of basic linear data structures.
白話導讀

線性結構的共同特徵:兩端、加入與移除的位置決定了結構的種類。

什麼是線性結構?

線性結構的共同特徵:兩端、加入與移除的位置決定了結構的種類。

STEP 1

深度探秘

什麼讓一個結構叫做「線性」?

線性結構的本質

堆疊 (stack)、佇列 (queue)、雙端佇列 (deque)、串列 (list) 都是同一家族:它們的項目會依照「何時、從哪一端被加入或移除」而保持一個固定的排列順序。

關鍵觀念:一個項目一旦放進去,它相對於前後鄰居的位置就固定了,不會自己亂跑。

我們可以把線性結構想成有兩端

  • 有時叫「左 / 右」
  • 有時叫「前 (front) / 後 (rear)」
  • 有時叫「頂 (top) / 底 (base)」

名字不重要,真正決定一個結構是哪一種的,是「加入」和「移除」分別發生在哪一端

結構 加入位置 移除位置
堆疊 Stack 同一端 同一端
佇列 Queue 一端 另一端
雙端佇列 Deque 任一端 任一端
💡
關鍵

線性結構的差異不在於存什麼,而在於「從哪一端進、從哪一端出」。

STEP 2

生活妙喻

一條走廊,兩個門

想像一條只有兩個門的走廊

把線性結構想成一條狹窄的走廊,裡面排著人,走廊只有頭尾兩個門。

  • 如果規定「只能從同一個門進出」,那後進來的人擋在門口,反而最先出去——這就是堆疊
  • 如果規定「從後門進、從前門出」,先進來的人先到前門出去——這就是佇列
  • 如果兩個門都能自由進出——這就是雙端佇列

走廊本身(資料)都一樣,是「門的規則」造就了完全不同的結構與用途。

flowchart LR
  A[後門 rear] --> B[人 人 人 人] --> C[前門 front]
💡
關鍵

同一條走廊,換個門的規則,就變成完全不同的資料結構。

STEP 3

實用超能力

為什麼這四個觀念這麼重要?

簡單卻威力強大

這四個觀念看似簡單,卻是電腦科學中最常用的工具,出現在無數演算法裡:

  • 瀏覽器的「上一頁」用堆疊
  • 印表機排隊、作業系統排程用佇列
  • 編譯器檢查括號是否成對用堆疊
  • 鍵盤打字緩衝區用佇列

辨認問題的訣竅:當你發現一個問題有「最後處理的要先回頭」(反轉)的味道,多半是堆疊;當你發現「先來先服務」的公平排隊味道,多半是佇列。

接下來幾節我們會一個一個深入,先從堆疊開始。

💡
關鍵

學會辨認問題的「順序味道」,就能選對線性結構。

🔆
生活妙喻:線性結構 ≈ 只有兩個門的窄走廊

資料像走廊裡排隊的人,結構的種類由「哪個門能進、哪個門能出」決定。

🔆
生活妙喻:結構的多樣性 ≈ 同一副撲克牌的不同玩法

牌(資料)一樣,規則(進出端)不同,就變成不同的遊戲(結構)。

本節字彙

線性資料結構 (linear data structure)
項目依加入順序排成一線、有兩端的資料集合。
🧠 「線」性=排成一條線。
front / rear / top / base
線性結構兩端的常見稱呼,名字不重要,重要的是進出規則。
🧠 頭尾各一個名字,看情境取名。
根據課文,真正區分堆疊、佇列、雙端佇列的關鍵是什麼?
一個結構規定「只能從同一端加入,也只能從同一端移除」,它最可能是哪一種?
課文說線性結構兩端的名字(如 top/base、front/rear)「不重要」,這句話的真正含意是?

堆疊是什麼:LIFO 的世界

堆疊的 top 與 base、後進先出原則,以及「反轉」這個關鍵性質。

STEP 1

深度探秘

後進先出 LIFO

堆疊 Stack

堆疊是一種有序集合,新增與移除永遠發生在同一端,這一端叫 top(頂),另一端叫 base(底)

  • 越靠近 base 的項目,待在堆疊裡越久(最早放進去)。
  • 最近加入的項目在 top,會最先被移除

這個排序原則叫做 LIFO(last-in first-out,後進先出):新的在上,舊的在下。

flowchart TD
  T[top 最新 最先離開] --> A[項目3]
  A --> B[項目2]
  B --> C[項目1 base 最舊 最後離開]
💡
關鍵

堆疊只在同一端 top 進出,最後進來的最先出去(LIFO)。

STEP 2

生活妙喻

桌上的一疊書

桌上的一疊書

想像你在桌上一本一本疊書。能看到封面的,只有最上面那一本。要拿下面的書,必須先把上面的搬開。

餐廳的托盤架也是:你拿走最上面那個,露出下一個給後面的人。

最神奇的是反轉性質:你依序疊上 A、B、C,拿下來的順序卻是 C、B、A——移除順序剛好是加入順序的相反

這個「反轉」特性,正是堆疊在無數演算法中大放異彩的原因。

flowchart LR
  subgraph 放入
  P[A 然後 B 然後 C]
  end
  subgraph 取出
  Q[C 然後 B 然後 A]
  end
  P --> Q
💡
關鍵

一疊書只能從最上面拿,取出順序恰好和放入相反。

STEP 3

實用超能力

反轉就是它的拿手好戲

堆疊能做什麼?

因為堆疊天生會反轉順序,許多需要「回頭」的場景都靠它:

  • 瀏覽器上一頁:每造訪一頁就把網址 push 進堆疊,按上一頁就 pop 出來,順著反方向走回去。
  • 反轉字串:把字元一個個 push 進去,再一個個 pop 出來,就得到反過來的字串。
  • 撤銷 (undo):每個動作 push 進堆疊,按撤銷就 pop 出最近的動作。

下一節我們會把堆疊的行為正式定義成抽象資料型別 (ADT),並用 Python 實作出來。

💡
關鍵

凡是需要「最近的先回頭處理」,堆疊就是好工具。

🔆
生活妙喻:堆疊 LIFO ≈ 桌上一疊書

只能從最上面取放,最後疊上去的最先被拿走。

🔆
生活妙喻:反轉性質 ≈ 把硬幣一枚枚投進細長存錢筒再倒出來

倒出來的順序和投進去的剛好相反。

本節字彙

堆疊 (stack)
只在同一端 (top) 進出的有序集合,後進先出。
🧠 stack=把東西『疊』起來。
LIFO
Last-In First-Out,後進先出,最近加入的最先被移除。
🧠 最後進門的人卡在門口,最先被請出去。
top / base
堆疊進出的那一端叫 top,相對的另一端叫 base。
🧠 top 在『頂』、base 在『底』。
你依序把 'a'、'b'、'c' push 進一個空堆疊,接著做一次 pop,會得到什麼?
若想用堆疊把字串 'cat' 反轉成 'tac',正確做法是?
在堆疊中,哪個位置的項目「待在裡面最久」?

堆疊 ADT 與 Python 實作

堆疊抽象資料型別的六個操作,以及用 list 實作時末端 vs 開頭的效能差異。

STEP 1

深度探秘

六個操作定義堆疊

堆疊抽象資料型別 (ADT)

ADT 只描述「能做什麼」,不管「怎麼做到」。堆疊 ADT 有六個操作:

操作 作用
Stack() 建立一個空堆疊
push(item) 把項目加到 top
pop() 移除並回傳 top 的項目
peek() 看 top 的項目但不移除
is_empty() 是否為空,回傳布林值
size() 回傳項目數量

當我們給 ADT 一個實際的程式實作,這個實作就稱為資料結構 (data structure)

peekpop 的差別很關鍵:peek 只「偷看」不動堆疊,pop 會「拿走」並改變堆疊。

💡
關鍵

ADT 定義行為(介面),資料結構是它的具體實作。

STEP 2

生活妙喻

餐廳點餐單 vs 廚房做法

菜單與廚房

ADT 就像餐廳的菜單:上面寫著「你可以點什麼」(push、pop、peek…),但沒寫廚房怎麼煮。

資料結構則是廚房的做法:同一道菜,可以用不同方式煮出來。

用 Python list 實作堆疊時,我們可以選「list 的末端當 top」或「list 的開頭當 top」——兩種菜單看起來一樣,但廚房效率差很多:

class Stack:
    def __init__(self):
        self.items = []
    def is_empty(self):
        return self.items == []
    def push(self, item):
        self.items.append(item)   # 末端當 top,O(1)
    def pop(self):
        return self.items.pop()   # O(1)
    def peek(self):
        return self.items[len(self.items) - 1]
    def size(self):
        return len(self.items)
💡
關鍵

同一份菜單(ADT)可以有不同廚房做法(實作),效率可能天差地別。

STEP 3

實用超能力

選對那一端,效能差很多

末端 vs 開頭:O(1) vs O(n)

若改成「list 開頭當 top」,push 要用 insert(0, item)、pop 要用 pop(0)

def push(self, item):
    self.items.insert(0, item)   # 開頭插入,O(n)
def pop(self):
    return self.items.pop(0)     # 開頭移除,O(n)

兩種實作在邏輯上完全等價(這正是抽象的威力),但效能不同:

  • 末端appendpop() 都是 $O(1)$,不管堆疊多大都一樣快。
  • 開頭insert(0)pop(0) 都是 $O(n)$,因為其餘元素都要往後挪一格。

結論:實作可以換,但要懂得選效能好的那一種。

💡
關鍵

末端進出是 O(1),開頭進出是 O(n)——邏輯等價但效能差很大。

🔆
生活妙喻:ADT vs 資料結構 ≈ 菜單 vs 廚房做法

菜單寫你能點什麼(行為),廚房決定怎麼煮(實作)。

🔆
生活妙喻:末端 vs 開頭實作 ≈ 從隊伍尾巴 vs 從隊伍最前面插隊

尾巴加人很快,最前面插人要全部後退,所以慢。

本節字彙

抽象資料型別 (ADT)
只描述操作行為、不涉及實作細節的資料模型。
🧠 ADT=『抽象』,只看介面不看內部。
資料結構 (data structure)
ADT 的具體程式實作。
🧠 把抽象的 ADT『落地』成程式。
peek
查看 top 的項目但不移除它,堆疊不變。
🧠 peek=偷看一眼,不動手。
下列哪一句最準確說明 ADT 與資料結構的關係?
用 Python list 實作堆疊,若 push 用 `append`、pop 用 `pop()`(操作末端),這兩個操作的時間複雜度是?
若把堆疊改成「list 開頭當 top」,用 `insert(0, item)` 和 `pop(0)`,為什麼會變慢?
02

堆疊的超能力:解決真實問題

用堆疊判斷括號是否正確配對與巢狀,並推廣到多種括號符號。

括號平衡檢查

用堆疊判斷括號是否正確配對與巢狀,並推廣到多種括號符號。

STEP 1

深度探秘

什麼叫括號平衡?

平衡的括號

平衡 (balanced) 指每個開符號都有對應的閉符號,而且配對正確巢狀

正確的例子:(()()())(((())))(()((())()))

不正確的例子:((((((())()))(()()(()

關鍵觀察:由左往右處理時,最近的那個開括號,必須和下一個閉括號配對。閉括號是由內而外、依出現的相反順序去配對的。

「相反順序配對」=反轉的味道=堆疊!

flowchart LR
  A[讀到 開括號] --> B[push 進堆疊]
  C[讀到 閉括號] --> D[pop 一個來配對]
💡
關鍵

閉括號由內而外、依相反順序配對開括號,這正是堆疊的拿手好戲。

STEP 2

生活妙喻

俄羅斯娃娃

一層層的俄羅斯娃娃

把每個開括號想成打開一個俄羅斯娃娃,每個閉括號想成把它蓋回去

  • 你最後打開的那個,必須最先蓋回去(後進先出)。
  • 全部蓋完後,桌上應該一個沒蓋的都不剩——這就是「堆疊最後必須為空」。

如果你想蓋回去卻發現桌上根本沒有打開的娃娃(堆疊是空的),代表多了一個閉括號——不平衡!

💡
關鍵

開=打開娃娃 push,閉=蓋回去 pop,最後桌上不能有沒蓋的娃娃。

STEP 3

實用超能力

演算法與多種括號

par_checker 演算法

def par_checker(symbol_string):
    s = Stack()
    balanced = True
    index = 0
    while index < len(symbol_string) and balanced:
        symbol = symbol_string[index]
        if symbol == "(":
            s.push(symbol)
        else:
            if s.is_empty():
                balanced = False   # 沒有開括號可配對
            else:
                s.pop()
        index = index + 1
    return balanced and s.is_empty()

推廣到多種括號 ()[]{}:閉括號出現時,不只要堆疊非空,還要檢查 pop 出的開符號型別相符。用一個 matches 輔助函式比對 ([{)]}在字串中的相同位置即可。

所以 {{([][])}()} 平衡,但 [{()] 不平衡(型別不符)。

💡
關鍵

開符號 push、閉符號 pop 並檢查型別相符,最後堆疊為空才算平衡。

🔆
生活妙喻:括號平衡 ≈ 俄羅斯娃娃的打開與蓋回

最後打開的要最先蓋回,全部蓋完桌上不能剩。

🔆
生活妙喻:型別相符檢查 ≈ 鎖與鑰匙要配對

方括號的鎖只能用方括號的鑰匙開,圓的不能配方的。

本節字彙

平衡 (balanced)
每個開符號都有對應閉符號且正確巢狀。
🧠 天平兩邊(開閉)要一樣多且配對。
巢狀 (nested)
括號一層包一層,內層必須先封閉。
🧠 像盒中盒,裡面的先蓋。
為什麼括號平衡問題特別適合用堆疊解決?
用 par_checker 處理字串 `(()`,最後結果與原因是?
在處理過程中,若讀到一個閉括號但堆疊已經是空的,代表什麼?

十進位轉二進位與任意進位

Divide by 2 演算法,用堆疊處理「先算出的餘數其實是最後一位」的反轉問題。

STEP 1

深度探秘

Divide by 2 演算法

為什麼又是堆疊?

要把十進位整數轉成二進位,用的是 Divide by 2(不斷除以 2) 演算法:

  1. 把數字不斷除以 2,每次記下餘數(0 或 1)。
  2. 第一次的餘數告訴你個位是奇是偶;但它其實會是二進位結果的最後一位
  3. 也就是說,先算出的餘數要排在最後——又是反轉!所以用堆疊。

例如 $233_{10} = 11101001_2$,可拆解為:

$$233 = 1\times2^7 + 1\times2^6 + 1\times2^5 + 0\times2^4 + 1\times2^3 + 0\times2^2 + 0\times2^1 + 1\times2^0$$

💡
關鍵

餘數的計算順序與最終位數順序相反,所以用堆疊來反轉。

STEP 2

生活妙喻

撕號碼牌再倒著貼

撕號碼牌

想像你一邊除以 2,一邊把每個餘數寫在便條紙上,在桌上。

  • 第一張(最先算出)壓在最底下。
  • 全部算完後,你從最上面一張張撕下來貼成一排。

這樣最後算出的餘數排在最前面,最先算出的排在最後——順序剛好被堆疊反轉過來,得到正確的二進位字串。

flowchart TD
  A[233 除2 餘1 push] --> B[116 除2 餘0 push]
  B --> C[58 除2 餘0 push]
  C --> D[繼續直到 0]
  D --> E[從 top 一個個 pop 串成結果]
💡
關鍵

先算的餘數壓在底下,最後 pop,自然把順序反轉成正確答案。

STEP 3

實用超能力

推廣到任意進位

從 Divide by 2 到 Divide by base

def base_converter(dec_number, base):
    digits = "0123456789ABCDEF"
    rem_stack = Stack()
    while dec_number > 0:
        rem = dec_number % base
        rem_stack.push(rem)
        dec_number = dec_number // base
    new_string = ""
    while not rem_stack.is_empty():
        new_string = new_string + digits[rem_stack.pop()]
    return new_string

把「除以 2」換成「除以 base」就能轉任意進位(2 到 16)。

超過 10 進位的小麻煩:餘數可能是 10~15,沒辦法用單一數字表示。解法是準備一個字元表 "0123456789ABCDEF",餘數當索引——餘數 13 就取出 D。這就是十六進位用 A~F 的由來。

💡
關鍵

把除數從 2 換成任意 base,再用字元表處理大於 9 的位數。

🔆
生活妙喻:餘數反轉 ≈ 便條紙疊起來再從上往下撕

先寫的壓底下,最後撕的順序剛好相反,得到正確排列。

🔆
生活妙喻:字元表 digits ≈ 用字母當『大號碼』的位數

10~15 沒有單一數字,就借 A~F 來當位數符號。

本節字彙

Divide by 2
反覆除以 2 取餘數、用堆疊反轉,得到二進位表示的演算法。
🧠 一直『除2』,餘數倒著讀。
進位制 (base)
數字系統的基底,如二進位 base 2、十六進位 base 16。
🧠 base=每滿多少進一位。
餘數 (remainder)
除法後剩下的數,用 `%` 取得。
🧠 % 取『剩下的』。
在 Divide by 2 演算法中,為什麼需要用堆疊而不是直接依序串接餘數?
用 base_converter 把十進位 25 轉成二進位,依序算出的餘數是 1、0、0、1、1(先算到後算),最後結果應該是?
把 Divide by 2 推廣到任意進位,主要的修改是什麼?

中序、前序、後序運算式

三種運算式表示法的差別,運算子相對於運算元的位置,以及為何前後序不需括號。

STEP 1

深度探秘

運算子站在哪?

三種運算式表示法

差別只在於運算子相對於運算元的位置

  • 中序 (infix):運算子在兩個運算元中間,如 A + B。這是我們最熟悉的寫法。
  • 前序 (prefix):運算子在運算元前面,如 + A B
  • 後序 (postfix):運算子在運算元後面,如 A B +
中序 前序 後序
A + B + A B A B +
A + B * C + A * B C A B C * +
(A + B) * C * + A B C A B + C *

注意:不管哪種表示法,運算元的相對順序都不變,只有運算子在移動。

💡
關鍵

中序、前序、後序的差別只在運算子的位置,運算元順序不變。

STEP 2

生活妙喻

為什麼中序最麻煩?

中序需要『額外規矩』

A + B * C,到底是先加還是先乘?中序之所以不會搞混,是因為我們腦中偷偷套用了兩條額外規矩

  1. 優先序 (precedence):乘除高於加減。
  2. 結合律 (associativity):同優先序由左到右。
  3. 還可以用括號強制改變順序。

中序就像講話需要靠語氣與上下文才懂的語言——容易產生歧義

而前序與後序就像把每個動作的順序講得清清楚楚的機器指令,完全不需要括號,因為運算子的位置已經把順序講死了。

💡
關鍵

中序要靠優先序、結合律、括號才不歧義;前後序光看位置就確定順序。

STEP 3

實用超能力

用全括號法手動轉換

全括號 (fully parenthesized) 轉換法

要把中序轉成前序或後序,先依優先序把式子完全加上括號,每個運算子一對括號:

A + B * C + D((A + (B * C)) + D)

然後把每個運算子搬到它那對括號的位置:

  • 搬到右括號位置、刪掉對應左括號 → 後序A B C * + D +
  • 搬到左括號位置、刪掉對應右括號 → 前序+ + A * B C D
flowchart TD
  A[中序 A加B乘C] --> B[全括號 開A加開B乘C閉閉]
  B --> C[運算子移到右括號 變後序]
  B --> D[運算子移到左括號 變前序]

為什麼電腦愛用後序?因為求值時不需要回頭看優先序,也不需要括號,一路掃過去用堆疊就能算。

💡
關鍵

全括號後把運算子移到右括號得後序、移到左括號得前序。

🔆
生活妙喻:中序的歧義 ≈ 口語需要靠語氣才懂的句子

A+B*C 要靠優先序這個『潛規則』才不會誤解。

🔆
生活妙喻:前後序 ≈ 把步驟一條條寫死的食譜

順序由位置決定,照著做不會錯,不需任何額外標點。

本節字彙

中序 (infix)
運算子在兩運算元中間的寫法,需優先序與括號輔助。
🧠 in=在中間。
前序 (prefix)
運算子在運算元前面,如 `+ A B`。
🧠 pre=在前。
後序 (postfix)
運算子在運算元後面,如 `A B +`,求值最方便。
🧠 post=在後。
優先序 (precedence)
運算子先後執行的等級,如乘除高於加減。
🧠 誰『優先』先算。
中序、前序、後序三種表示法的根本差別是什麼?
中序運算式 `A + B * C` 的後序形式是?
為什麼前序與後序運算式『不需要括號』?

中序轉後序與後序求值

用堆疊把任意中序運算式轉成後序,再用堆疊對後序運算式求值。

STEP 1

深度探秘

中序轉後序:用堆疊暫存運算子

為什麼運算子要被暫存?

處理 A + B * C 時,讀到 + 還不能馬上輸出,因為後面可能有更高優先序的運算子(這裡是 *)要先處理。所以運算子要先存進堆疊等待

演算法(運算元用 split 切成 token):

  1. 建立空 op_stack 與輸出 list。
  2. 由左到右掃描每個 token:
    • 運算元 → 直接放進輸出 list。
    • 左括號 ( → push 進 op_stack。
    • 右括號 ) → 持續 pop 直到遇到對應的 (,pop 出的運算子加進輸出。
    • 運算子 → 先把堆疊上優先序大於等於它的運算子 pop 出來加進輸出,再 push 自己。
  3. 掃完後,把堆疊剩下的運算子全 pop 出來加進輸出。

prec 字典記錄優先序:* / = 3+ - = 2( = 1(最低,這樣任何運算子都會疊在它上面)。

💡
關鍵

轉換時堆疊暫存運算子;遇到優先序不低於自己的就先輸出,達成優先序的『反轉』。

STEP 2

生活妙喻

後序求值:運算元排隊等運算子

求值時換運算元等待

中序轉後序時是運算子在等;後序求值時剛好相反,是運算元在等

想像櫃台前排著一群數字,每當來了一位『運算子』,他就把最近的兩位數字叫出來算,算完的結果再回到隊伍當作新數字。

演算法(後序求值):

  1. 建立空 operand_stack
  2. 掃描每個 token:
    • 運算元 → 轉成整數 push 進堆疊。
    • 運算子 → pop 兩次取得兩個運算元,運算後把結果 push 回去。
  3. 掃完後,堆疊上唯一剩下的就是答案。
flowchart TD
  A[讀到運算元] --> B[push 進堆疊]
  C[讀到運算子] --> D[pop 兩個運算元]
  D --> E[運算後 push 結果回堆疊]
💡
關鍵

後序求值時運算元入堆疊,遇運算子就 pop 兩個來算,結果再 push 回去。

STEP 3

實用超能力

小心非交換運算的順序

pop 順序的陷阱

後序求值有個容易出錯的地方:減法與除法不可交換15 / 5 ≠ 5 / 15

從堆疊 pop 兩次時,第一個 pop 出的是『第二個運算元』,第二個 pop 出的才是『第一個運算元』

def postfix_eval(postfix_expr):
    operand_stack = Stack()
    for token in postfix_expr.split():
        if token in "0123456789":
            operand_stack.push(int(token))
        else:
            operand2 = operand_stack.pop()   # 先 pop 的是第二運算元
            operand1 = operand_stack.pop()   # 後 pop 的是第一運算元
            result = do_math(token, operand1, operand2)
            operand_stack.push(result)
    return operand_stack.pop()

例如 7 8 + 3 2 + /:先算 7+8=153+2=5,最後 15 / 5 = 3。順序若搞反就會得到錯誤答案。

💡
關鍵

pop 兩次時先取到的是第二運算元,順序顛倒會讓減法、除法算錯。

🔆
生活妙喻:中序轉後序 ≈ 運算子在候診室排隊

優先序高的先被叫進去(先輸出),低的要等。

🔆
生活妙喻:後序求值 ≈ 數字排隊等運算子點名兩個來計算

運算子一來就帶走最近兩個數字,算完結果再回隊伍。

🔆
生活妙喻:pop 順序陷阱 ≈ 先拿到的是『右邊那個數』

減除不可交換,先 pop 的要當第二運算元才正確。

本節字彙

token
用空白切開後的最小單位,可能是運算元或運算子。
🧠 用 split 切出來的一小塊。
prec 優先序字典
把運算子對應到整數優先序,用來比較先後。
🧠 prec=precedence 的縮寫。
非交換運算
交換運算元順序會改變結果的運算,如減法、除法。
🧠 a-b ≠ b-a。
在中序轉後序時,堆疊 op_stack 用來暫存什麼?
轉換時讀到一個運算子,演算法為何要先 pop 出堆疊上『優先序大於等於』它的運算子?
後序求值時,遇到一個運算子要怎麼做?
03

佇列 Queue 與模擬

佇列的 front 與 rear、先進先出原則,以及生活與電腦中的例子。

佇列是什麼:FIFO 的排隊

佇列的 front 與 rear、先進先出原則,以及生活與電腦中的例子。

STEP 1

深度探秘

先進先出 FIFO

佇列 Queue

佇列是一種有序集合:新增發生在一端叫 rear(後端),移除發生在另一端叫 front(前端)

  • 元素從 rear 進來,慢慢往 front 移動,輪到它在最前面時才被移除。
  • 待最久的在 front,最新加入的在 rear。

這個排序原則叫 FIFO(first-in first-out,先進先出),也就是「先來先服務」。

flowchart LR
  R[rear 新加入] --> A[項目] --> B[項目] --> C[項目] --> F[front 先離開]

和堆疊最大的不同:堆疊同一端進出(後進先出),佇列兩端分開(先進先出)。

💡
關鍵

佇列從 rear 進、從 front 出,先進來的先離開(FIFO)。

STEP 2

生活妙喻

排隊買票

規規矩矩的一條隊伍

佇列就是我們最熟悉的排隊:買電影票、超市結帳、餐廳取餐。

好的隊伍很有規矩:

  • 只有一個入口(rear)、一個出口(front)
  • 不能中途插隊。
  • 不能還沒輪到就先走。

所以先來的人先買到票——這就是 FIFO 的精神:公平地依到達順序服務。

💡
關鍵

佇列像規矩的排隊:尾巴進、前頭出,先到先服務、不准插隊。

STEP 3

實用超能力

電腦裡到處是佇列

佇列無所不在

電腦科學裡佇列的例子非常多:

  • 印表機排隊:30 台電腦共用一台印表機,列印工作排成一列,先送的先印。
  • 作業系統排程:用佇列管理待執行的程序,盡量公平且快速地服務多個使用者。
  • 鍵盤緩衝區:打字太快時,按鍵先進入一個像佇列的緩衝區,再依正確順序顯示到螢幕。

辨認訣竅:當問題有「先來先服務、依到達順序處理」的味道,就該想到佇列。後面我們會用它做兩個有趣的模擬。

💡
關鍵

凡是『先來先服務、依順序處理』的情境,都是佇列的舞台。

🔆
生活妙喻:佇列 FIFO ≈ 排隊買票的隊伍

從尾巴排進、從前頭離開,先到先服務不准插隊。

🔆
生活妙喻:鍵盤緩衝區 ≈ 排隊等著上螢幕的按鍵

打太快的字先排隊,再依正確順序一個個顯示。

本節字彙

佇列 (queue)
從 rear 加入、從 front 移除的有序集合,先進先出。
🧠 queue=排隊。
FIFO
First-In First-Out,先進先出,先加入的先被移除。
🧠 先到的先走,公平排隊。
front / rear
佇列移除的那端叫 front,加入的那端叫 rear。
🧠 前頭出、後尾進。
佇列的排序原則 FIFO 指的是什麼?
佇列與堆疊最關鍵的差別是什麼?
在佇列中,哪一端的項目『待最久、最先被移除』?

佇列 ADT 與燙手山芋模擬

佇列的操作與 Python 實作,並用佇列模擬環狀傳遞的 Hot Potato / Josephus 問題。

STEP 1

深度探秘

佇列 ADT 與 Python 實作

佇列的五個操作

操作 作用
Queue() 建立空佇列
enqueue(item) 把項目加到 rear
dequeue() 移除並回傳 front 的項目
is_empty() 是否為空
size() 項目數量

Python 實作(假設 list 的位置 0 是 rear):

class Queue:
    def __init__(self):
        self.items = []
    def is_empty(self):
        return self.items == []
    def enqueue(self, item):
        self.items.insert(0, item)   # 加到開頭(rear),O(n)
    def dequeue(self):
        return self.items.pop()      # 從末端(front)移除,O(1)
    def size(self):
        return len(self.items)

這個設計下 enqueue 是 $O(n)$、dequeue 是 $O(1)$。

💡
關鍵

佇列用 enqueue 從 rear 加、dequeue 從 front 取;此實作 enqueue 為 O(n)、dequeue 為 O(1)。

STEP 2

生活妙喻

燙手山芋的圍圈圈

Hot Potato 燙手山芋

小朋友圍成一圈傳一個東西,傳到某個時刻喊停,拿著東西的人出局,直到剩下一人。

這是經典的 Josephus 問題的現代版。怎麼用佇列模擬一個『圈圈』?

訣竅:dequeue 後立刻 enqueue

  • 拿著山芋的人在 front。
  • 傳一次=把 front 的人 dequeue 再 enqueue 到 rear(等於排到隊伍最後)。
  • num 次之後,front 的人被永久 dequeue(出局)。
  • 重複直到佇列只剩一人。
flowchart LR
  A[front 拿山芋] --> B[dequeue]
  B --> C[立刻 enqueue 到 rear]
  C --> D[模擬出圍成一圈不斷循環]
💡
關鍵

dequeue 後立刻 enqueue,就把線性佇列變成繞圈的環。

STEP 3

實用超能力

完整模擬程式

hot_potato 實作

def hot_potato(name_list, num):
    sim_queue = Queue()
    for name in name_list:
        sim_queue.enqueue(name)
    while sim_queue.size() > 1:
        for i in range(num):
            sim_queue.enqueue(sim_queue.dequeue())  # 傳 num 次
        sim_queue.dequeue()                          # 第 num+1 個出局
    return sim_queue.dequeue()

幾個重點:

  • 名單照順序載入,第一個名字在 front。
  • num 可以大於人數,沒問題——佇列像圓圈,數到底會繞回開頭繼續算。
  • 每輪傳 num 次後,當前 front 永久出局,再開始下一輪。

這展示了佇列模擬真實『繞圈計數』情境的威力。

💡
關鍵

把名單灌進佇列、傳 num 次後讓 front 出局,重複到剩一人即為贏家。

🔆
生活妙喻:繞圈模擬 ≈ 傳完山芋就排到隊伍最後

dequeue 再 enqueue,等於讓人從前頭繞回尾巴,形成圓圈。

🔆
生活妙喻:num 大於人數 ≈ 鐘面上一直繞圈數數

數超過一圈沒關係,會繞回起點繼續,因為佇列是循環的。

本節字彙

enqueue
把項目加入佇列 rear 端。
🧠 en-queue=『入』隊。
dequeue
從佇列 front 端移除並回傳項目。
🧠 de-queue=『出』隊。
Josephus 問題
圍圈報數淘汰、求最後存活位置的經典問題。
🧠 燙手山芋的數學版。
在課文的 Queue 實作中(位置 0 為 rear),enqueue 與 dequeue 的時間複雜度分別是?
在燙手山芋模擬中,『dequeue 後立刻 enqueue』這個動作模擬了什麼?
若計數常數 num 大於圈內人數,模擬會出問題嗎?

模擬:印表機排隊

用機率與佇列建構印表機模擬,回答「列印速度該設多少」這種 what-if 問題。

STEP 1

深度探秘

用模擬回答 what-if

印表機該調快還是調慢?

情境:實驗室有 10 位學生,每人每小時平均列印 2 次,每份 1~20 頁。印表機草稿模式每分鐘 10 頁、高品質模式每分鐘 5 頁。調慢換品質會不會讓學生等太久?

我們用模擬 (simulation) 來回答。需要三個物件:

  • Task:一個列印工作,建立時隨機產生 1~20 頁,並記下 timestamp(建立時間)。
  • Printer:記錄是否忙碌、剩餘時間;tick() 每秒倒數一秒。
  • PrintQueue:用佇列存等待中的工作,先送先印。

我們關心的是平均等待時間=工作在佇列裡待的平均時間。

💡
關鍵

模擬把現實拆成 Task、Printer、Queue 三個物件,估算平均等待時間。

STEP 2

生活妙喻

用機率擲骰子模擬事件

一秒一秒地擲骰子

怎麼決定『這一秒有沒有人送列印工作』?用機率

10 人 × 每人 2 次 = 每小時 20 個工作,換算下來平均每 180 秒一個:

$$\frac{20\ \text{tasks}}{1\ \text{hour}} \times \frac{1\ \text{hour}}{3600\ \text{seconds}} = \frac{1\ \text{task}}{180\ \text{seconds}}$$

所以每一秒就擲一個 1~180 的隨機數,如果剛好是 180,就說『這秒產生了一個列印工作』。

這就像每秒擲一顆 180 面的骰子,擲到特定一面才有事件發生。可能連續好幾個工作湧入,也可能很久都沒人印——這正是真實世界的隨機性。

💡
關鍵

用每秒一次的隨機數模擬『事件是否發生』的機率,重現真實的隨機到達。

STEP 3

實用超能力

主迴圈與結論

模擬主迴圈

每一秒 (current_second) 做:

  1. 是否有新工作?有就帶著 timestamp 加入佇列。
  2. 若印表機不忙佇列非空:dequeue 下一個工作,用 current_second - timestamp 算出它的等待時間並記錄,指派給印表機。
  3. 印表機 tick() 一秒。
  4. 工作印完就把印表機設為閒置。

最後用記錄下來的等待時間算平均。

結論:模擬顯示 5 頁/分時平均等待從 17 到 376 秒(約 6 分鐘),且常有工作沒印完;10 頁/分時等待僅 1~28 秒,幾乎都印完。所以為了品質調慢,代價是學生等太久——不划算

提醒:模擬好壞取決於假設是否貼近真實。要有可靠的真實資料(每小時工作數、學生數)才能建出可信的模擬。

💡
關鍵

逐秒推進模擬累積等待時間求平均;模擬的可信度取決於假設是否符合現實。

🔆
生活妙喻:事件機率模擬 ≈ 每秒擲一顆 180 面骰子

擲到特定面才算有工作產生,重現隨機到達。

🔆
生活妙喻:what-if 分析 ≈ 調整食譜份量再試做

改變參數(頁速、人數)就能預測不同情況的結果。

本節字彙

模擬 (simulation)
用程式模型重現真實情境、估算結果的方法。
🧠 在電腦裡『演一遍』真實世界。
timestamp
工作建立的時間戳記,用來計算等待時間。
🧠 蓋一個『幾點進來的』時間章。
平均等待時間
所有工作在佇列中等待時間的平均值。
🧠 大家排隊等了多久的平均。
這個印表機模擬最主要想回答的問題是什麼?
若平均每小時有 20 個列印工作,平均多久產生一個工作?
模擬中用『每秒產生 1~180 的隨機數,若為 180 則代表有新工作』的目的是?
04

雙端佇列 Deque

Deque 兩端都可加入移除,兼具堆疊與佇列的能力,但不強制 LIFO/FIFO。

雙端佇列是什麼

Deque 兩端都可加入移除,兼具堆疊與佇列的能力,但不強制 LIFO/FIFO。

STEP 1

深度探秘

兩端都能進出的混合體

雙端佇列 Deque

Deque(發音「deck」,雙端佇列 double-ended queue)也是線性結構,有 front 與 rear 兩端,但限制非常少:

  • 新項目可以從 front 或 rear 任一端加入。
  • 既有項目可以從任一端移除。

等於把堆疊與佇列的能力合而為一。但要注意:deque 不強制 LIFO 或 FIFO,順序規則要由你自己維持一致

flowchart LR
  FF[front 可進可出] --> A[項目] --> B[項目] --> RR[rear 可進可出]

小提醒:別把資料結構 deque 和佇列的移除操作 dequeue 搞混了,拼法不同。

💡
關鍵

deque 兩端都能自由進出,兼具堆疊與佇列能力,但順序規則要自己維持。

STEP 2

生活妙喻

兩頭都能上下車的車廂

兩頭都有門的車廂

堆疊像只有一個門的車廂(同門進出);佇列像前後門固定方向(後門上、前門下)。

Deque 則像前後兩個門都能自由上下車:你可以從前門上、也可以從後門上;可以從前門下、也可以從後門下。

這份自由很強大,但也代表規矩要你自己訂並遵守——如果你想用它當堆疊,就只在同一端進出;想當佇列,就一端進、另一端出。結構不會幫你把關。

💡
關鍵

deque 像兩頭都能上下車的車廂,自由度高,但秩序得靠使用者自律。

STEP 3

實用超能力

Deque ADT 的操作

Deque 的操作

操作 作用
Deque() 建立空 deque
add_front(item) 從 front 加入
add_rear(item) 從 rear 加入
remove_front() 從 front 移除並回傳
remove_rear() 從 rear 移除並回傳
is_empty() 是否為空
size() 項目數量

因為兩端都能操作,移動項目時務必清楚誰是 front、誰是 rear,否則很容易搞混。

下一節我們會看到 deque 的一個漂亮應用:用『同時從兩端取出比較』來檢查回文。

💡
關鍵

deque 有四個進出操作(前後各加減一個),使用時要隨時分清 front 與 rear。

🔆
生活妙喻:雙端佇列 ≈ 前後兩個門都能上下車的車廂

兩端都能進出,自由度最高,但秩序要自己維持。

🔆
生活妙喻:不強制 LIFO/FIFO ≈ 一把萬用工具

能當堆疊也能當佇列,但用哪種規則由你決定並貫徹。

本節字彙

雙端佇列 (deque)
兩端都能加入與移除的線性結構,發音 deck。
🧠 double-ended queue=雙端。
add_front / add_rear
分別從 front、rear 加入項目。
🧠 前加、後加都行。
remove_front / remove_rear
分別從 front、rear 移除項目。
🧠 前取、後取都行。
雙端佇列 (deque) 與一般佇列最大的不同是什麼?
課文說 deque『不要求 LIFO 與 FIFO 順序』,這代表什麼?
如果只用 deque 的 `add_rear` 與 `remove_rear`(都在同一端),它的行為等同於?

Deque 實作與回文檢查

用 list 實作 deque,並用「同時比較頭尾」的方式檢查字串是否為回文。

STEP 1

深度探秘

用 list 實作 Deque

Deque 的 Python 實作

假設 list 的 rear 在位置 0、front 在末端:

class Deque:
    def __init__(self):
        self.items = []
    def is_empty(self):
        return self.items == []
    def add_front(self, item):
        self.items.append(item)     # 末端=front,O(1)
    def add_rear(self, item):
        self.items.insert(0, item)  # 開頭=rear,O(n)
    def remove_front(self):
        return self.items.pop()     # O(1)
    def remove_rear(self):
        return self.items.pop(0)    # O(n)
    def size(self):
        return len(self.items)

效能觀察:因為 front 對應 list 末端,front 端操作是 $O(1)$、rear 端操作是 $O(n)$。重點還是要清楚哪一端被指定為 front、哪一端為 rear。

💡
關鍵

此 deque 實作 front 端操作 O(1)、rear 端 O(n),關鍵是分清兩端的對應。

STEP 2

生活妙喻

回文:從兩頭往中間夾

回文 Palindrome

回文是正著讀、反著讀都一樣的字串,如 radar、toot、madam。

用 deque 檢查回文的妙處在於:可以同時從兩端各取一個字元來比較

做法:把字串的每個字元依序 add_rear 進 deque(此時像普通佇列)。然後:

  • remove_front() 取出第一個字元、remove_rear() 取出最後一個字元。
  • 兩者相等就繼續往內夾,不相等就不是回文。
flowchart LR
  A[remove_front 取頭] --> C[比較]
  B[remove_rear 取尾] --> C
  C --> D[相等就繼續往中間夾]

就像兩隻手分別從字串頭尾往中間靠攏,一對一對核對。

💡
關鍵

deque 能同時從頭尾取字元比較,像兩手往中間夾,最適合檢查回文。

STEP 3

實用超能力

pal_checker 與終止條件

pal_checker 實作

def pal_checker(a_string):
    char_deque = Deque()
    for ch in a_string:
        char_deque.add_rear(ch)
    still_equal = True
    while char_deque.size() > 1 and still_equal:
        first = char_deque.remove_front()
        last = char_deque.remove_rear()
        if first != last:
            still_equal = False
    return still_equal

為什麼條件是 size() > 1 因為每次比較會移除兩個字元:

  • 字串長度為偶數:最後會剛好取完(size 變 0)。
  • 字串長度為奇數:最後會剩中間一個字元(size 變 1),它不需和任何人比,本來就對稱。

所以只要每一對都相等,不論奇偶,都是回文。

💡
關鍵

每輪移除頭尾兩字元比較,size 剩 0 或 1 時結束;全程相等即為回文。

🔆
生活妙喻:回文檢查 ≈ 兩隻手從字串頭尾往中間夾

一對一對核對,全部相等就是回文。

🔆
生活妙喻:奇數長度剩中間一個 ≈ 對折紙張時中線那格沒有對象

中間字元自己對稱,不需要比較。

本節字彙

回文 (palindrome)
正讀反讀皆相同的字串,如 radar。
🧠 前後對稱,照鏡子一樣。
add_rear / remove_front
回文檢查中先全部從 rear 加入,再從兩端取出比較。
🧠 先全進,再頭尾夾。
為什麼 deque 特別適合用來檢查回文?
pal_checker 的迴圈條件是 `size() > 1`,對奇數長度字串而言,迴圈結束時 deque 會?
檢查字串 'radar' 是不是回文,第一輪比較會取出哪兩個字元?
05

串列與鏈結串列 Linked List

串列的相對位置概念、無序串列的操作,以及鏈結串列的基本磚塊——節點。

無序串列 ADT 與 Node 節點

串列的相對位置概念、無序串列的操作,以及鏈結串列的基本磚塊——節點。

STEP 1

深度探秘

什麼是串列?為什麼要自己做?

自己打造 list

Python 內建的 list 很好用,但不是每個語言都內建。這時就得自己實作。

串列 (list) 是一個項目集合,每個項目相對於其他項目有個相對位置:有第一個、第二個、第三個……有開頭也有結尾。本節討論無序串列 (unordered list)(為簡化,假設不含重複項目)。

無序串列 ADT 的常見操作:

操作 作用
add(item) 加入一個項目
remove(item) 移除指定項目
search(item) 搜尋是否存在,回傳布林
is_empty() / size() 是否為空 / 數量
append / index / insert / pop 末端加入 / 找位置 / 指定位置插入 / 取出

例如分數 54, 26, 93, 17, 77, 31 就是一個簡單的無序串列。

💡
關鍵

無序串列是有相對位置的項目集合;當語言沒內建時,我們得自己實作它。

STEP 2

生活妙喻

尋寶遊戲的線索紙條

一張接一張的尋寶線索

鏈結串列不要求項目存放在連續的記憶體裡。項目可以散落各處,只要每個項目都記著『下一個在哪裡』就行。

這就像一場尋寶遊戲:每張線索紙條除了寫著寶物(資料),還寫著『下一張紙條藏在哪』(參照 reference)。

  • 你只要知道第一張紙條在哪(稱為 head),就能順著一張找一張,走完全程。
  • 最後一張紙條會說『沒有下一張了』——用特殊值 None 表示盡頭。
flowchart LR
  H[head] --> N1[節點 資料93 next]
  N1 --> N2[節點 資料17 next]
  N2 --> N3[節點 資料77 next]
  N3 --> X[None 盡頭]
💡
關鍵

鏈結串列像一串尋寶線索:每個節點記著資料與下一個的位置,靠 head 起步、None 收尾。

STEP 3

實用超能力

Node 節點類別

基本磚塊:Node

鏈結串列的基本單位是節點 (node),每個節點至少存兩樣東西:

  1. data:項目本身的值。
  2. next:指向下一個節點的參照(一開始是 None)。
class Node:
    def __init__(self, init_data):
        self.data = init_data
        self.next = None
    def get_data(self):
        return self.data
    def get_next(self):
        return self.next
    def set_data(self, new_data):
        self.data = new_data
    def set_next(self, new_next):
        self.next = new_next

建立節點:temp = Node(93)temp.get_data() 會得到 93。

None 的角色很重要:節點一建立時 next 就設為 None,代表『目前沒有下一個』。明確把初始 next 設成 None 是好習慣,這讓我們之後能用『是不是 None』來判斷是否走到了串列盡頭。

💡
關鍵

節點 = data + next 兩個欄位;next 為 None 代表沒有下一個節點。

🔆
生活妙喻:鏈結串列 ≈ 一張接一張的尋寶線索

每張紙條寫著寶物與下一張的位置,順著找就能走完。

🔆
生活妙喻:None 盡頭 ≈ 最後一張線索寫著『到此為止』

None 明確標示沒有下一個節點,方便判斷走到底了。

本節字彙

節點 (node)
鏈結串列的基本單位,含 data 與指向下一節點的 next。
🧠 一顆裝著資料、又牽著下一顆的珠子。
next 參照
節點中指向下一個節點的連結,盡頭為 None。
🧠 next=『下一個在哪』的箭頭。
None
Python 的特殊值,在此代表『沒有下一個節點』。
🧠 什麼都沒指到=盡頭。
鏈結串列相對於陣列,最重要的特性是什麼?
一個 Node 至少需要儲存哪兩樣東西?
在 Node 中,next 被設為 None 代表什麼?

鏈結串列:head 與 add

UnorderedList 只持有 head 參照,從頭加入新節點的兩步驟與順序為何重要。

STEP 1

深度探秘

串列只記住 head

UnorderedList 類別

鏈結串列由一串節點組成,彼此用 next 連著。只要知道第一個節點在哪,後面都能順著找到。因此 UnorderedList 只需維護一個指向第一個節點的參照——head

class UnorderedList:
    def __init__(self):
        self.head = None

重要:串列物件本身不包含任何節點,它只持有指向第一個節點的單一參照。

空串列時 head 就是 None。所以判斷是否為空很簡單:

def is_empty(self):
    return self.head == None

建構子(head=None)與 is_empty 的判斷彼此一致,這正是用 None 標示盡頭的好處。

💡
關鍵

串列只持有 head 一個參照;head 為 None 即代表空串列。

STEP 2

生活妙喻

從最容易的地方加新成員

新人插在最前面

要把新項目加進串列,該放哪裡?因為是無序串列,位置無所謂,那就放在最容易的地方。

鏈結串列只有一個入口——head。所以最簡單的就是把新節點放在最前面,當作新的第一個節點。

這也解釋了一個有趣現象:先加入的項目反而會被推到串列尾端。例如依序 add(31)add(77)add(54),最先加的 31 會在最後,最後加的 54 會在最前。

flowchart LR
  H[head] --> NEW[新節點 54] --> OLD[原本的第一個節點 26] --> R[其餘節點]
💡
關鍵

無序串列把新節點加在 head(最前面)最省事;結果是越早加入的越靠尾端。

STEP 3

實用超能力

add 的兩步驟,順序不能反

add 的兩個步驟

def add(self, item):
    temp = Node(item)        # 1. 建立新節點
    temp.set_next(self.head) # 2. 新節點的 next 指向原本的第一個節點
    self.head = temp         # 3. head 改指向新節點

關鍵在於第 2、3 步的順序絕對不能反

  • 正確:先讓新節點接上舊的第一個節點,再把 head 移過去。
  • 錯誤:若先改 head 指向新節點,原本那串節點就沒有任何參照指著它們,整串資料就此遺失、再也找不回來!

教訓:在鏈結串列裡改動連結時,務必確保『接上新的之前,不要先斷開唯一的舊連結』。

💡
關鍵

add 要先把新節點接到舊頭、再移動 head;順序反了會弄丟整串資料。

🔆
生活妙喻:head 參照 ≈ 尋寶遊戲的第一張線索

只要握住第一張,就能順藤摸瓜找到全部;弄丟它全盤皆失。

🔆
生活妙喻:add 順序不能反 ≈ 換繩子前先抓好新的一端

先牽上新繩再放開舊繩,否則整串會掉下去。

本節字彙

head
串列指向第一個節點的參照,空串列時為 None。
🧠 head=串列的『頭』,唯一入口。
add
在串列最前面插入新節點的操作。
🧠 新人插隊到最前面最快。
UnorderedList 物件本身儲存了什麼?
為什麼 add 把新項目加在串列最前面(head 處)?
依序 add(31)、add(77)、add(17) 後,串列從 head 往後的順序是?

走訪:size、search 與 remove

用 current 走訪節點計數與搜尋;remove 用 previous 與 current 兩個參照處理移除。

STEP 1

深度探秘

走訪 traversal 與 size、search

走訪:沿著 next 一個個拜訪

走訪 (traversal) 是系統性地拜訪每個節點:用一個外部參照 current 從 head 出發,每拜訪一個就沿著 next 移到下一個,直到遇上 None。

size:邊走邊數。

def size(self):
    current = self.head
    count = 0
    while current != None:
        count = count + 1
        current = current.get_next()
    return count

search:邊走邊找,找到就提早停。

def search(self, item):
    current = self.head
    found = False
    while current != None and not found:
        if current.get_data() == item:
            found = True
        else:
            current = current.get_next()
    return found

size 一定走到底($O(n)$);search 找到就停,最壞也是 $O(n)$。

💡
關鍵

走訪用 current 從 head 沿 next 移動;size 走到底計數、search 找到即停。

STEP 2

生活妙喻

remove 為何要兩個人同行

一前一後的『毛毛蟲爬行』

要移除一個節點,得讓它的前一個節點改指向它的後一個節點,把它『跳過』。

但鏈結串列無法往回走!當 current 停在要刪的節點上,已經來不及回頭改前一個的連結了。

解法:用兩個參照同行——

  • current:照常標記目前位置。
  • previous:永遠跟在 current 後面一個節點

移動時 previous 要先追上 current 的位置,current 才往前——這個一前一後的動作叫 inch-worming(毛毛蟲爬行)。當 current 停在目標,previous 剛好停在它前面,就能動手改連結了。

flowchart LR
  P[previous] --> C[current 目標節點] --> N[下一個節點]
  P -. 改指向 .-> N
💡
關鍵

鏈結串列不能回頭,所以 remove 用 previous 跟在 current 後面,才改得了前一個的連結。

STEP 3

實用超能力

remove 的程式與頭節點特例

remove 實作

def remove(self, item):
    current = self.head
    previous = None
    found = False
    while not found:
        if current.get_data() == item:
            found = True
        else:
            previous = current          # previous 先追上
            current = current.get_next()  # current 再前進
    if previous == None:               # 特例:要刪的是第一個節點
        self.head = current.get_next()
    else:
        previous.set_next(current.get_next())

頭節點特例:若要刪的就是第一個節點,迴圈一次都沒移動,previous 還是 None。這時不是改 previous,而是把 head 直接指向 current 的下一個,等於把第一個節點移除。

注意移動 previous 與 current 的順序也很重要:一定要 previous 先到 current 的位置,current 才能前進。

💡
關鍵

remove 用 found 控制迴圈;previous 為 None 時改 head(刪頭節點),否則改 previous 的 next。

🔆
生活妙喻:走訪 traversal ≈ 順著尋寶線索一站站拜訪

current 從第一張線索出發,一路跟著 next 走到底。

🔆
生活妙喻:previous 與 current 同行 ≈ 毛毛蟲爬行,後腳先追上前腳再前進

因為不能回頭,需有人守在目標前一格才改得了連結。

🔆
生活妙喻:頭節點特例 ≈ 刪掉隊伍第一個人要改隊伍入口

沒有前一個人,改的是 head 而非 previous。

本節字彙

走訪 (traversal)
用 current 從 head 沿 next 逐一拜訪節點的過程。
🧠 順著箭頭一路走訪。
current / previous
remove 時的兩個參照,current 在目標、previous 跟在後一步。
🧠 一前一後,像毛毛蟲。
inch-worming
previous 先追上 current 再讓 current 前進的移動方式。
🧠 毛毛蟲:後腳先動,前腳再動。
鏈結串列的走訪 (traversal) 是指什麼?
search 的迴圈條件是 `current != None and not found`,為什麼比 size 可能更快結束?
remove 為什麼需要 previous 這個額外參照?