01

認識樹:到處都是的階層結構

從生物分類、Unix 檔案系統與 HTML 認識樹的三大性質。

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

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

原文 · 認識樹:到處都是的階層結構 CHAPTER SIX TREES AND TREE ALGORITHMS 6. 1 Objectives • To understand what a tree data structure is and how it is used. • To see how trees can be used to implement a map data structure. • To implement trees using a list.
白話導讀

從生物分類、Unix 檔案系統與 HTML 認識樹的三大性質。

樹是什麼?生活中的樹狀結構

從生物分類、Unix 檔案系統與 HTML 認識樹的三大性質。

STEP 1

深度探秘

把資料排成有階層的關係

樹不是一條線

我們之前學的堆疊、佇列都是線性結構:資料像排隊一樣一個接一個。但真實世界裡很多關係不是一條線,而是一層分出好幾支的階層關係,這就需要「樹」。

樹這個資料結構有趣的地方是:它的根 (root) 在最上面,葉子 (leaf) 在最下面,跟你窗外那棵樹剛好上下顛倒。

從根開始,你可以沿著一個個節點與箭頭往下走。每到一層就像在問一個問題、依答案選一條路。例如生物分類:先問「這動物是脊索動物還是節肢動物?」選了脊索動物,再問「是不是哺乳類?」一路問下去,最後走到最底層的物種名稱。

樹有三個關鍵性質:

  • 子節點彼此獨立:某節點的孩子改了,不會影響另一個節點的孩子。
  • 每個葉節點唯一:從根到任一葉節點有一條唯一的路徑可以辨認它。
  • 可整段搬移:你可以把一整段子樹搬到別的位置,而不破壞它內部的結構。
💡
關鍵

樹是用來表達階層關係的非線性資料結構,根在上、葉在下。

STEP 2

生活妙喻

電腦裡的資料夾就是一棵樹

你每天都在爬樹

想想你電腦裡的檔案系統。最外層有一個根目錄(在 Unix 裡就是 /),裡面有 etc/usr/ 這些資料夾,資料夾裡又有子資料夾與檔案——這完完全全就是一棵樹!

從根目錄走到任何一個資料夾,這條路徑唯一指出了它的位置,例如 /etc/httpd

而「可整段搬移」這個性質也很直覺:你把 etc/ 整個資料夾從根目錄剪下、貼到 usr/ 底下,httpd 的路徑會從 /etc/httpd 變成 /usr/etc/httpd,但 httpd 裡面的內容與子資料夾完全不受影響。搬家換了地址,但家裡的傢俱還是原封不動。

graph TD
  root[根目錄] --> etc[etc]
  root --> usr[usr]
  root --> var[var]
  etc --> httpd[httpd]
  var --> log[log]
  var --> spool[spool]
💡
關鍵

檔案系統的資料夾階層就是一棵樹,搬移子樹不影響其內部內容。

STEP 3

實用超能力

從 HTML 看出網頁的樹

網頁也是一棵樹

你看到的每個網頁,背後的 HTML 標籤也是巢狀的階層。最外層是 <html>,裡面包 <head><body><body> 裡又包 <h1><ul><ul> 裡再包 <li>

每一層巢狀,就對應樹的一層。瀏覽器把這棵樹叫做 DOM,靠它來排版與互動。

<html>
  <head>
    <title>simple</title>
  </head>
  <body>
    <h1>A simple web page</h1>
    <ul>
      <li>List item one</li>
      <li>List item two</li>
    </ul>
  </body>
</html>

當你能「看出」資料中的樹狀結構,就能用樹的演算法去處理它:搜尋、走訪、整段搬移都變得有章法可循。

💡
關鍵

凡是有巢狀、階層關係的資料(檔案、HTML、分類),都能用樹來表示與處理。

🔆
生活妙喻:樹的結構 ≈ 公司組織圖

最上面是執行長(根),往下是各部門主管,再往下是組員(葉)。每個人只有一個直屬上司(一條進入的邊),但可以帶很多下屬。

🔆
生活妙喻:子節點獨立性 ≈ 兩個不同家庭都有小孩叫小明

張家的小明和李家的小明同名,但是兩個完全獨立的人。改張家小明的事,不會動到李家小明。

本節字彙

樹 (tree)
用來表達階層關係的資料結構,由節點與連接它們的邊組成,根在最上方。
🧠 想成上下顛倒的真樹:根在天上、葉在地上。
子樹 (subtree)
某個節點連同它底下所有後代,自成一棵小樹。
🧠 樹的任何一根樹枝,連同上面的葉子,都是一棵迷你樹。
下列哪一個是「樹」這種資料結構最貼切的描述?
在 Unix 檔案系統中把 `etc/` 整個資料夾從根目錄搬到 `usr/` 底下,下列敘述何者正確?
為什麼說「從根到每個葉節點的路徑是唯一的」這個性質很有用?

樹的詞彙:節點、邊、根、葉

認識 node、edge、root、path、children、parent、sibling、subtree、leaf、level、height 等術語。

STEP 1

深度探秘

先把零件名稱搞清楚

樹的零件清單

要談樹的演算法,得先有共同語言。以下是核心詞彙:

  • 節點 (node):樹的基本單位。它有一個名字叫鍵 (key),還可能附帶額外資料叫酬載 (payload)
  • 邊 (edge):連接兩個節點、表示它們有關係。除了根,每個節點恰有一條進入的邊;每個節點可以有多條外出的邊。
  • 根 (root):唯一沒有進入邊的節點。
  • 路徑 (path):用邊串起來的一串有序節點。
  • 子節點 (children):從同一個節點伸出邊所連到的那些節點。
  • 父節點 (parent):用外出邊連到某些節點的那個節點。
  • 兄弟 (sibling):同一個父節點下的節點。
  • 子樹 (subtree):一個父節點連同它所有後代。
  • 葉節點 (leaf):沒有子節點的節點。
💡
關鍵

節點是基本單位,邊表示關係;除了根,每個節點恰有一條進入的邊。

STEP 2

生活妙喻

家族族譜上的稱謂

樹的詞彙就是族譜稱謂

樹的術語幾乎照搬家族關係:

樹的術語 家族對應
根 (root) 最年長的祖先
父節點 (parent) 上一代
子節點 (children) 下一代
兄弟 (sibling) 同一對父母的孩子
葉節點 (leaf) 還沒有後代的人
子樹 (subtree) 某個人連同他所有後代組成的家族分支

唯一和真實家族不同的是:在樹裡每個人只有「一個」父親(一條進入的邊),不能有兩個父節點。

graph TD
  A[根 祖父] --> B[父 爸爸]
  A --> C[父 叔叔]
  B --> D[葉 我]
  B --> E[葉 妹妹]
💡
關鍵

把樹想成族譜,根是祖先、葉是最年輕一代、兄弟是同父節點的孩子。

STEP 3

實用超能力

算出層數與高度

層數與高度怎麼算

