01

線性結構與堆疊 Stack

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

先讀原文段落,旁邊就是白話

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

原文 · 線性結構與堆疊 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

top) 進出的有序集合,後進先出。">堆疊 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

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

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

先讀原文段落,旁邊就是白話

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

原文 · 堆疊的超能力:解決真實問題 e the remainders, as they are themselves represented as two-digit decimal numbers. Instead we need to create a set of digits that can be used to represent those remainders beyond 9. import Stack # As previously defined 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.
白話導讀

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

括號平衡檢查

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

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、先進先出原則,以及生活與電腦中的例子。

先讀原文段落,旁邊就是白話

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

原文 · 佇列 Queue 與模擬 It is also known as “first-come first-served. ” The simplest example of a queue is the typical line that we all participate in from time to time. We wait in a line for a movie, we wait in the check-out line at a grocery store, and we wait in the cafeteria line (so that we can pop the tray stack). Well-behaved lines, or queues, are very restrictive in that they have only one way in and only one way out.
白話導讀

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

佇列是什麼:FIFO 的排隊

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

STEP 1

深度探秘

先進先出 FIFO

front 移除的有序集合,先進先出。">佇列 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 sic data structures by looking at another variation on the theme of linear collections. However, unlike stack and queue, the deque (pronounced “deck”) has very few restrictions. Also, be careful that you do not confuse the spelling of “deque” with the queue removal operation “dequeue. A deque, also known as a double-ended queue, is an ordered collection of items similar to the queue.
白話導讀

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

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

先讀原文段落,旁邊就是白話

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

原文 · 串列與鏈結串列 Linked List Node Class The basic building block for the linked list implementation is the node. Each node object must hold at least two pieces of information. First, the node must contain the list item itself. We will call this the data field of the node.
白話導讀

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

無序串列 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

current 從 head 沿 next 逐一拜訪節點的過程。">走訪:沿著 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 這個額外參照?