Home
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

4

Local AI

--

Uedu Code

--

Taipei AQI 57 · Taichung AQI 74 · Tainan AQI 63 · Kaohsiung AQI 64
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 14

二元搜尋樹

搜尋、插入、刪除,以及樹退化成串列
Interaction實作 30 minute(s)

Learning Objectives

  • 說明二元搜尋樹的性質:左子樹都比節點小、右子樹都比節點大
  • 追蹤搜尋與插入從根往下走的路徑,說明比較次數等於走過的層數
  • 說明中序走訪二元搜尋樹會得到由小到大的結果
  • 分辨刪除節點的三種情況,用後繼者處理有兩個子節點的節點
  • 說明依序插入已排序的資料會讓樹退化成一條線,以及平衡樹要解決的問題
  • 使用 C++ set/map、Java TreeMap/TreeSet,以及 Python 的 bisect 替代做法

資料一直在變,還要能快速搜尋

第 04 課的二分搜尋很快:排好序的陣列裡找一個數,最多比 log₂n 次。可是如果資料會一直新增、刪除呢? 例如選課系統的候補名單、線上遊戲依分數排好的玩家、圖書館依書號排好的館藏,隨時有東西進來、有東西離開。

做法 搜尋 插入一筆 刪除一筆
排好序的陣列 O(log n)(二分搜尋) O(n)(後面的元素要往後挪) O(n)
鏈結串列(第 11 課) O(n)(只能一個一個走) 找到位置後 O(1) 找到位置後 O(1)
二元搜尋樹(樹的形狀好的時候) O(log n) O(log n) O(log n)

二元搜尋樹(binary search tree,BST)是一種二元樹(第 13 課),每個節點都滿足:

  • 左子樹裡所有的值都比這個節點小
  • 右子樹裡所有的值都比這個節點大

這個性質讓它像一棵「做好的二分搜尋」:站在任何一個節點,和目標比一次,就知道該往左還是往右,另一邊整棵子樹都不用看。 這一課假設樹裡沒有重複的值,遇到相同的值就不再插入。

插入:走到空位就放下

依序插入 50、30、70、20、40、60、80、35。每次都從根出發:比節點小往左、比節點大往右,走到空的位置就建立新節點。insert 用遞迴寫:回傳「插入之後這棵子樹的根」,上一層把它接回 node.left 或 node.right。注意最後的 35:先比 50 往左、再比 30 往右、再比 40 往左,掛在 40 的左邊。

搜尋:每比一次往下一層

搜尋和插入走的是同一條路,只是走到空的位置時,代表目標不在樹裡:

cur = root
while cur is not None:
    if target == cur.val:
        return cur              # 找到了
    elif target < cur.val:
        cur = cur.left          # 往左
    else:
        cur = cur.right         # 往右
return None                     # 走到空位:不在樹裡

每比一次就往下一層,所以比較次數最多等於樹高(從根到最深的節點經過幾個節點)。 樹的形狀越「矮胖」,搜尋越快;n 個節點的樹排得夠整齊時,樹高大約是 log₂n。

中序走訪:由小到大

第 13 課學過中序走訪(inorder):先走左子樹、再處理自己、最後走右子樹。 用在二元搜尋樹上,剛好會由小到大拜訪每個值:

  • 左子樹的值全部比自己小,所以先把它們走完
  • 接著是自己
  • 右子樹的值全部比自己大,最後才走

所以「把一堆數插進 BST,再中序走訪」就是一種排序方法。n 個數各插入一次,樹的形狀好的時候總共 O(n log n)。

中序走訪得到排序結果

建好樹之後(前面約 150 步),看 inorder 怎麼先一路往左走到 20,才開始把值放進 out。out 一格一格長出來,永遠是由小到大。

想一想:插入、搜尋與中序

1.

依序插入 40、20、60、10、30、50 建成一棵二元搜尋樹,再插入 25,25 會掛在哪裡?

2.

用「搜尋」範例的樹(插入 50、30、70、20、40、60、80、35)找 45,會印出什麼?

3.

依序插入 8、3、10、1、6、14,再做中序走訪,印出的順序是什麼?

第 1 次作答

刪除的三種情況

刪除比插入麻煩,因為拿掉一個節點之後,它的子節點要有地方接,而且整棵樹還要維持「左小右大」。先找到要刪的節點,再看它有幾個子節點:

情況 做法
葉節點(沒有子節點) 直接拿掉,父節點那一邊變成空的
只有一個子節點 讓那個子節點頂替自己的位置
兩個子節點 找後繼者(右子樹裡最小的值),把它的值複製過來,再到右子樹刪掉後繼者

第三種情況為什麼用後繼者?要找一個值來頂替,它必須比左子樹全部大、比右子樹全部小。右子樹裡最小的那個剛好符合: 它比左子樹全部大(因為它在右子樹),又比右子樹其他值都小。後繼者的找法是從右子節點出發,一直往左走到底。

後繼者一定沒有左子節點(不然它就不是最小的),所以到右子樹刪掉它時,只會遇到前兩種簡單情況。 改用「前驅者」(左子樹裡最大的值)也可以,道理相同。

C++ 的 delete 是保留字,所以範例的 C++ 函式叫 remove,並在拿掉節點時用 delete 釋放記憶體;Python 和 Java 會自動回收沒有人指到的物件。