還有兩個常被搞混的詞:

  • 層 (level):節點 $n$ 的層數,是從根到 $n$ 這條路徑上的邊數。根的層數定義為 $0$。
  • 高度 (height):整棵樹中任一節點的最大層數

舉例:若 Felis 從根走下來要經過 5 條邊,它的層數就是 $5$。若整棵樹最深的節點層數是 $2$,這棵樹的高度就是 $2$。

為什麼要關心高度?因為很多樹演算法的效率取決於高度。一棵平衡良好的樹高度大約是 $O(\log n)$,搜尋會很快;一棵歪斜的樹高度可能變成 $O(n)$,就退化得跟串列一樣慢。

所以記住:層數從 0 算起、是「邊數」;高度是最深的那個層數。

💡
關鍵

層數是從根到該節點的邊數(根為 0),高度是最大層數,決定演算法效率。

🔆
生活妙喻:邊的進入限制 ≈ 每個人只有一位親生母親

除了最初的祖先(根),每個人都恰好由一條邊從父節點連入,就像每個人都只有一位親生母親。

🔆
生活妙喻:層數 ≈ 捷運從起站算過了幾站

層數是從根走過的邊數,就像從起站算你經過了幾段鐵軌;起站本身是第 0 站。

本節字彙

鍵與酬載 (key / payload)
鍵是節點的名字,酬載是節點額外附帶的資料。
🧠 鍵是門牌號碼,酬載是屋子裡住的東西。
高度 (height)
整棵樹中任一節點的最大層數,反映樹有多深。
🧠 高度=樹最深的那條路有幾條邊。
葉節點 (leaf node)
沒有任何子節點的節點,位於樹的末端。
🧠 樹枝的最末端,長不出更多枝葉了。
關於「根節點」,下列哪一項是它最關鍵的特徵?
某節點從根走下來經過了 3 條邊,它的層數是多少?
在族譜式的樹中,「兄弟 (sibling)」指的是哪些節點?

兩種定義:節點邊定義與遞迴定義

用節點與邊的定義,以及更實用的遞迴定義來精確描述樹與二元樹。

STEP 1

深度探秘

用節點與邊正式定義樹

定義一:節點與邊

有了詞彙,我們可以正式定義樹。第一種定義用節點與邊:

一棵樹由一組節點與一組連接節點對的邊組成,且滿足:

  • 其中一個節點被指定為根節點
  • 除了根,每個節點 $n$ 都由一條邊從恰好一個其他節點 $p$ 連入,$p$ 是 $n$ 的父節點。
  • 從根到每個節點都有一條唯一路徑
  • 如果每個節點最多兩個子節點,我們就說這是一棵二元樹 (binary tree)

邊上的箭頭指出連接的方向(由父指向子)。這個定義很直觀,但接下來那個遞迴定義,寫程式時會更好用。

💡
關鍵

定義一用節點、邊與唯一路徑描述樹;每節點最多兩子即為二元樹。

STEP 2

生活妙喻

俄羅斯娃娃裡還是娃娃

定義二:遞迴定義

第二種定義用遞迴來描述:

一棵樹要嘛是空的要嘛由一個根加上零或多個子樹組成,而每個子樹本身也是一棵樹。每個子樹的根,用一條邊連到母樹的根。

這就像俄羅斯娃娃:打開一個娃娃,裡面還是一個娃娃;再打開,裡面還是娃娃……每一層都跟整體長得一樣。

樹也是如此:一棵樹的任何一根樹枝(子樹),自己看起來也是一棵完整的樹。

graph TD
  R[根] --> S1[子樹一也是一棵樹]
  R --> S2[子樹二也是一棵樹]
  R --> S3[子樹三也是一棵樹]

看著上圖,我們知道至少有 4 個節點:根加上三個子樹各自的根。但實際可能更多,要往裡看才知道。

💡
關鍵

遞迴定義:樹是空的,或由根加上若干「本身也是樹」的子樹組成。

STEP 3

實用超能力

遞迴定義讓程式碼變優雅

為什麼遞迴定義這麼好用

遞迴定義的威力在於:只要結構是遞迴的,處理它的演算法也能寫成遞迴,而且通常短得驚人。

例如要走訪整棵樹,你只要寫:

def visit(tree):
    if tree is None:        # 基底情形:空樹什麼都不做
        return
    process(tree)           # 處理根
    visit(tree.left)        # 子樹也是樹,遞迴處理
    visit(tree.right)

注意這段程式幾乎就是定義二的直接翻譯:「空樹就停,否則處理根、再處理每個子樹(它們也是樹)」。

後面我們實作堆積、剖析樹、二元搜尋樹時,會一再看到這種「把遞迴定義直接寫成遞迴函式」的優雅模式。先建立這個直覺,後面學起來會很順。

💡
關鍵

遞迴定義讓樹演算法能直接寫成遞迴:基底處理空樹,遞迴步驟處理各子樹。

🔆
生活妙喻:遞迴定義 ≈ 俄羅斯娃娃

打開娃娃裡面還是娃娃,每一層都和整體同構;樹的每個子樹也都是一棵完整的樹。

🔆
生活妙喻:二元樹 ≈ 只能生兩個孩子的家庭

二元樹規定每個節點最多兩個子節點,就像一個限定最多兩個孩子的家庭。

本節字彙

二元樹 (binary tree)
每個節點最多有兩個子節點(左、右)的樹。
🧠 binary=二,每個節點最多兩條外出邊。
遞迴定義 (recursive definition)
用自己定義自己的方式:樹由根加上若干個本身也是樹的子樹組成。
🧠 定義裡又出現了『樹』,這就是遞迴。
依照樹的「遞迴定義」,下列哪一個敘述正確?
什麼條件下我們稱一棵樹為「二元樹」?
為什麼遞迴定義在寫樹的演算法時特別好用?
02

親手實作二元樹

用 [根, 左子樹, 右子樹] 的巢狀串列來表示二元樹,並寫出操作函式。

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

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

原文 · 親手實作二元樹 left and right children, as well as the root values. def get_right_child(self): return self. right_child def get_left_child(self): return self. left_child def set_root_val(self,obj): self.
白話導讀

用 [根, 左子樹, 右子樹] 的巢狀串列來表示二元樹,並寫出操作函式。

串列的串列表示法

用 [根, 左子樹, 右子樹] 的巢狀串列來表示二元樹,並寫出操作函式。

STEP 1

深度探秘

用三格的串列裝下一棵樹

一個串列,三個位置

第一種實作二元樹的方式叫「串列的串列 (list of lists)」。核心想法很簡單:用一個有三個位置的串列來代表一個節點:

  • 位置 [0]:根節點的值
  • 位置 [1]:左子樹(本身也是一個這種三格串列)
  • 位置 [2]:右子樹(同上)

例如這棵樹:

my_tree = ['a',                  # 根
            ['b',                # 左子樹
              ['d', [], []],
              ['e', [], []]],
            ['c',                # 右子樹
              ['f', [], []],
              []]]

