知識體系樹狀圖:樹
1. 二叉樹相關定義
二叉樹:每個節點最多有兩個“叉”的樹,也就是兩個子節點,分別是左子節點和右子節點。
滿二叉樹:葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫作滿二叉樹。
完全二叉樹:葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫作完全二叉樹。
2. 如何存儲一個二叉樹
鏈式存儲法:每個節點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。我們只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。這種存儲方式我們比較常用。大部分二叉樹代碼都是通過這種結構來實現的。
基於數組的順序存儲法:我們把根節點存儲在下標 i = 1 的位置,那左子節點存儲在下標 2 * i = 2 的位置,右子節點存儲在 2 * i + 1 = 3 的位置。以此類推,B 節點的左子節點存儲在 2 * i = 2 * 2 = 4 的位置,右子節點存儲在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。
二叉樹既可以用鏈式存儲,也可以用數組順序存儲。數組順序存儲的方式比較適合完全二叉樹,其他類型的二叉樹用數組存儲會比較浪費存儲空間。
3. 二叉樹遍歷
如何將所有節點都遍歷打印出來呢?經典的方法有三種,前序遍歷、中序遍歷和後序遍歷。其中,前、中、後序,表示的是節點與它的左右子樹節點遍歷打印的先後順序。
- 前序遍歷是指,對於樹中的任意節點來說,先打印這個節點,然後再打印它的左子樹,最後打印它的右子樹。
- 中序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它本身,最後打印它的右子樹。
- 後序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然後再打印它的右子樹,最後打印這個節點本身。
記憶技巧:前序遍歷,又叫先序遍歷,可以稱爲“先根”遍歷,同理中序遍歷和後序遍歷可以分別思考爲“中根”、“後根”遍歷。
前、中、後序遍歷操作,遍歷的時間複雜度是 O(n)。
golang代碼實現(遞歸版本):
package main
import (
"fmt"
)
type NodeTmp struct {
Data string
Left *NodeTmp
Right *NodeTmp
}
func frontTree(node *NodeTmp) {
if node == nil{
return
}
fmt.Printf("%v ", node.Data)
frontTree(node.Left)
frontTree(node.Right)
}
func midTree(node *NodeTmp) {
if node == nil{
return
}
midTree(node.Left)
fmt.Printf("%v ", node.Data)
midTree(node.Right)
}
func backTree(node *NodeTmp) {
if node == nil{
return
}
backTree(node.Left)
backTree(node.Right)
fmt.Printf("%v ", node.Data)
}
func main() {
node := &NodeTmp{"A", nil, nil}
//左
node.Left = &NodeTmp{"B", nil, nil}
node.Left.Left = &NodeTmp{"D", nil, nil}
node.Left.Right = &NodeTmp{"E", nil, nil}
//右
node.Right = &NodeTmp{"C", nil, nil}
node.Right.Right = &NodeTmp{"F", nil, nil}
//先序遍歷
fmt.Println("先序遍歷")
frontTree(node)
fmt.Println()
//中序遍歷
fmt.Println("中序遍歷")
midTree(node)
fmt.Println()
//後序遍歷
fmt.Println("後序遍歷")
backTree(node)
fmt.Println()
}
4. 二叉查找樹
二叉查找樹是二叉樹中最常用的一種類型,也叫二叉搜索樹。顧名思義,二叉查找樹是爲了實現快速查找而生的。不過,它不僅僅支持快速查找一個數據,還支持快速插入、刪除一個數據。
二叉查找樹定義:在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
二叉查找樹的插入、查詢、刪除操作代碼:
package main
type Node struct {
Data int
Left *Node
Right *Node
}
//查找
func (node *Node) find(data int) *Node {
tmp := node
for tmp != nil {
if data < tmp.Data {
tmp = tmp.Left
} else if data > tmp.Data {
tmp = tmp.Right
} else {
return tmp
}
}
return nil
}
//插入
func (node *Node) insert(data int) {
if node == nil {
node = &Node{data, nil, nil}
}
tmp := node
for tmp != nil {
if data > tmp.Data {
if tmp.Right == nil {
tmp.Right = &Node{data, nil, nil}
return
}
tmp = tmp.Right
} else {
if data < tmp.Data {
if tmp.Left == nil {
tmp.Left = &Node{data, nil, nil}
return
}
}
tmp = tmp.Left
}
}
}
//刪除
func (node *Node) delete(data int) {
}
func main() {
}
重要特性:就是中序遍歷二叉查找樹,可以輸出有序的數據序列,時間複雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
與散列表對比:
散列表的插入、刪除、查找操作的時間複雜度可以做到常量級的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時間複雜度纔是 O(logn),相對散列表,好像並沒有什麼優勢,那我們爲什麼還要用二叉查找樹呢?
原因大概如下:
- 散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的數據序列。
- 散列表擴容耗時很多,而且當遇到散列衝突時,性能不穩定,儘管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間複雜度穩定在 O(logn)。
- 籠統地來說,儘管散列表的查找等操作的時間複雜度是常量級的,但因爲哈希衝突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高。
- 散列表的構造比二叉查找樹要複雜,需要考慮的東西很多。比如散列函數的設計、衝突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。
- 爲了避免過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,不然會浪費一定的存儲空間。
綜合這幾點,平衡二叉查找樹在某些方面還是優於散列表的,所以,這兩者的存在並不衝突。我們在實際的開發過程中,需要結合具體的需求來選擇使用哪一個。
5. 平衡二叉查找樹