刪除:葉節點、一個子節點、兩個子節點

同一棵樹依序刪除 20(葉節點)、30(只有右子節點 40)、50(兩個子節點)。刪除 50 時,succ 從 70 出發往左走到 60,把 60 複製到根,再到右子樹把原本的 60 刪掉。建樹的部分約 160 步,可以拖到刪除開始的地方看。

樹的形狀取決於插入順序

同樣一組數,插入的順序不同,長出來的樹就不同。最糟的情況是資料已經排好序: 依序插入 1、2、3、4、5、6,每個新的數都比樹裡所有的數大,只能一直往右掛,樹變成一條線,和鏈結串列沒有兩樣。

樹的形狀 樹高 搜尋、插入、刪除
整齊(每一層盡量填滿) 約 log₂n O(log n)
退化成一條線 n O(n)

下面的範例用迴圈版的插入,回傳新節點在第幾層,順便算出兩棵樹的樹高。

依序插入讓樹退化成一條線

插入 6 到 chain 時,cur 要從 1 一路走過 2、3、4、5,比較 5 次才找到空位;good 同樣 6 個數,樹高只有 3。資料越多差距越大:1,000 個依序插入的數,樹高是 1,000;排得整齊的話只要 10 層。

實驗:自己長一棵二元搜尋樹

輸入數字後按「插入」「搜尋」或「刪除」。搜尋時會一步一步標出走過的節點,並數比較次數;刪除時會說明屬於哪一種情況。「依序插入」和「隨機插入」會清空後重建,下方記下兩種的樹高,方便比較。

平衡樹與內建的有序容器

避免退化的辦法,是在每次插入、刪除之後調整樹的形狀,讓樹高一直保持在 O(log n),這類樹叫平衡二元搜尋樹(self-balancing BST)。 調整的基本動作叫旋轉(rotation):把一個子節點轉上來當父節點,左小右大的性質不變,但某一邊的高度少了一層。常見的兩種:

  • AVL 樹:1962 年由 Adelson-Velsky 與 Landis 提出,是第一種自我平衡的二元搜尋樹。每個節點左右子樹的高度最多差 1。
  • 紅黑樹(red-black tree):源自 Bayer 1972 年的對稱二元 B 樹,1978 年 Guibas 與 Sedgewick 用紅、黑兩種顏色描述它。 平衡的要求比 AVL 寬鬆,插入、刪除時需要的調整比較少。

旋轉的細節這一課不展開;實際寫程式時,通常直接用語言內建的有序容器:

語言 寫法 說明
C++ set<int>、map<int, string> 依鍵排序,搜尋、插入、刪除 O(log n);常見的實作是紅黑樹。lower_bound、upper_bound 可以找相鄰的值
Java TreeSet<Integer>、TreeMap<Integer, String> 以紅黑樹實作;floor、ceiling、floorKey 找相鄰的值
Python 標準函式庫沒有 可以用排好序的串列配 bisect:搜尋 O(log n),但 bisect.insort 插入時要挪動元素,O(n)
import bisect

a = []
for x in [50, 30, 70, 20, 40]:
    bisect.insort(a, x)       # 插入並保持排序
print(a)                      # [20, 30, 40, 50, 70]
i = bisect.bisect_right(a, 45)
print(a[i - 1])               # 小於等於 45 的最大值:40

注意 C++ 的 unordered_set、Java 的 HashSet、Python 的 set 和 dict 是雜湊表(第 12 課),不依大小排序;需要「由小到大走訪」或「找比 x 小的最大值」時,要用上面的有序容器。

想一想:刪除、退化與有序容器

1.

依序插入 50、30、70、60、80、65,再刪除 50(用後繼者取代)。刪除之後,70 的左子節點是什麼?

2.

把 1、2、3、…、1000 依序插入一棵普通的二元搜尋樹(不做平衡),再搜尋 1000,要比較幾次?

3.

想要「一直保持由小到大,而且能找出小於等於 x 的最大值」,下列哪一個不適合?

第 1 次作答

實作:小於等於 x 的最大值

完成 floor(C++/Java 叫 floorOf):在二元搜尋樹裡找出小於等於 x 的最大值,找不到就回傳 -1。只能從根往下走一條路,不要把整棵樹走訪一遍。預期輸出在右邊。

你的輸出
預期輸出

重點整理

  • 二元搜尋樹:每個節點的左子樹都比它小、右子樹都比它大
  • 搜尋、插入都從根往下走,每比一次下一層;比較次數最多等於樹高
  • 中序走訪二元搜尋樹得到由小到大的結果
  • 刪除分三種:葉節點直接拿掉;一個子節點就讓子節點頂替;兩個子節點用後繼者(右子樹最小值)取代
  • 樹高決定速度:整齊時約 log₂n,依序插入已排序的資料會退化成一條線,樹高 n
  • 平衡樹(AVL、紅黑樹)用旋轉維持 O(log n);實務上用 C++ set/map、Java TreeSet/TreeMap,Python 可用 bisect 搭配串列

下一課:堆積與優先佇列。同樣是二元樹,堆積只要求「父節點比子節點小(或大)」,換來的是能用陣列存,而且永遠能在 O(1) 拿到最小值。

這一課完成了!
Next lesson:堆積與優先佇列
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。