什麼是樹
樹是由結點和邊組成的,不存在環的一種數據結構。看下圖:
與樹相關的術語
樹的結點(node):包含一個數據元素及若干指向子樹的分支;
孩子結點(child node):結點的子樹的根稱爲該結點的孩子;
雙親結點:B 結點是A 結點的孩子,則A結點是B 結點的雙親;
兄弟結點:同一雙親的孩子結點;堂兄結點:同一層上結點;
葉子結點:也叫終端結點,是度爲 0 的結點;
樹的深度:樹中最大的結點層數;
二叉樹是什麼
二叉樹是一種被高頻使用的特殊樹。在二叉樹中,每個結點最多有兩個分支,即每個結點最多有兩個子結點,分別稱作左子結點和右子結點。
在二叉樹中,有下面兩個特殊的類型:
滿二叉樹,定義爲只有最後一層無任何子結點,其他所有層上的所有結點都有兩個子結點的二叉樹。
完全二叉樹,定義爲除了最後一層以外,其他層的結點個數都達到最大,並且最後一層的葉子結點都靠左排列。
完全二叉樹看上去並不完全,但爲什麼這樣稱呼它呢?這和二叉樹的存儲有關係。存儲二叉樹有兩種辦法,一種是基於指針的鏈式存儲法,另一種是基於數組的順序存儲法。
鏈式存儲法,也就是像鏈表一樣,每個結點有三個字段,一個存儲數據,另外兩個分別存放指向左右子結點的指針,如下圖所示:
順序存儲法,就是按照規律把結點存放在數組裏,如下圖所示,爲了方便計算,我們會約定把根結點放在下標爲 1 的位置。隨後,B 結點存放在下標爲 2 的位置,C 結點存放在下標爲 3 的位置,依次類推。
存儲方法:
根據這種存儲方法,我們可以發現如果結點 X 的下標爲 i,那麼 X 的左子結點總是存放在 2 * i 的位置,X 的右子結點總是存放在 2 * i + 1 的位置。
之所以稱爲完全二叉樹,是從存儲空間利用效率的視角來看的。對於一棵完全二叉樹而言,僅僅浪費了下標爲 0 的存儲位置。而如果是一棵非完全二叉樹,則會浪費大量的存儲空間。
樹的基本操作
- 樹的遍歷
遍歷一棵樹,有非常經典的三種方法,分別是前序遍歷、中序遍歷、後序遍歷。這裏的序指的是父結點的遍歷順序,前序就是先遍歷父結點,中序就是中間遍歷父結點,後序就是最後遍歷父結點。不管哪種遍歷,都是通過遞歸調用完成的。
前序遍歷,對樹中的任意結點來說,先打印這個結點,然後前序遍歷它的左子樹,最後前序遍歷它的右子樹。
中序遍歷,對樹中的任意結點來說,先中序遍歷它的左子樹,然後打印這個結點,最後中序遍歷它的右子樹。
後序遍歷,對樹中的任意結點來說,先後序遍歷它的左子樹,然後後序遍歷它的右子樹,最後打印它本身。
三種遍歷方式的代碼方式:
// 先序遍歷
func preOrder( _ root: TreeNode?) {
guard let rt = root else {
return
}
print(rt.val)
preOrder(rt.left)
preOrder(rt.right)
}
// 中序遍歷
func inOrderTraverse(_ root: TreeNode?) {
guard let rt = root else {
return
}
preOrder(rt.left)
print(rt.val)
preOrder(rt.right)
}
// 後序遍歷
func postOrderTraverse(_ root: TreeNode?) {
guard let rt = root else {
return
}
preOrder(rt.left)
preOrder(rt.right)
print(rt.val)
}
二叉樹遍歷過程中,每個結點都被訪問了一次,其時間複雜度是 O(n)。執行增加和刪除數據的操作時,在找到位置後,我們只需要通過指針建立連接關係就可以了。對於沒有任何特殊性質的二叉樹而言,拋開遍歷的時間複雜度以外,真正執行增加和刪除操作的時間複雜度是 O(1)。
這裏穿插講下遞歸
遞歸是指在函數的定義中使用函數自身的方法。
遞歸有兩層含義:
- 遞歸問題必須可以分解爲若干個規模較小、與原問題形式相同的子問題。並且這些子問題可以用完全相同的解題思路來解決;
- 遞歸問題的演化過程是一個對原問題從大到小進行拆解的過程,並且會有一個明確的終點(臨界點)。一旦原問題到達了這個臨界點,就不用再往更小的問題上拆解了。最後,從這個臨界點開始,把小問題的答案按照原路返回,原問題便得以解決。
通過二叉樹的定義和特性可知,二叉樹的遍歷可用遞歸的方式處理,臨界點是葉子結點的左子樹和右子樹皆爲空,爲空就return返回了。
二叉查找樹
二叉查找樹(也稱作二叉搜索樹)具備以下幾個的特性:
- 在二叉查找樹中的任意一個結點,其左子樹中的每個結點的值,都要小於這個結點的值。
- 在二叉查找樹中的任意一個結點,其右子樹中每個結點的值,都要大於這個結點的值。
- 在二叉查找樹中,會盡可能規避兩個結點數值相等的情況。
- 對二叉查找樹進行中序遍歷,就可以輸出一個從小到大的有序數據隊列。
二叉查找樹的查找操作
在利用二叉查找樹執行查找操作時,我們可以進行以下判斷:
- 首先判斷根結點是否等於要查找的數據,如果是就返回。
- 如果根結點大於要查找的數據,就在左子樹中遞歸執行查找動作,直到葉子結點。
- 如果根結點小於要查找的數據,就在右子樹中遞歸執行查找動作,直到葉子結點。
這樣的“二分查找”所消耗的時間複雜度就可以降低爲 O(logn)。代碼如下:
func searchBST(_ root: TreeNode?, _ val: Int) -> TreeNode? {
guard let rt = root else {
return nil
}
if rt.val > val {
return searchBST(rt.left, val)
} else if rt.val < val {
return searchBST(rt.right, val)
} else {
return rt
}
}
二叉查找樹的增加操作
從根結點開始,如果要插入的數據比根結點的數據大,且根結點的右子結點不爲空,則在根結點的右子樹中繼續嘗試執行插入操作。直到找到爲空的子結點執行插入動作。
二叉查找樹插入數據的時間複雜度是 O(logn)。但這並不意味着它比普通二叉樹要複雜。原因在於這裏的時間複雜度更多是消耗在了遍歷數據去找到查找位置上,真正執行插入動作的時間複雜度仍然是 O(1)。
func insertIntoBST(_ root: TreeNode?, _ val: Int) -> TreeNode? {
guard let currNode = root else {
return TreeNode(val)
}
if val > currNode.val {
root?.right = insertIntoBST(root?.right, val)
} else {
root?.left = insertIntoBST(root?.left, val)
}
return root
}
二叉查找樹的刪除操作
/*
根據二叉搜索樹的性質
如果目標節點大於當前節點值,則去右子樹中刪除;
如果目標節點小於當前節點值,則去左子樹中刪除;
如果目標節點就是當前節點,分爲以下三種情況:
其無左子:其右子頂替其位置,刪除了該節點;
其無右子:其左子頂替其位置,刪除了該節點;
其左右子節點都有:其左子樹轉移到其右子樹的最左節點的左子樹上,然後右子樹頂替其位置,由此刪除了該節點。
*/
func deleteNode(_ root: TreeNode?, _ key: Int) -> TreeNode? {
guard var root = root else {
return nil
}
if key > root.val {
root.right = deleteNode(root.right, key)
} else if key < root.val{
root.left = deleteNode(root.left, key)
} else {
if nil == root.right {
return root.left
} else if nil == root.left {
return root.right
} else if let _ = root.right, let _ = root.left {
var temp = root.right
while let _ = temp?.left {
temp = temp?.left
}
temp?.left = root.left
if let right = root.right {
root = right
}
}
}
return root
}
總結
本文主要講解的是二叉樹的基本理解和操作,理解二叉樹的各類操作無外乎查增刪,鏈式二叉樹增刪的時間複雜度都是0(1),真正耗時的是查,時間複雜度是O(logn),所以二叉樹的增刪查的時間複雜度都是0(logn)。另外二叉樹的查找分兩種,深度優先遍歷還是廣度優先遍歷,本文講的都是深度優先的查找操作即遞歸。