要取用子樹就用一般的串列索引:my_tree[0] 是根、my_tree[1] 是左子樹、my_tree[2] 是右子樹。空的 [] 代表沒有子樹。一個有值且兩個子樹都空的串列,就是一個葉節點。

💡
關鍵

用 [根, 左子樹, 右子樹] 的三格串列表示節點,子樹本身也是同樣的三格串列。

STEP 2

生活妙喻

結構本身就會遞迴

自我相似的盒子

這個表示法最妙的地方是:結構本身就是遞迴的。代表子樹的那個串列,格式跟代表整棵樹的串列一模一樣——都是「值、左、右」三格。

這就像一個分隔成三格的便當盒:第一格放主菜(根),另外兩格各放一個一模一樣的小便當盒(左、右子樹),打開小盒又是三格……

而且它還能輕鬆推廣到「多元樹」:如果一個節點不只兩個子節點,再多塞幾個子串列進去就行。

graph TD
  a[a 根] --> b[b]
  a --> c[c]
  b --> d[d]
  b --> e[e]
  c --> f[f]

上圖這棵樹,就對應前一頁那個巢狀串列。看圖、看串列,兩者結構完全吻合。

💡
關鍵

子樹串列的格式與整棵樹相同,結構自我相似,而且容易推廣到多元樹。

STEP 3

實用超能力

寫出操作函式

用函式操作這個串列樹

我們不另外定義類別,只寫一些函式把普通串列當樹用。建構與插入左子節點如下:

def binary_tree(r):
    return [r, [], []]

def insert_left(root, new_branch):
    t = root.pop(1)
    if len(t) > 1:
        root.insert(1, [new_branch, t, []])
    else:
        root.insert(1, [new_branch, [], []])
    return root

關鍵巧思在 insert_left:先把目前的左子樹取出來 t。如果它非空(len(t) > 1),就建立新節點、把舊的左子樹當成新節點的左子,再裝回去——也就是把舊子樹往下推一層

insert_right 對稱地處理右邊。再加上 get_root_valset_root_valget_left_childget_right_child 這些存取函式,就能完整地建構與走訪這棵串列樹了。

💡
關鍵

insert_left 取出舊左子樹,把它推到新節點底下,再插回位置 1。

🔆
生活妙喻:串列的串列 ≈ 三格便當盒裡再放小便當盒

主格放根的值,另外兩格各放一個格式相同的小便當盒(左右子樹),層層相套。

🔆
生活妙喻:插入時把舊子節點往下推 ≈ 排隊插隊把後面的人往後推

在父與舊左子之間插入新節點時,舊左子被推到新節點下方一層,而不是被丟掉。

本節字彙

串列的串列 (list of lists)
用 [根, 左子樹, 右子樹] 的巢狀串列表示二元樹的方法。
🧠 一個串列裡又裝串列,三格一組。
葉節點的串列形式
形如 [值, [], []],有值但兩個子樹都是空串列的節點。
🧠 兩格都空空如也,就到末梢了。
在串列的串列表示法中,`my_tree[2]` 代表什麼?
下列哪一個串列表示一個葉節點(值為 'x')?
`insert_left` 在目前已有左子樹的情況下做了什麼?

節點與參照表示法

用 BinaryTree 類別,以 left_child 與 right_child 參照來表示樹,更貼近物件導向。

STEP 1

深度探秘

每個節點都是一個物件

用類別與參照表示樹

第二種實作方式叫「節點與參照 (nodes and references)」,更貼近物件導向的思維。我們定義一個 BinaryTree 類別,每個節點是一個物件,內含:

  • key:根節點的值
  • left_child:對左子節點物件的參照(一開始是 None
  • right_child:對右子節點物件的參照
class BinaryTree:
    def __init__(self, root):
        self.key = root
        self.left_child = None
        self.right_child = None

重點是:left_childright_child 會變成指向其他 BinaryTree 實例的參照。當我們插入一個左子節點,就建立另一個 BinaryTree 物件,並讓根的 left_child 指向它。本章接下來都會用這種表示法。

💡
關鍵

每個節點是一個 BinaryTree 物件,left_child/right_child 是指向其他節點物件的參照。

STEP 2

生活妙喻

便利貼上寫著下一張的位置

參照就像便利貼上的指路

想像每個節點是一張便利貼,上面寫著自己的值,還寫著「左邊那張貼在哪裡、右邊那張貼在哪裡」。這個「貼在哪裡」就是參照——它不是子節點本身,而是指向子節點的箭頭。

graph TD
  a[a key] --> b[b left_child]
  a --> c[c right_child]

插入時要分兩種情況:

  • 沒有左子:直接建一個新節點,讓 left_child 指向它。
  • 已有左子:建新節點,把舊的左子接到新節點的 left_child,再讓自己指向新節點——也就是把舊子節點往下推一層,跟串列版的巧思一模一樣。
def insert_left(self, new_node):
    if self.left_child == None:
        self.left_child = BinaryTree(new_node)
    else:
        t = BinaryTree(new_node)
        t.left_child = self.left_child
        self.left_child = t
💡
關鍵

插入分兩種情況:沒有子節點就直接接上,已有子節點就把舊的往下推一層。

STEP 3

實用超能力

子節點也是一棵完整的樹

任一子節點本身也是樹

補上存取方法後,類別就完整了:

def get_left_child(self):
    return self.left_child
def get_right_child(self):
    return self.right_child
def set_root_val(self, obj):
    self.key = obj
def get_root_val(self):
    return self.key

來建一棵小樹試試:

r = BinaryTree('a')
r.insert_left('b')
r.insert_right('c')
print(r.get_left_child().get_root_val())   # b
r.get_right_child().set_root_val('hello')
print(r.get_right_child().get_root_val())  # hello

注意 r.get_left_child() 回傳的本身就是一個 BinaryTree 物件,所以你可以對它再呼叫 get_root_val()、甚至再 insert_left()

這正呼應了遞迴定義:任一子節點本身也是一棵完整的二元樹。這個性質讓後面的遞迴演算法寫起來特別自然。

💡
關鍵

存取方法回傳的子節點本身是 BinaryTree 物件,可繼續對它操作,呼應遞迴定義。

🔆
生活妙喻:參照 (reference) ≈ 便利貼上寫著下一張貼在哪

left_child 不是子節點本身,而是指向子節點物件的箭頭,就像便利貼上指路的字條。

🔆
生活妙喻:插入時把舊子節點下推 ≈ 在父與子之間塞進一個中間人

已有左子時,新節點成為中間人,舊的左子被掛到新節點底下,整體往下移一層。

本節字彙

參照 (reference)
指向另一個物件的連結;這裡用來讓父節點指向子節點物件。
🧠 不是東西本身,而是指向它的箭頭。
BinaryTree 類別
封裝 key 與左右子節點參照的節點物件,子節點也是同類別實例。
🧠 一個物件=一個節點,左右各掛一個同類物件。
在節點與參照表示法中,`left_child` 屬性存的是什麼?
對一個「已經有左子節點」的節點呼叫 `insert_left('x')`,結果是什麼?
`r.get_left_child()` 回傳的東西,為什麼可以接著呼叫 `.get_root_val()` 或 `.insert_left()`?
03

優先佇列與二元堆積

認識優先佇列,以及為什麼用二元堆積能比排序串列更有效率。

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

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

原文 · 優先佇列與二元堆積 rent node to the operator represented by the current token. Add a new node as the right child of the current node and descend to the right child. If the current token is a number, set the root value of the current node to the number and return to the parent. If the current token is a ‘)’, go to the parent of the current node.
白話導讀

