Minghsin University of Science and Technology
Uedu Main Site
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

1

Local AI

--

Uedu Code

--

明新科大 AQI 70 24°C PM2.5 19
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 13

二元樹與走訪

前序、中序、後序與層序走訪
Interaction實作 30 minute(s)

Learning Objectives

  • 說明根、葉、子節點、父節點、子樹、深度、高度等術語,並在一棵樹上指出它們
  • 用節點物件(val、left、right)建立二元樹
  • 寫出前序、中序、後序走訪的遞迴程式,說明三者只差在「什麼時候處理自己」
  • 用佇列寫出層序走訪,一層一層由左到右拜訪節點
  • 用遞迴計算樹的高度與節點數,並能由前序與中序走訪結果反推出樹

有分支的資料

到目前為止的陣列、鏈結串列、堆疊、佇列都是線性的:每個元素前面最多一個、後面最多一個。很多資料不是這樣:

  • 電腦的資料夾:一個資料夾裡有好幾個子資料夾,子資料夾裡還有子資料夾
  • 運動比賽的賽程表:兩隊比完,勝者再和另一組的勝者比
  • 算式 (3 + 4) * 5:* 的兩邊各是一個小算式

這種「一個分成好幾個」的結構叫做樹(tree)。每個節點最多有兩個子節點的樹叫二元樹(binary tree),兩個子節點分別叫左子節點和右子節點。 二元樹是後面好幾課的基礎:第 14 課的二元搜尋樹、第 15 課的堆積都是二元樹。

樹的術語

這一課的範例都用這棵樹:

        1
      /   \
     2     3
    / \     \
   4   5     6
      /
     7
術語 意思 在這棵樹上
根(root) 最上面的節點,沒有父節點 1
子節點(child)/父節點(parent) 節點往下連到的節點是它的子節點;反過來是父節點 2、3 是 1 的子節點;5 是 7 的父節點
葉節點(leaf) 沒有任何子節點的節點 4、7、6
子樹(subtree) 某個節點和它底下所有節點組成的樹 以 2 為根的子樹是 2、4、5、7
深度(depth) 從根往下走幾步才到這個節點;根的深度是 0 7 的深度是 3
高度(height) 從根到最遠的葉節點,路徑上有幾個節點 整棵樹的高度是 4(1 → 2 → 5 → 7)

高度有兩種算法:本課用「節點數」,空樹的高度是 0、只有根的樹是 1;有些書用「邊數」,結果會少 1。讀資料時先確認用的是哪一種。

節點的寫法和上一課的鏈結串列很像,只是 next 換成兩個欄位 left、right,沒有子節點時是 null(None、nullptr)。

建立一棵二元樹

先建立根,再一個一個接上子節點,樹就一層一層長出來。後半段用 node、leaf 兩個變數指到樹裡的節點,看它們的子節點。C++ 版本最後用 freeTree 釋放所有節點,它的順序會在後序走訪說明。

三種深度優先走訪

走訪(traversal)是把每個節點都拜訪一次。二元樹天生適合用遞迴處理:一棵樹=根+左子樹+右子樹,左右子樹本身也是樹。 所以「走訪整棵樹」可以拆成「處理根」「走訪左子樹」「走訪右子樹」三件事,差別只在處理根的時機:

走訪 順序 這棵樹的結果
前序(preorder) 自己 → 左子樹 → 右子樹 1, 2, 4, 5, 7, 3, 6
中序(inorder) 左子樹 → 自己 → 右子樹 4, 2, 7, 5, 1, 3, 6
後序(postorder) 左子樹 → 右子樹 → 自己 4, 7, 5, 2, 6, 3, 1

名稱裡的「前、中、後」指的就是「自己」排在第幾個。三種都是先一路往深處走,走到底才回頭,所以統稱深度優先走訪(depth-first traversal)。

遞迴的終止條件是空樹:節點是 null 就直接返回。每個節點恰好拜訪一次,時間 O(n);遞迴呼叫堆疊的深度等於樹的高度。

前序走訪:先處理自己

每進入一個節點,第一件事就是把它的值放進 out,然後才往左、往右遞迴。看遞迴樹:每個真的節點都會呼叫兩次(左、右),走到 null 就立刻返回,所以 7 個節點共有 15 次呼叫。out 依序是 1, 2, 4, 5, 7, 3, 6。

中序與後序:同一個遞迴,換一行的位置

兩個函式的遞迴呼叫完全一樣,只有 out.append(node.val) 的位置不同。中序要等左子樹全部走完才處理自己,所以第一個輸出的是最左邊的 4;後序要等兩邊子樹都走完,所以根 1 最後才輸出。

三種走訪各有用途

  • 前序:先處理父節點再處理子節點。複製一棵樹、把樹存成文字(序列化)、印出資料夾結構(先印資料夾名稱,再印裡面的東西)都用前序。
  • 中序:在二元搜尋樹(下一課)上,中序走訪會由小到大輸出所有值。
  • 後序:先處理子節點再處理父節點。要算出「這個資料夾總共多大」,得先知道每個子資料夾多大; C++ 釋放整棵樹時也必須先刪子節點,最後才刪自己,否則刪掉父節點後就找不到子節點了。範例裡的 freeTree 就是後序走訪。

算式樹也是個好例子:(3 + 4) * 5 的根是 *,左子樹是 3 + 4,右子樹是 5。 用後序走訪計算,就是「先算出兩邊的值,再做自己的運算」;把三種走訪的結果寫出來,分別是 * + 3 4 5、3 + 4 * 5、3 4 + 5 *(前序、中序、後序)。

想一想:三種走訪

1.

下面的程式會印出什麼?

