上一篇文章,學習了二叉樹的前序、中序、後序和按層遍歷方法,以及如何求二叉樹的最大最小深度。
今天我們再來看一種更加特殊二叉樹——二叉查找樹。二叉查找樹最大的特點是,支持動態數據集合的快速插入、刪除、查找操作。
不過,你應該還記得,之前介紹的線性數據結構散列表,也支持數據的快速插入、刪除、查找操作,而且時間複雜度是 O(1)。二叉查找樹還能比這個時間複雜厲害?帶着這個問題,我們開始學習二叉查找樹。
1. 二叉查找樹
二叉查找樹的特點是,當前節點的值,大於它左子樹中所有節點的值,而小於它右子樹中所有節點的值。舉幾個例子看看:
2.查找數據
利用二叉查找樹的特點,當前結點node大於左子樹node.left的所有節點,小於右子樹node.right的所有節點。因此,查找值爲data 的節點時,當data<node時,則要在左子樹中繼續查找,如果data>node時,則要在右子樹中繼續查找,直到查找的節點爲None,表示沒有找到。用代碼表示:
from typing import TypeVar, Generic, Optional
T = TypeVar("T")
class TreeNode(Generic[T]):
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def search(node: Optional[TreeNode], data: Generic[T]) -> Optional[TreeNode]:
while node:
if data < node.value: # 在左子樹中找
node = node.left
elif data > node.value: # 在右子樹中找
node = node.right
else: # 找到了
return node
return None
if __name__ == '__main__':
root = TreeNode(16) # 構造一個二叉查找樹,樣子就是第一節中間那個圖
first = TreeNode(10)
second = TreeNode(9)
third = TreeNode(13)
fourth = TreeNode(11)
fifth = TreeNode(14)
root.left = first
first.left = second
first.right = third
third.left = fourth
third.right = fifth
print(search(root, 13))
3.插入數據
插入的操作跟查找數據類似。先插入的節點一般會插在葉子節點,也是從根節點開始,逐個比較節點與插入的數據大小。
當二叉查找樹的根節點爲空時,將數據直接放到根節點就行了。
如果插入的數據比當前節點大,看看這個節點的右子樹是否爲空,如果是空,直接將數據放到右子節點上。如果不空,則在右子樹上繼續找插入的位置。
如果插入的數據比當前節點小,看看這個節點的左子樹是否爲空,如果爲空,直接將數據放到左子節點上。如果不空,則在左子樹上繼續找插入的位置。
代碼如下:
def insert(node: Optional[TreeNode], data: Generic[T]):
if node is None:
node = TreeNode(data)
return None
while node:
if data < node.value:
if node.right is None:
node.right = TreeNode(data)
return None
node = node.right
else:
if node.left is None:
node.left = TreeNode(data)
return None
node = node.left
4.刪除數據
給定一個二叉搜索樹的根節點 node 和一個值value ,刪除二叉搜索樹中的 value 對應的節點,並保證刪除後依然是二叉查找樹。
相比查找和插入,刪除操作有點複雜。需要考慮三種情況。
我們以刪除上面樹中55,13,18,三個被刪除節點爲例,說明一下刪除的思路和過程。
- 55這個節點,沒有左子節點,也沒有右子節點。刪除55這個節點,只需要將他的父節點指向None就行了。
- 13這個節點,只有一個子節點,沒有左子節點,只有右子節點。刪除13這個節點,將它父節點指向它的指針,指向它的右子節點。
- 18這個節點,有兩個子節點,用它的右子樹中的最小節點替換它,然後再刪除右子樹中的最小節點(最小節點肯定沒有左子節點如果有左子結點,那就不是最小節點了)。如何刪除右子樹的最小節點呢?參考刪除55和13這兩個節點的方法。
可見整個刪除過程中,查找被刪除節點和它的父節點是關鍵。
下面看看代碼實現:
def remove(node: Optional[TreeNode], data: Generic[T]) -> Optional[TreeNode]:
pp = None
while node and node.value != data:
pp = node # 被刪除節點的父節點
node = node.left if node.value > data else node.right # node是被刪除節點
if node is None: # 沒找到
return None
# 被刪除的節點有兩個子節點
if node.left and node.right: # 尋找右子樹的最小節點(這個節點肯定沒有左子節點,要麼是葉子節點要麼只有一個右子節點)
min_p = node.right # 最小節點,初始化爲被刪除節點的右節點
min_pp = node # 最小節點的父節點,初始化爲被刪除節點
while min_p.left: # 最小節點肯定在被刪除節點的右子樹的左子樹中
min_p = min_p.left
min_pp = min_p
node.value = min_p.value # 最小節點的值放到node上
# 這兩句話就是把問題轉化爲刪除最小節點min_p的問題了(畫下圖就能明白了)
pp = min_pp
node = min_p
# 刪除的節點是葉子節點或者僅有一個子節點(如果上面的if成立,這裏的node就是min_p了)
if node.left: # 當有一個左子節點,找到它的子節點child
child = node.left
elif node.right: # 當有一個右子節點,找到它的子節點child
child = node.right
else: # node是葉子節點,它的子節點就是None
child = None
if pp is None: # 刪除的是根節點
node = child
elif pp.left == node:
pp.left = child
else:
pp.right = child
if __name__ == '__main__':
root = TreeNode(16)
first = TreeNode(10)
second = TreeNode(9)
third = TreeNode(13)
fourth = TreeNode(11)
fifth = TreeNode(14)
seven = TreeNode(18)
root.left = first
root.right = seven
first.left = second
first.right = third
third.left = fourth
third.right = fifth
remove(root, 16)
print(list(in_order(root)))
參考https://leetcode-cn.com/problems/delete-node-in-a-bst/solution/python3cai-yong-suan-fa-4ti-gong-de-si-lu-bu-zou-q/的解答。
5. 查找最大最小節點
二叉查找樹的最大節點是在右子樹中沒有右子節點的那個結點。最小節點是左子樹中沒有左子節點的那個結點。
思路很清晰了,既可以用遞歸實現也可以循環實現。
對於遞歸查找最小節點,一開始先判斷節點是否爲空,如果爲空就返回None,否則一直往左遞歸,直到找到最左節點,則是最小節點。
對於遞歸查找最大節點,一開始先判斷節點是否爲空,如果爲空就返回None,否則一直往右遞歸,直到找到最右節點,則是最大節點。
迭代方法查找也是,一開始判斷結點空不空,不空就進入while循環。對於查找最小值,只要節點的左子樹不爲None,就讓node=node.left,直到node.left爲None了,就證明找到了最左的節點,此時退出了while循環,return node。
查找最大節點方法一樣,循環右子樹直到找到最右結點,再return出去就可以了。
def min_node(node: Optional[TreeNode[T]]) -> Optional[TreeNode]:
if node is None:
return None
# while node.left:
# node = node.left
# return node
elif node.left is None:
return node
else:
return min_node(node.left)
def max_node(node: Optional[TreeNode[T]]) -> Optional[TreeNode]:
if node is None:
return None
# while node.right:
# node = node.right
# return node
elif node.right is None:
return node
else:
return max_node(node.right)
6.查找前驅節點和後繼節點
前驅節點(predecessor)指的是比給定節點value值小的所有節點中最大的節點。後繼節點(successor)指的是比給定結點value值大的所有節點中最小的節點。
換個說法,前驅節點就是給定節點左子樹中最右邊節點(right most node),後繼節點就是給定節點右子樹中最左邊的節點(left most node)。舉例,下圖6的前驅節點是左子樹的最右邊節點(right most node)5,後繼節點是右子樹的最左邊節點(left most node)7。
從上面的圖中,可以得出:
- 6的前驅結點是5,後繼節點是7
- 2的前驅節點是1,後繼節點是3
- 4的前驅節點是3,後繼節點是5
根據上述例子,我們可以得到下述規則:
- 前驅節點
- 若一個節點有左子樹,那麼該節點的前驅節點是其左子樹中value值最大的節點。
- 若一個節點沒有左子樹,那麼判斷該節點和其父節點的關係。
2.1 若該節點是其父節點的右節點,它的父節點就是它的前驅節點。
2.2 若該節點是其父節點的左節點,那麼沿着其父親節點往根節點找,直到找到一個節點p,p節點是p的父節點pp的右節點(可參考例子2的前驅節點是1),那麼pp就是該節點的前驅節點。
類似,可以得到求後繼節點的規則如下。
- 後繼節點
- 若一個節點有右子節點,那麼該節點的後繼節點是其右子樹中value值最小的節點。
- 若一個節點沒有右子節點,那麼判斷該節點和其父節點的關係。
2.1 若該節點是其父節點的左子節點,那麼該節點的父節點就是後繼節點。
2.2 若該節點是其父節點的右子節點,那麼沿着其父親節點往根節點找,直到找到一個節點p,p節點是其父節點pp的左子節點(可參考例子5的後繼結點是6),那麼pp就是該節點的後繼節點。
當然我們可以對一個二叉搜索樹直接進行中序遍歷,立馬可以得到節點的前驅和後繼節點,但是這樣的方法時間複雜度爲O(N),顯然不是最好的方法。而上面算法的時間複雜度是O(logN)。
前面的規則,我們是從下往上來尋找前驅節點和後繼節點的。但是在編碼時,我們沒有辦法從下往上,只能從上往下查找,在查找過程中記錄父節點。
有了上面的規則,我們用代碼來實現一下。
def inorder_successor(root: Optional[TreeNode[T]], value) -> Optional[TreeNode[T]]:
"""
後繼節點(successor)指的是比給定結點value值大的所有節點中最小的節點。換個說法,後繼節點就是給定節點右子樹中最左邊的節點(left most node)
算法思路:從根節點開始逐個與給定節點對比。見下面註釋。
:param root: 當前節點
:param value: 給定結點的值
:return: 後繼節點
"""
# 方法一:通過遞歸
# if root:
# if root.value > value: # value在root的左子樹中
# return inorder_successor(root.left, value) or root
# return inorder_successor(root.right, value)
# 方法二:通過迭代
res = None # 後繼節點res,初始化爲None
while root: # 當前節點初始化爲root,隨着迭代的進行,root在變
if root.value > value: # 當前節點值比給定節點的值大
res = root # 當前結點作爲後繼節點,但不一定哦
root = root.left # 到左子樹中繼續找是否也有比給定值大的節點,如果有更新res,如果沒有左子樹,while循環結束,返回res。
else: # 當前節點的值比給定節點小
root = root.right # 在到右子樹找
return res # 返回後繼節點
def inorder_predecessor(root: Optional[TreeNode[T]], value) -> Optional[TreeNode[T]]:
"""
前驅節點(predecessor)指的是比給定結點value值小的所有節點中最大的節點。換個說法,前驅節點就是給定節點左子樹中最右邊的節點(right most node)
算法思路:從根節點開始逐個與給定節點對比。見下面註釋。
:param root: 當前節點
:param value: 給定結點的值
:return: 前驅節點
"""
# 方法一:通過遞歸
# if root:
# if root.value < value: # 當前節點值小於給定節點
# return inorder_predecessor(root.right, value) or root
# return inorder_predecessor(root.left, value)
# 方法二:通過迭代
res = None # 前驅節點res,初始化爲None
while root: # 當前節點初始化爲root,隨着迭代的進行,root在變
if root.value < value: # 當前節點值比給定節點的值小
res = root # 當前結點作爲前驅節點,但不一定是真正的前驅節點
root = root.right # 到右子樹中繼續找是否也有比給定值小的節點,如果有更新res,如果沒有右子樹,while循環結束,返回res。
else:
root = root.left # 在到左子樹找
return res # 返回前驅節點
二叉查找樹除了支持上面幾個操作之外,還有一個重要的特性,就是中序遍歷二叉查找樹,可以輸出有序的數據序列,時間複雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
7.二叉查找樹的時間複雜度分析
下面是三中二叉查找輸的例子,從平衡性角度,他們的結構差別比較大。最左邊的極度不平衡,已經從二叉樹退化成鏈表了。最右邊的是完全二叉樹,是高度平衡的。不管操作是插入、刪除還是查找,時間複雜度其實都跟樹的高度成正比,也就是 O(height)。而height最大值是N,最小值是log2N。因此二叉查找樹的時間複雜度,介於O(N)和O(log2N)。
8.二叉查找樹與散列表的比較
散列表的插入、刪除、查找操作的時間複雜度可以做到常量級的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時間複雜度纔是 O(logn),相對散列表,好像並沒有什麼優勢,那我們爲什麼還要用二叉查找樹呢?
主要原因應該有以下幾點:
第一,散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而二叉查找樹中序遍歷,就可以在O(n)的時間複雜度內輸出有序序列。
第二,散列表擴容比較耗時,遇到散列衝突時,性能衰減太快。儘管二叉查找樹的性能也不穩定,但是工程實際應用中的平衡二叉查找樹的性能非常穩定,是O(logN)。
第三,散列表的構造比較複雜,需要考慮哈希算法設計、散列衝突、擴容、縮容等,而二叉查找樹的構造,只需要考慮平衡性。
第四,儘快散列表的時間複雜度是常量級O(1),但是遇到散列衝突時,這個常量不一定比二叉查找樹的logN小。加上哈希函數的耗時,總的效率不一定比平衡二叉查找樹高。
9. 求N個節點的完全二叉樹的高度
可以參考https://liuchunming.blog.csdn.net/article/details/103420491這篇文章中,求二叉樹最大最小深度的練習題。
11.重複數據的二叉查找樹
前面介紹的二叉查找樹是不包含重複數據的情況。當有重複數據時,查找、插入和刪除高如何操作呢?
在查找插入位置的過程中,如果碰到一個節點的值,與要插入數據的值相同,我們就將這個要插入的數據放到這個節點的右子樹,也就是說,把這個新插入的數據當作大於這個節點的值來處理。
當要查找數據的時候,遇到值相同的節點,我們並不停止查找操作,而是繼續在右子樹中查找,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查找值的所有節點都找出來。
對於刪除操作,我們也需要先查找到每個要刪除的節點,然後再按前面講的刪除操作的方法,依次刪除。