認識優先佇列,以及為什麼用二元堆積能比排序串列更有效率。

優先佇列與二元堆積的概念

認識優先佇列,以及為什麼用二元堆積能比排序串列更有效率。

STEP 1

深度探秘

依優先序而非到達順序

不照先來後到,照優先序

之前學的佇列 (queue) 是先進先出 (FIFO):誰先排隊誰先出。優先佇列 (priority queue) 也是從前端取出,但「誰在前端」不是看誰先到,而是看優先序

  • 最高優先序的項目永遠在最前面,最低優先序在最後面。
  • 因此 enqueue(加入)一個新項目時,它可能會一路插到最前面。

你可能會想:用排序加串列不就好了?確實可以,但:

  • 在串列中插入是 $O(n)$
  • 對串列排序是 $O(n \log n)$

我們可以做得更好。經典做法是用一種叫二元堆積 (binary heap) 的資料結構,它能讓 enqueue 與 dequeue 都只要 $O(\log n)$。

💡
關鍵

優先佇列依優先序取出;二元堆積讓加入與取出都達到 O(log n),勝過排序串列。

STEP 2

生活妙喻

醫院急診的檢傷分類

急診室不是先到先看

優先佇列最貼切的比喻是醫院急診的檢傷分類。你不是按掛號順序看診,而是按病情嚴重程度:心臟病發的人就算最後到,也會被立刻推到最前面;輕微感冒的人就算先到,也得等。

這就是優先佇列:到達順序不重要,優先序才決定誰先被處理。

二元堆積有兩種常見變體:

變體 誰在最前面
最小堆積 (min heap) 最小的鍵
最大堆積 (max heap) 最大的鍵

本章我們實作最小堆積(min heap),最大堆積留作練習。要注意:「最小」或「最大」是指鍵值,可以把鍵想成優先序的數字(例如越小越緊急)。

💡
關鍵

優先佇列像急診檢傷:嚴重程度(優先序)決定先後;堆積分 min 與 max 兩種。

STEP 3

實用超能力

看堆積怎麼運作

不管怎麼放進去,最小的先出來

二元堆積的介面長這樣:

  • BinaryHeap():建立空堆積
  • insert(k):加入一個項目
  • find_min():回傳最小鍵的項目,但不移除
  • del_min():回傳並移除最小鍵的項目
  • is_empty()size()build_heap(list)

來看一段示範:

bh = BinHeap()
bh.insert(5)
bh.insert(7)
bh.insert(3)
bh.insert(11)
print(bh.del_min())  # 3
print(bh.del_min())  # 5
print(bh.del_min())  # 7
print(bh.del_min())  # 11

注意:不管我們以什麼順序丟進去(5、7、3、11),del_min 每次都取出當前最小的。這正是優先佇列要的行為。下一節我們就來看它內部如何用一個串列就辦到這件事。

二元堆積在下一章的圖演算法(如最短路徑)會非常有用,先打好底子。

💡
關鍵

不論插入順序為何,del_min 每次都取出當前最小鍵,正是優先佇列的核心行為。

🔆
生活妙喻:優先佇列 ≈ 急診室檢傷分類

病人不是按到達順序看診,而是按病情嚴重程度;最緊急的就算最後到也先被處理。

🔆
生活妙喻:二元堆積勝過排序串列 ≈ 與其每次全部重排,不如只調整一條路徑

排序串列每次插入要 O(n) 或排序 O(n log n);堆積只沿一條路徑調整,達到 O(log n)。

本節字彙

優先佇列 (priority queue)
依項目優先序而非到達順序取出的佇列,最高優先在最前。
🧠 急診排隊,重症先看。
二元堆積 (binary heap)
用來高效實作優先佇列的資料結構,enqueue 與 dequeue 皆 O(log n)。
🧠 形狀像樹,骨子裡只是一個串列。
最小堆積 (min heap)
最小鍵永遠在最前端的堆積;最大堆積則相反。
🧠 min=小,最小的當老大坐最前面。
優先佇列與普通佇列最關鍵的差別是什麼?
用「排序串列」實作優先佇列,為什麼效率不夠好?
依序執行 `insert(5)`、`insert(7)`、`insert(3)`、`insert(11)` 後,第一次 `del_min()` 回傳什麼?

結構性質與順序性質

完全二元樹可用單一串列表示,並用 2p、2p+1 找子節點;堆積順序性質保證父節點不大於子節點。

STEP 1

深度探秘

完全二元樹與一個串列

結構性質:完全二元樹

要讓堆積有效率,我們利用二元樹的對數特性,但前提是要讓樹平衡。堆積的做法是維持一棵完全二元樹 (complete binary tree)

每一層都被節點填滿,唯一的例外是最底層,而最底層也要從左到右依序填。

完全二元樹有個迷人的性質:可以只用一個串列來表示,不需要節點與參照,也不需要串列的串列。因為樹是完全的,我們能用簡單的數學在串列中找到父子關係:

  • 父節點在位置 $p$,它的左子在位置 $2p$,右子在位置 $2p+1$。
  • 反過來,某節點在位置 $n$,它的父節點在位置 $n // 2$(整數除法)。

這幾條算式讓我們只用一個串列,就能在堆積裡快速上下移動。

💡
關鍵

完全二元樹每層填滿、底層從左到右填,可用單一串列表示:左子 2p、右子 2p+1、父 n//2。

STEP 2

生活妙喻

整齊的劇院座位編號

像劇院座位一樣有規律

把完全二元樹想成一座從上往下、從左往右坐滿的劇院:第一排坐 1 個(根),第二排坐 2 個,第三排坐 4 個……每排都坐滿才開放下一排,且每排都從左邊開始坐。

因為座位這麼規律,我們只要知道座號(串列位置),就能用算式直接算出父母與子女的座號——完全不必畫箭頭。

graph TD
  p1[位置 1 根] --> p2[位置 2 左子]
  p1 --> p3[位置 3 右子]
  p2 --> p4[位置 4]
  p2 --> p5[位置 5]
  p3 --> p6[位置 6]

