前幾篇我們一直在問一個很類似的問題:
資料要怎麼拿?
但今天,我們要換一個方向。
因為有些資料的重心放在:
它和其他資料之間,是什麼關係?
最容易看到這種關係的地方,就是日常都會使用的資料夾。
假設電腦裡有這樣的結構:
Documents
├─ Work
│ ├─ report.pdf
│ └─ meeting.md
│
└─ Personal
├─ photos
└─ notes.md
如果只把這些資料全部攤平,我們可能得到:
Documents
Work
report.pdf
meeting.md
Personal
photos
notes.md
資料都還在,但是一些很重要的資訊不見了:
report.pdf 屬於 Work
meeting.md 屬於 Work
photos 屬於 Personal
notes.md 屬於 Personal
Work 和 Personal 又都位於 Documents 底下也就是說,真正重要的不只是「有哪些資料」。
還包括:
這些資料彼此之間的上下關係。
而這種結構,就符合資料結構中的Tree。
如果把剛才的資料夾畫成節點:
Documents
├─ Work
│ ├─ report.pdf
│ └─ meeting.md
└─ Personal
├─ photos
└─ notes.md
我們可以把每一個資料夾或檔案想成一個 node(節點)。
節點之間,再用關係連接起來。
例如:
Documents
│
├──── Work
│ │
│ ├──── report.pdf
│ └──── meeting.md
│
└──── Personal
│
├──── photos
└──── notes.md
這種從一個起點往下不斷分支的形狀,看起來就像一棵倒過來的樹。
因此我們稱它為Tree。
一棵樹通常會有樹根(Root)。從樹根開始的話大概是這樣
假設 Documents 這個節點視為 Root,所有其他節點,都可以從 Root 一路往下找到。
例如:
Documents
→ Work
→ report.pdf
或:
Documents
→ Personal
→ notes.md
這和 Array 不一樣,Array 通常關心的是:
index 0
index 1
index 2
index 3
但 Tree 關心的是:
誰在誰下面?
誰屬於誰?
來看這段:
Documents
└─ Work
├─ report.pdf
└─ meeting.md
對 Work 而言 Documents 是它的 parent。
而:
report.pdf
meeting.md
則是 Work 的 children。
所以:
Documents
│
└─ Work
│
├─ report.pdf
└─ meeting.md
可以理解成:
Documents
↓ parent of
Work
↓ parent of
report.pdf
meeting.md
反過來看 Work 是 Documents 的 child。
而 report.pdf 又是 Work 的 child。
樹 Tree 就是透過這樣一層一層的 parent-child relationship,把階層關係保存下來。
再來看看:
report.pdf
meeting.md
photos
notes.md
這些節點下面已經沒有其他節點,這種節點通常稱為 leaf node。
也就是:
沒有 child 的節點。
例如:
Documents
├─ Work
│ ├─ report.pdf ← leaf
│ └─ meeting.md ← leaf
│
└─ Personal
├─ photos ← leaf
└─ notes.md ← leaf
如果把 Tree 想像成一棵真的樹:
root
↓
branches
↓
leaves
leaf 這個名稱就很直覺了。
現在我們再回頭看整個結構:
Documents
├─ Work
│ ├─ report.pdf
│ └─ meeting.md
│
└─ Personal
├─ photos
└─ notes.md
這裡真正被保存的,其實是一種 hierarchy (階層關係)。
例如:
Documents
↓
Work
↓
report.pdf
代表 report.pdf 屬於 Work,Work 又屬於 Documents。
這也是為什麼 Tree 非常適合描述具有上下層級的問題。
資料夾只是最好理解的例子,Tree 其實到處都存在。
例如公司組織:
CEO
├─ Engineering
│ ├─ Frontend
│ └─ Backend
│
└─ Sales
├─ Domestic
└─ International
或者網站選單:
Products
├─ Computers
│ ├─ Laptop
│ └─ Desktop
│
└─ Phones
├─ Android
└─ iPhone
商品分類也一樣:
Electronics
├─ Computer
│ ├─ Laptop
│ └─ Monitor
│
└─ Audio
├─ Headphones
└─ Speakers
甚至前端工程師很熟悉的 DOM,也可以用類似方式理解:
<body>
<main>
<h1>Hello</h1>
<p>Welcome</p>
</main>
</body>
可以抽象成:
body
└─ main
├─ h1
└─ p
這些問題共同具有一個特徵:
資料之間存在明確的上下階層。
再來看這個結構:
Documents
├─ Work
│ ├─ report.pdf
│ └─ meeting.md
│
└─ Personal
├─ photos
└─ notes.md
如果我們暫時只看:
Work
├─ report.pdf
└─ meeting.md
你會發現:
它自己其實也完全符合 Tree 的形狀。
這種從某個節點開始、連同它底下所有 descendants 所形成的結構,就可以視為一個 subtree (分支)。
例如整棵樹:
Documents
├─ Work
│ ├─ report.pdf
│ └─ meeting.md
└─ Personal
├─ photos
└─ notes.md
其中:
Work
├─ report.pdf
└─ meeting.md
是一個 subtree,而:
Personal
├─ photos
└─ notes.md
也是另一個 subtree。
很多 Tree 的問題,其實都可以理解成:
把大問題拆成一個又一個比較小的 subtree。
這也是為什麼未來談到 recursion 時,Tree 會是一個非常自然的應用場景。
不過今天我們先不深入 recursion。
現在只需要記住:
一棵樹的某個分支,本身也可以看成另一棵樹。
假設現在我要找 notes.md,我們可以從 Documents 開始。
先看到:
Work
Personal
接著進入 Personal,再找到 notes.md。
也就是:
Documents
→ Personal
→ notes.md
問題開始變成:
我要按照什麼順序拜訪這些節點?
這件事情稱為 traversal。
也就是:
按照某種規則走過 Tree 裡的節點。
例如我們可以從 root 開始,一路把每個節點印出來。
假設用 JavaScript 表示:
const fileTree = {
name: "Documents",
children: [
{
name: "Work",
children: [
{ name: "report.pdf", children: [] },
{ name: "meeting.md", children: [] }
]
},
{
name: "Personal",
children: [
{ name: "photos", children: [] },
{ name: "notes.md", children: [] }
]
}
]
};
我們可以寫一個很簡單的函式:
function traverse(node) {
console.log(node.name);
for (const child of node.children) {
traverse(child);
}
}
然後:
traverse(fileTree);
可能得到:
Documents
Work
report.pdf
meeting.md
Personal
photos
notes.md
這裡暫時不用急著記住這段程式,重要的是:
先處理目前節點
↓
再處理它的 child
↓
child 又繼續處理自己的 child
也就是沿著 Tree 的結構,一層一層走下去。
至於:
這會牽涉到不同的 traversal strategy。
我們之後進入搜尋演算法時,還會再次遇到這個問題。
到這裡,可以回頭看看前面幾天學過的東西。
這些資料結構當然也存在資料之間的組織方式。
但是對使用者而言,我們前幾篇重心都放在:
資料要怎麼放,以及下一筆資料要怎麼拿。
Tree 開始出現一個很明顯的轉變。
我們真正想保存的是:
也就是:
資料之間的關係,本身就是問題的一部分。
假設我們只存:
const files = [
"Documents",
"Work",
"report.pdf",
"meeting.md",
"Personal",
"photos",
"notes.md"
];
資料確實都存在。
但現在問:
report.pdf 位於哪個資料夾?Work 底下有哪些東西?Personal 的 parent 是誰?單純從這個 Array 裡,很難回答。
因為我們只保存了「有哪些資料」,卻沒有保存「資料彼此之間的關係」。
我們當然可以另外增加資訊:
{
name: "report.pdf",
parent: "Work"
}
但是當這些關係越來越重要時,我們其實已經開始在建立某種 Tree structure。
所以資料結構真正重要的從來不只是:
能不能把資料塞進去。
而是:
這個表述方式,有沒有保留問題真正重要的資訊。
例如:
Documents
├─ Work
└─ Personal
一個 Documents 可以有很多 children。Work 又可以有:
Work
├─ report.pdf
└─ meeting.md
因此每個節點都可以繼續往下分支。
但 Tree 通常還有一個很重要的限制:
一個節點只會沿著一條階層路徑往上回到 root。
例如:
report.pdf
↑
Work
↑
Documents
它不會同時又屬於 Personal,否則原本清楚的 parent-child hierarchy 就開始改變了。
現在假設我們不再描述資料夾。
而是在描述四個人:
A
B
C
D
他們彼此認識:

這時候就很難說:
A 可以跟 B 有關係。
A 也可以跟 C 有關係。
A 還可以直接跟 D 有關係。
而且:
A
→ B
→ D
→ C
→ A
最後甚至可能又回到原本的位置。
這已經不是單純的上下階層,而是一張關係網。
Tree 給了我們一種非常重要的 problem representation:
root
↓
parent
↓
child
↓
leaf
它非常適合處理:
資料夾
公司組織
DOM
選單
商品分類
因為這些問題都具有相對清楚的 hierarchy。
但我們很快就會發現,有很多現實問題不是這樣。
例如:
朋友關係
道路
捷運
航班
網站連結
電腦網路
dependency
一個節點可以同時和很多節點發生關係。
甚至可能一路走出去,最後又回到原本的位置。
像這樣:

這時候,我們就不能只靠 Tree 來描述了。
因為 Tree 只是一種比較特殊、限制比較明確的關係結構。
下一篇,我們就要把這些限制拿掉,從上下階層走向任意關係。
也就是第一章最後一個非常重要的資料結構 Graph。
而當 Graph 出現之後,我們下一個需要考量的問題也會跟著出現:
在這麼多可能的路徑裡,我們到底要怎麼找到自己想要的東西?