class Node:
    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def inorder(node, out):
    if node is None:
        return
    inorder(node.left, out)
    out.append(node.val)
    inorder(node.right, out)

#       10
#      /  \
#     5    20
#    / \
#   3   8
root = Node(10, Node(5, Node(3), Node(8)), Node(20))
out = []
inorder(root, out)
print(out)
2.

同一棵樹(10 的左子節點 5、右子節點 20;5 的左右子節點是 3 和 8),後序走訪的結果是什麼?

3.

要用 C++ 的 delete 釋放整棵樹的所有節點,應該用哪一種走訪的順序?

第 1 次作答

層序走訪:一層一層來

層序走訪(level-order traversal)先拜訪第 0 層(根),再由左到右拜訪第 1 層、第 2 層……這棵樹的結果是 1, 2, 3, 4, 5, 6, 7。

遞迴會一路往深處走,不適合「一層一層」的順序。這裡要用第 10 課的佇列(先進先出):

  1. 把根放進佇列
  2. 從佇列前面取出一個節點,處理它
  3. 把它的左、右子節點(不是 null 的)放到佇列後面
  4. 佇列還有東西就回到第 2 步

子節點排在佇列後面,要等同一層、排在前面的節點都處理完才會輪到它們,所以輸出的順序剛好是一層一層。 每個節點進出佇列各一次,時間 O(n)。第 16 課的廣度優先搜尋(BFS)是同一個想法用在圖上。

層序走訪:佇列裡的節點

看佇列的變化(右下角的變數區可以看到 q 裡的節點):取出 1,放進 2、3;取出 2,放進 4、5;取出 3,放進 6……輸出的順序就是一層一層、由左到右。(C++ 用 queue,取出要先 front() 再 pop();Java 用 ArrayDeque 的 add、poll;函式名稱是 levelOrder。)

用遞迴算高度與節點數

很多樹的問題都能寫成「把左右子樹的答案組合起來」:

  • 高度:空樹是 0;否則是 1(自己這一層)加上左右子樹高度比較大的那一個
  • 節點數:空樹是 0;否則是 1(自己)加上左子樹的節點數、再加上右子樹的節點數
def height(node):
    if node is None:
        return 0
    return 1 + max(height(node.left), height(node.right))

這其實是後序走訪:要先知道兩個子樹的答案,才能算出自己的答案。兩個函式都把每個節點拜訪一次,O(n)。

高度與節點數

看遞迴樹上的回傳值:葉節點的兩個子樹都是 null(回傳 0),所以葉節點回傳 1;往上一層取兩邊比較大的再加 1。節點 5 的左邊高度 1、右邊 0,回傳 2;節點 2 的兩邊是 1 和 2,回傳 3;根回傳 4。(C++、Java 的節點數函式叫 countNodes。)

從走訪結果反推樹

只給一種走訪結果,通常無法確定樹長什麼樣子。例如前序 1, 2 可能是「2 是 1 的左子節點」,也可能是「2 是 1 的右子節點」。

但如果同時知道前序和中序(而且節點的值都不相同),樹就唯一確定:

  1. 前序的第一個一定是根
  2. 在中序裡找到根:根左邊的都在左子樹,右邊的都在右子樹
  3. 依左子樹的節點數,把前序也切成左右兩段,對兩邊重複同樣的步驟

用本課的樹驗證:前序 1, 2, 4, 5, 7, 3, 6、中序 4, 2, 7, 5, 1, 3, 6。

  • 根是 1;中序裡 1 的左邊 4, 2, 7, 5 是左子樹(4 個節點),右邊 3, 6 是右子樹
  • 前序去掉根之後,前 4 個 2, 4, 5, 7 是左子樹的前序,後面 3, 6 是右子樹的前序
  • 左子樹的根是 2;中序 4, 2, 7, 5 裡,2 的左邊是 4、右邊是 7, 5……一路切下去,就得到原本的樹

後序加中序也可以(後序的最後一個是根);但前序加後序在某些情況下無法區分左右,例如上面的 1, 2。

想一想:層序、高度與反推

1.

對本課的樹(1 的子節點 2、3;2 的子節點 4、5;3 的右子節點 6;5 的左子節點 7)做層序走訪。剛把節點 2 從佇列取出、並放進它的子節點之後,佇列裡依序是哪些節點?

2.

一棵二元樹的前序是 A, B, D, E, C,中序是 D, B, E, A, C。根的左子樹有哪些節點?

3.

一棵有 7 個節點的二元樹,用本課的定義(節點數),高度最小和最大分別是多少?

第 1 次作答

實作:葉節點個數與節點值總和

請用遞迴完成兩個函式:count_leaves 算葉節點的個數,tree_sum 算所有節點的值加起來是多少(C++、Java 是 countLeaves、treeSum)。兩個都是「空樹回傳 0,否則組合左右子樹的答案」。

你的輸出
預期輸出

重點整理

  • 二元樹的每個節點有 val、left、right;沒有父節點的是根,沒有子節點的是葉節點
  • 深度:根是 0,往下一層加 1;高度(本課的定義):根到最遠葉節點路徑上的節點數,空樹是 0
  • 前序:自己 → 左 → 右;中序:左 → 自己 → 右;後序:左 → 右 → 自己;三種都是遞迴,O(n)
  • 層序:用佇列,取出一個節點、把子節點放到後面,一層一層由左到右
  • 高度、節點數等問題寫成「空樹回傳 0,否則組合左右子樹的答案」
  • 前序(或後序)加上中序,就能唯一反推出一棵值都不相同的二元樹

下一課:二元搜尋樹。在二元樹上加一條規則「左邊都比較小、右邊都比較大」,搜尋就能像二分搜尋一樣,每一步排除一半。

這一課完成了!
Next lesson:二元搜尋樹
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。