注意:堆積串列的第 0 格放一個沒用到的 0,從位置 1 才開始放真正的資料。這個小技巧讓 $2p$、$2p+1$、$n//2$ 這些整數除法算得乾淨俐落。

💡
關鍵

完全二元樹像從上到下、從左到右坐滿的劇院,座號即串列位置,父子關係可直接算。

STEP 3

實用超能力

堆積順序性質

順序性質:父不大於子

光有形狀還不夠,堆積還要維持堆積順序性質 (heap order property)

在最小堆積中,對於每個節點 $x$ 及其父節點 $p$,$p$ 的鍵小於或等於 $x$ 的鍵。

換句話說,父節點永遠不會比子節點大。這條規則一路傳遞下去,就保證了整棵樹的最小值一定在根(位置 1)——這就是為什麼 find_min 可以是 $O(1)$,直接看根就好。

注意堆積順序性質只規範父子之間,不規範兄弟之間的大小,也不像二元搜尋樹那樣左小右大。所以同一組資料可以對應多種合法的堆積排列。

把兩件事合起來記:

性質 規範什麼 帶來什麼好處
結構性質 形狀是完全二元樹 高度約 log n、可用串列表示
順序性質 父鍵 ≤ 子鍵 最小值永遠在根
💡
關鍵

堆積順序性質要求父鍵不大於子鍵,於是最小值必在根,find_min 為 O(1)。

🔆
生活妙喻:完全二元樹 ≈ 從上往下、從左往右坐滿的劇院

每排坐滿才開放下一排,且都從左邊開始坐,只有最後一排可能沒坐滿。

🔆
生活妙喻:堆積順序性質 ≈ 金字塔的管理階層

每位主管(父)的『緊急程度數字』都不大於其下屬(子),所以最緊急的(最小值)一定坐在金字塔頂端。

本節字彙

完全二元樹 (complete binary tree)
每層都填滿、僅最底層可能未滿且從左到右填的二元樹。
🧠 從左到右、從上到下坐滿的劇院。
堆積順序性質 (heap order property)
每個父節點的鍵都小於等於其子節點的鍵(最小堆積)。
🧠 父母永遠不比孩子大,最小的當頭。
父子位置公式
位置 p 的左子在 2p、右子在 2p+1、父在 n 整除 2。
🧠 乘 2 往下、除 2 往上。
在堆積的串列表示法中,位置 $p$ 的節點,其左子節點在哪個位置?
某節點在串列位置 7,它的父節點在哪個位置?
「堆積順序性質」(最小堆積)規定的是什麼?

堆積操作:上浮、下沉與建堆

用 perc_up、perc_down 與 build_heap 維持堆積性質,並理解 build_heap 的 O(n)。

STEP 1

深度探秘

插入靠上浮 perc_up

insert:先附加,再上浮

建構子很簡單:堆積就是一個串列,第 0 格放沒用到的 0,再加一個 current_size

class BinHeap:
    def __init__(self):
        self.heap_list = [0]
        self.current_size = 0

插入時最快的做法是把新項目附加到串列尾端——這保證仍是完全二元樹(結構性質沒破)。壞消息是它可能破壞順序性質。解法是上浮 (percolate up):把新項目和它的父節點比較,如果比父節點小就交換,一路往上浮到正確位置。

def perc_up(self, i):
    while i // 2 > 0:
        if self.heap_list[i] < self.heap_list[i // 2]:
            tmp = self.heap_list[i // 2]
            self.heap_list[i // 2] = self.heap_list[i]
            self.heap_list[i] = tmp
        i = i // 2

def insert(self, k):
    self.heap_list.append(k)
    self.current_size += 1
    self.perc_up(self.current_size)

因為只沿著一條路往上,最多走樹的高度層,所以 insert 是 $O(\log n)$。

💡
關鍵

insert 先把新項目附加到尾端(保結構),再用 perc_up 與父節點比較交換往上浮(保順序)。

STEP 2

生活妙喻

刪除最小值靠下沉 perc_down

del_min:搬尾端到根,再下沉

最小值就在根(位置 1),取出很容易;難的是取走後怎麼修復。做法分兩步:

  1. 串列最後一個項目搬到根的位置——這維持了完全二元樹的結構,但很可能破壞順序性質。
  2. 下沉 (percolate down) 把這個新根往下推到正確位置。

下沉時每一步都和較小的那個子節點交換,直到它比兩個子節點都小為止。

def perc_down(self, i):
    while (i * 2) <= self.current_size:
        mc = self.min_child(i)
        if self.heap_list[i] > self.heap_list[mc]:
            tmp = self.heap_list[i]
            self.heap_list[i] = self.heap_list[mc]
            self.heap_list[mc] = tmp
        i = mc

def min_child(self, i):
    if i * 2 + 1 > self.current_size:
        return i * 2
    elif self.heap_list[i*2] < self.heap_list[i*2 + 1]:
        return i * 2
    else:
        return i * 2 + 1

為什麼要跟較小的子交換?因為交上去的那個會變成新父節點,必須比另一個子小,才不會又違反順序性質。這就像公司升職:要升就升兩個下屬中比較資深(鍵較小)的,否則又得重來。

💡
關鍵

del_min 把尾端搬到根,再 perc_down 與較小的子節點逐層交換,直到比兩子都小。

STEP 3

實用超能力

建堆 build_heap 的 O(n) 魔法

從一整串資料一次建好堆積

如果手上已有一整個串列,要怎麼變成堆積?直覺做法是逐一 insert,但那要 $O(n \log n)$。更聰明的是 build_heap

def build_heap(self, a_list):
    i = len(a_list) // 2
    self.current_size = len(a_list)
    self.heap_list = [0] + a_list[:]
    while i > 0:
        self.perc_down(i)
        i = i - 1

關鍵在於從中間往根節點方向、對每個節點做 perc_down。為什麼從 len // 2 開始?因為超過一半位置的節點全是葉節點,沒有子節點,不需要下沉。

令人驚訝的是,這樣建堆只要 $O(n)$,比逐一插入快!直覺是:$\log n$ 來自樹的高度,但 build_heap 大部分的工作發生在接近底部、樹很矮的地方,所以總成本遠低於 $n \log n$。

之後你會在練習中用 build_heap 寫出一個 $O(n \log n)$ 的排序演算法(堆積排序)。

💡
關鍵

build_heap 從中間位置往根對每個節點 perc_down,葉節點免做,總成本只要 O(n)。

🔆
生活妙喻:上浮 perc_up ≈ 氣泡在水中往上冒

新加入的小項目像氣泡,只要比上面的父節點輕(小)就往上冒,直到浮到合適的深度。

🔆
生活妙喻:下沉 perc_down 選較小的子 ≈ 升職要升較資深的下屬

把根往下換時必須和較小(較資深)的子交換,新父才會比另一個子小,避免再度違反順序。

🔆
生活妙喻:build_heap 的 O(n) ≈ 大部分工作都在矮樹叢進行

底層節點又多又矮,下沉沒幾步;高的工作很少。整體加起來只要 O(n) 而非 O(n log n)。

本節字彙

上浮 (perc_up)
把新加入的項目與父節點比較交換,往上移到正確位置的過程。
🧠 小東西像氣泡往上冒。
下沉 (perc_down)
把根上的項目與較小的子節點交換,往下移到正確位置的過程。
🧠 大東西像石頭往下沉。
建堆 (build_heap)
從一整個串列一次建成堆積,只需 O(n) 時間。
🧠 一次到位,比逐個插入更快。
`insert` 為什麼要先把新項目附加到串列尾端,再做 `perc_up`?
`del_min` 取走根之後,為什麼把「最後一個項目」搬到根的位置?
`perc_down` 時為什麼要和「較小的」那個子節點交換,而不是隨便挑一個?
04

樹的應用與走訪

把完全加括號的運算式變成剖析樹,再遞迴求值。

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

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

原文 · 樹的應用與走訪 • del Delete the key-value pair from the map using a statement of the form del map[key]. • len() Return the number of key-value pairs stored in the map. • in Return True for a statement of the form key in map, if the given key is in the map. 2 Search Tree Implementation A binary search tree relies on the property that keys that are less than the parent are found in the left subtree, and keys that are greater than the parent are found in the right subtree.
白話導讀

把完全加括號的運算式變成剖析樹,再遞迴求值。

剖析樹:建立與求值

把完全加括號的運算式變成剖析樹,再遞迴求值。

STEP 1

深度探秘

把運算式變成一棵樹

剖析樹是什麼

剖析樹 (parse tree) 能把真實世界的結構(句子、數學運算式)表示成樹。以數學運算式為例,$((7 + 3) * (5 - 2))$ 可以畫成一棵樹:根是 *,左子樹是 +(7 與 3),右子樹是 -(5 與 2)。

樹的階層自然反映了運算順序:要算頂層的乘法,得先算兩個括號裡的加法與減法。先把左子樹算成 10、右子樹算成 3,再相乘得 30。

建立剖析樹用四條規則,逐一讀取詞元 (token):

  1. 讀到 (:在目前節點新增一個左子,往左子下降。
  2. 讀到運算子 + - * /:把目前節點的值設為該運算子,新增一個右子,往右子下降。
  3. 讀到數字:把目前節點的值設為該數字,回到父節點
  4. 讀到 )回到父節點
💡
關鍵

剖析樹把運算式表示成樹,階層反映運算順序;建立時依四條規則處理括號、運算子、數字。

STEP 2

生活妙喻

用堆疊記住回家的路

麵包屑與堆疊

建樹時我們會一直往子節點下降,但「回到父節點」時怎麼知道父節點是誰?樹介面只給我們往下找子節點的方法,沒有往上找父節點的方法。

解法是用一個堆疊 (stack):每次要下降到子節點前,先把目前節點 push 進堆疊;要回到父節點時,就從堆疊 pop 出來。

這就像走迷宮時沿路撒麵包屑:往深處走時一路留記號,要回頭時照著麵包屑倒著走回去。

flowchart TD
  A[讀左括號 下降到左子 並把目前節點推入堆疊] --> B[讀數字 設值 從堆疊彈出回到父節點]
  B --> C[讀運算子 設值 新增右子並下降 推入堆疊]
  C --> D[讀右括號 從堆疊彈出回到父節點]

程式碼大致如下(節錄):

if i == '(':
    current_tree.insert_left('')
    p_stack.push(current_tree)
    current_tree = current_tree.get_left_child()
elif i in ['+', '-', '*', '/']:
    current_tree.set_root_val(i)
    current_tree.insert_right('')
    p_stack.push(current_tree)
    current_tree = current_tree.get_right_child()
elif i == ')':
    current_tree = p_stack.pop()
💡
關鍵

用堆疊追蹤父節點:下降前 push 目前節點,回到父節點時 pop,就像撒麵包屑找回家的路。

STEP 3

實用超能力

遞迴求值剖析樹

建好之後,遞迴求值

樹建好後,我們用遞迴來求值。基底情形是葉節點——在剖析樹裡,葉節點一定是運算元(數字),直接回傳它的值。遞迴步驟則是對左、右子節點各自呼叫 evaluate,再把兩個結果套上父節點的運算子。

運算子用一個字典對應到 Python operator 模組的函式:

import operator

def evaluate(parse_tree):
    opers = {'+': operator.add, '-': operator.sub,
             '*': operator.mul, '/': operator.truediv}
    left = parse_tree.get_left_child()
    right = parse_tree.get_right_child()
    if left and right:
        fn = opers[parse_tree.get_root_val()]
        return fn(evaluate(left), evaluate(right))
    else:
        return parse_tree.get_root_val()

以 $(3 + (4 * 5))$ 為例:左子 evaluate 得 3,右子 evaluate 先算 $4 * 5 = 20$,最後 $3 + 20 = 23$。整個過程就是「先算子樹、再合併到根」,與我們後面要學的後序走訪不謀而合。

💡
關鍵

求值的基底情形是葉節點(直接回傳值),遞迴步驟對左右子求值後套上父節點運算子。

🔆
生活妙喻:用堆疊追蹤父節點 ≈ 走迷宮撒麵包屑

深入時沿路留記號(push),要回頭時照麵包屑倒走回去(pop),就能找到父節點。

🔆
生活妙喻:遞迴求值 ≈ 把一團問題拆成左右兩半各自解決再合併

先算出左子樹與右子樹的答案,再用根上的運算子把兩個小答案合成一個大答案。

本節字彙

剖析樹 (parse tree)
把句子或數學運算式的階層結構表示成樹,葉節點為運算元、內部節點為運算子。
🧠 把運算式拆解成樹,運算順序一目了然。
詞元 (token)
拆解運算式後的最小單位:左括號、右括號、運算子或運算元。
🧠 把字串切成一塊塊有意義的小積木。
建立剖析樹時讀到一個運算子(如 `+`),依規則該怎麼做?
建立剖析樹時為什麼需要一個堆疊?
遞迴 `evaluate` 函式的「基底情形」是什麼?

三種走訪:前序、中序、後序

差別只在於拜訪根節點的時機,用遞迴寫起來非常優雅。

STEP 1

深度探秘

差別只在拜訪根的時機

三種走訪,差在何時看根

走訪 (traversal) 是指拜訪樹裡所有節點的固定模式。三種常見走訪的差別,只在於什麼時候拜訪根節點

  • 前序 (preorder):先拜訪根 → 遞迴走左子樹 → 遞迴走右子樹。
  • 中序 (inorder):遞迴走左子樹 → 拜訪根 → 遞迴走右子樹。
  • 後序 (postorder):遞迴走左子樹 → 遞迴走右子樹 → 最後拜訪根。

注意三者左、右子樹的順序都一樣(先左後右),唯一移動的是「印出/拜訪根」這一行的位置。寫成程式碼時優雅得驚人,因為都是遞迴:

def preorder(tree):
    if tree:
        print(tree.get_root_val())   # 根放最前
        preorder(tree.get_left_child())
        preorder(tree.get_right_child())

把那行 print 往下挪,就變成中序或後序。

💡
關鍵

前序、中序、後序的唯一差別是拜訪根的時機(最前、中間、最後),左右子樹順序相同。

STEP 2

生活妙喻

前序就像從頭讀一本書

把整本書當成一棵樹

把一本書想成樹:書是根,每一章是子節點,每章下面有節,節下面有小節……

如果你想從頭到尾讀這本書,那順序正好就是前序走訪:先看書名(根),再進第一章,章裡先看 1.1、再看 1.2……讀完第一章才回到書、進第二章。

graph TD
  Book[書] --> C1[第一章]
  Book --> C2[第二章]
  C1 --> S11[第 1.1 節]
  C1 --> S12[第 1.2 節]
  C2 --> S21[第 2.1 節]

前序=先看自己再往下,正是「先讀標題再讀內容」的閱讀順序。

而把走訪寫成「外部函式」(傳入樹當參數)比寫成「方法」更俐落,因為外部函式的基底情形只要檢查 if tree:(樹是否存在)即可;寫成方法則要在呼叫前先檢查左右子是否存在。所以本章其餘走訪都寫成外部函式。

💡
關鍵

前序走訪等同從頭讀一本書的順序:先看標題(根)再依序深入各章節。

STEP 3

實用超能力

後序求值,中序還原運算式

走訪的真實用途

後序走訪和我們上一節的剖析樹求值幾乎一模一樣:先算左子樹、再算右子樹、最後在根用運算子合併。把 print 改成 return,就成了 postorder_eval

def postorder_eval(tree):
    opers = {'+': operator.add, '-': operator.sub,
             '*': operator.mul, '/': operator.truediv}
    res1 = res2 = None
    if tree:
        res1 = postorder_eval(tree.get_left_child())
        res2 = postorder_eval(tree.get_right_child())
        if res1 and res2:
            return opers[tree.get_root_val()](res1, res2)
        else:
            return tree.get_root_val()

中序走訪則能把剖析樹還原成運算式。只要在走左子樹前印 (、走右子樹後印 ),就能還原出完全加括號的版本:

def print_exp(tree):
    str_val = ""
    if tree:
        str_val = '(' + print_exp(tree.get_left_child())
        str_val = str_val + str(tree.get_root_val())
        str_val = str_val + print_exp(tree.get_right_child()) + ')'
    return str_val

所以走訪不只是「逛一圈」,而是解決求值、還原、排序等真實問題的骨架。

💡
關鍵

後序走訪天生適合求值剖析樹;中序走訪加上括號能還原原始運算式。

🔆
生活妙喻:前序走訪 ≈ 從頭到尾讀一本書

先看書名與章標題(根),再依序深入每一節,正是先根後子的前序順序。

🔆
生活妙喻:三種走訪的差異 ≈ 同一條路線,只改變拍照打卡的時機

左右子樹的行走順序都一樣,只是『在根拍照』的時機放在出發前、中途或回程,就成了前、中、後序。

本節字彙

前序走訪 (preorder)
先拜訪根,再遞迴走左子樹、右子樹。
🧠 pre=先,根最先被看到。
中序走訪 (inorder)
先走左子樹,再拜訪根,最後走右子樹。
🧠 in=中間,根夾在左右之間。
後序走訪 (postorder)
先走左、右子樹,最後才拜訪根,常用於求值剖析樹。
🧠 post=後,根留到最後處理。
前序、中序、後序三種走訪,彼此唯一的差別是什麼?
若想以「從頭到尾讀一本書」的順序走訪一棵代表書本的樹,該用哪種走訪?
為什麼「後序走訪」特別適合用來求值剖析樹?
05

二元搜尋樹與 Map

二元搜尋樹靠 bst 性質:左子樹的鍵都比父小、右子樹都比父大。

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

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

原文 · 二元搜尋樹與 Map map using a statement of the form del map[key]. • len() Return the number of key-value pairs stored in the map. • in Return True for a statement of the form key in map, if the given key is in the map. 2 Search Tree Implementation A binary search tree relies on the property that keys that are less than the parent are found in the left subtree, and keys that are greater than the parent are found in the right subtree.
白話導讀

二元搜尋樹靠 bst 性質:左子樹的鍵都比父小、右子樹都比父大。

Map ADT 與 BST 性質

二元搜尋樹靠 bst 性質:左子樹的鍵都比父小、右子樹都比父大。

STEP 1

深度探秘

用樹來實作鍵值對照表

Map ADT:鍵對應到值

我們之前看過兩種實作 Map(對照表) 的方式:在串列上做二元搜尋,以及雜湊表。這一章用二元搜尋樹 (binary search tree, BST) 當作第三種方式,把鍵 (key) 對應到值 (value)。Map 的介面和 Python 字典幾乎一樣:

  • Map():建立空 Map。
  • put(key, val):加入鍵值對;鍵若已存在則更新值。
  • get(key):依鍵取值,找不到回 None
  • del map[key]:刪除鍵值對。
  • len():鍵值對數量。
  • key in map:判斷鍵是否存在。

和堆積不同,這裡我們不在意節點的確切位置,而是想利用二元樹的結構來做有效率的搜尋

💡
關鍵

二元搜尋樹是實作 Map ADT 的第三種方式,介面像字典,目標是有效率地依鍵搜尋值。

STEP 2

生活妙喻

左小右大的猜數字遊戲

BST 性質:左小右大

二元搜尋樹的核心是 bst 性質

對每個節點,左子樹裡的所有鍵都比它小,右子樹裡的所有鍵都比它大

這就像玩猜數字遊戲:對方說「比 50 小」,你就只往左邊找;說「比 50 大」,就只往右邊找——每問一次,搜尋範圍就砍半。

graph TD
  n70[70] --> n31[31]
  n70 --> n93[93]
  n31 --> n14[14]
  n93 --> n94[94]
  n14 --> n23[23]

上圖是依序插入 70、31、93、94、14、23 後的樹。注意這個性質對每一對父子都成立:往任何節點看,它左邊的子孫都比它小、右邊的都比它大。正因如此,搜尋時可以像翻字典一樣不斷縮小範圍。

💡
關鍵

bst 性質:左子樹全部比父小、右子樹全部比父大,搜尋時每步都能砍半範圍。

STEP 3

實用超能力

插入順序決定樹的形狀

插入順序很重要

建立 BST 時,鍵的插入順序會決定樹長什麼樣子。第一個插入的鍵成為根,之後每個鍵都從根開始比較:比目前節點小就往左、大就往右,直到找到一個空位掛上去。

例如依序插入 70、31、93、94、14、23、73:

  • 70 是第一個,成為根。
  • 31 < 70,成為 70 的左子。
  • 93 > 70,成為 70 的右子。
  • 94 > 70 且 > 93,成為 93 的右子。
  • 14 < 70 且 < 31,成為 31 的左子。
  • 23 < 31 但 > 14,成為 14 的右子。

警告:如果你照排序好的順序插入(例如 1, 2, 3, 4, 5),每個都比前一個大,全部往右掛,樹就退化成一條歪斜的串列!這時搜尋效率會從 $O(\log n)$ 掉到 $O(n)$。

所以同樣一組鍵,插入順序不同,可能得到漂亮平衡的樹,也可能得到糟糕的歪斜樹。

💡
關鍵

插入順序決定 BST 形狀;照排序順序插入會退化成歪斜串列,搜尋掉到 O(n)。

🔆
生活妙喻:bst 性質 ≈ 猜數字遊戲

每次得到『比某數大或小』的提示就往對應方向走,搜尋範圍每步砍半,就像 BST 沿左右子樹下降。

🔆
生活妙喻:插入順序影響形狀 ≈ 排隊入座的先後決定座位分佈

先到的人佔據關鍵位置(根),後面的人只能依規則往左或往右找空位,順序不同分佈就不同。

本節字彙

Map ADT
把鍵對應到值的抽象資料型別,支援 put、get、del、len、in 等操作。
🧠 像 Python 字典:給一把鑰匙,找到對應的東西。
二元搜尋樹 (BST)
滿足『左子樹皆小於、右子樹皆大於父節點』性質的二元樹。
🧠 左小右大,搜尋像翻字典。
bst 性質
對每個節點,左子樹所有鍵 < 該鍵 < 右子樹所有鍵。
🧠 向左找小的,向右找大的。
二元搜尋樹的「bst 性質」是什麼?
為什麼 BST 性質能讓搜尋變有效率?
依序插入 70、31、93,得到的 BST 結構是?

BST 的實作與效能

用 BinarySearchTree 與 TreeNode 兩個類別實作,並理解平衡與否對效能的影響。

STEP 1

深度探秘

兩個類別分工合作

為什麼用兩個類別

實作 BST 時,我們用兩個類別分工:

  • BinarySearchTree:外層門面,持有一個指向根 TreeNode 的參照,還有 size
  • TreeNode:真正的節點,存 keypayload,以及 left_childright_childparent 參照。

為什麼要分兩個?因為我們必須能處理「空樹」:剛建立時根是 None。外層方法通常先檢查樹是否為空,若有節點就把工作委派給以根為起點的私有遞迴輔助函式(如 _put_get)。空樹或要刪除根這種特殊情況,才在外層特別處理。

class BinarySearchTree:
    def __init__(self):
        self.root = None
        self.size = 0

TreeNode 還提供 is_leaf()has_both_children()is_left_child() 等許多輔助函式,並且明確記錄 parent——這在實作 del 刪除時特別重要。

💡
關鍵

BinarySearchTree 是門面、TreeNode 是節點;分兩類別才好處理空樹,TreeNode 記 parent 方便刪除。

STEP 2

生活妙喻

put 與 get 像翻電話簿

插入與查找:沿路比較

put 從根開始,把新鍵和目前節點比較:比較小往左、比較大往右,直到遇到一個空位(沒有對應的子節點)就把新節點掛上去。插入時把目前節點當作新節點的 parent 傳進去。

def _put(self, key, val, current_node):
    if key < current_node.key:
        if current_node.has_left_child():
            self._put(key, val, current_node.left_child)
        else:
            current_node.left_child = TreeNode(key, val, parent=current_node)
    else:
        if current_node.has_right_child():
            self._put(key, val, current_node.right_child)
        else:
            current_node.right_child = TreeNode(key, val, parent=current_node)

get 更簡單,用同樣的左右判斷邏輯一路找下去,找到相符的鍵就回傳其 payload,走到底還沒找到就回 None

這就像翻紙本電話簿:翻到中間看看姓氏,太前面就往後翻、太後面就往前翻,每翻一次範圍就縮小一半。

透過 __setitem____getitem____contains__ 多載運算子,我們就能寫出 my_tree['Fargo'] = 55446'Fargo' in my_tree 這種像字典的語法。

💡
關鍵

put/get 從根沿左右子下降比較鍵,找到空位掛上或找到相符鍵回傳值,像翻電話簿縮小範圍。

STEP 3

實用超能力

效能取決於是否平衡

BST 的效能:平衡是關鍵

BST 的操作成本,取決於從根走到目標所經過的路徑長度,也就是樹的高度

情況 樹的形狀 put / get / in 的成本
平衡良好 高度約 $\log n$ $O(\log n)$
最差(歪斜) 高度約 $n$ $O(n)$

當鍵以隨機順序插入時,樹通常相當平衡,操作接近 $O(\log n)$,非常快。但若鍵以排序順序插入,樹會退化成一條斜線,操作退化成 $O(n)$,跟普通串列一樣慢。

flowchart TD
  A[插入順序隨機] --> B[樹大致平衡 高度約 log n] --> C[查找快 O log n]
  D[插入順序已排序] --> E[樹歪斜成串列 高度約 n] --> F[查找慢 O n]

這就是為什麼會發展出 AVL 樹這類「自我平衡」的二元搜尋樹:它們在每次插入後自動旋轉調整,保證高度始終維持在 $O(\log n)$,避免退化。

記住核心觀念:BST 快不快,全看它平不平衡。

💡
關鍵

BST 操作成本等於樹高;平衡時 O(log n),歪斜時退化 O(n),AVL 樹靠自動旋轉維持平衡。

🔆
生活妙喻:兩個類別分工 ≈ 公司的櫃檯與內部員工

BinarySearchTree 像櫃檯處理『有沒有人在』這類門面問題,再把實際工作轉給內部員工 TreeNode 的遞迴輔助函式。

🔆
生活妙喻:平衡與效能 ≈ 整齊的書架 vs 全堆一疊的書

平衡的樹像分類整齊的書架,幾步就找到;歪斜的樹像把書全疊成一落,只能從頭一本本翻。

本節字彙

TreeNode
BST 的節點類別,存 key、payload 與 left_child、right_child、parent 參照。
🧠 每個節點都記得自己的父母,方便日後刪除。
私有輔助函式 (_put / _get)
由外層方法委派、以某節點為起點遞迴搜尋的內部函式。
🧠 底線開頭,是門面方法私下找來幫忙的工人。
AVL 樹 (AVL tree)
會在插入後自動旋轉以維持平衡、保證 O(log n) 的二元搜尋樹。
🧠 自我平衡的 BST,永遠不讓自己歪掉。
為什麼 BST 的實作要分成 `BinarySearchTree` 與 `TreeNode` 兩個類別?
`_put` 在插入新節點時,為什麼要把 `current_node` 當作 `parent` 傳給新的 `TreeNode`?
當 BST 大致平衡時,`get`(查找)的時間複雜度大約是多少?