Python 樹表查找(二叉排序樹、平衡二叉樹)
什麼是樹表查詢?
借助具有特殊
性質的樹數據結構
進行關鍵字查找。
本文所涉及到的特殊結構性質的樹包括:
二叉排序樹
。 平衡二叉樹
。
使用上述樹結構
存儲數據時,因其本身對結點之間的關系以及順序有特殊要求,也得益於這種限制,在查詢某一個結點時會帶來性能上的優勢和操作上的方便。
樹表查詢屬於動態查找
算法。
所謂動態查找
,不僅僅能很方便查詢到目標結點。而且可以根據需要添加、刪除結點,而不影響樹的整體結構,也不會影響數據的查詢。
本文並不會深入講解樹數據結構
的基本的概念,僅是站在使用的角度說清楚動態查詢。閱讀此文之前,請預備一些樹的基礎知識。
1. 二叉排序樹
二叉樹
是樹結構中具有艷明特點的子類。
二叉樹
要求樹的每一個結點(除葉結點)的子結點最多隻能有 2
個。在二叉樹
的基礎上,繼續對其進行有序限制則變成二叉排序樹
。
二叉排序樹特點:
基於二叉樹
結構,從根結點開始,從上向下,每一個父結點的值大於左子結點(如果存在左子結點)的值,而小於右子結點(如果存在右子結點)的值。則把符合這種特征要求的樹稱為二叉排序樹
。
1.1 構建一棵二叉排序樹
如有數列 nums=[5,12,4,45,32,8,10,50,32,3]
。通過下面流程,把每一個數字映射到二叉排序樹
的結點上。
如果樹為空,把第一個數字作為根結點。如下圖,數字 5
作為根結點。
如果已經存在根結點,則把數字和根結點比較,小於根結點則作為根結點的左子結點,大於根結點的作為根結點的右子結點。如數字 4
插入到左邊,數字 12
插入到右邊。
數列中後面的數字依據相同法則,分別插入到不同子的位置。
原始數列中的數字是無序的,根據二叉排序樹
的插入算法,最終可得到一棵有排序性質的樹結構。對此棵樹進行中序遍歷
就可得到從小到大的一個遞增有序數列。
綜觀二叉排序樹
,進行關鍵字查找時,也應該是接近於二分查找算法的時間度。
這裡有一個要註意的地方。
原始數列中的數字順序不一樣時,生成的二叉排序樹的結構也會有差異性。對於查找算法的性能會產生影響。
1.2 二叉排序樹的數據結構
現在使用OOP
設計方案描述二叉排序樹的數據結構。
首先,設計一個結點類,用來描述結點本身的信息。
''' 二叉排序樹的結點類 ''' class TreeNode(): def __init__(self, value): # 結點上的值 self.value = value # 左結點 self.l_child = None # 右結點 self.r_child = None
結點類中有 3
個屬性:
value
:結點上附加的數據信息。 l_child
:左子結點,初始值為 None
。 r_child
:右子結點,初始值為 None
。
二叉排序樹類: 用來實現樹的增、刪、改、查。
''' 二叉排序樹類 ''' class BinarySortTree: # 初始化樹 def __init__(self, value=None): pass ''' 在整棵樹上查詢是否存在給定的關鍵字 ''' def find(self, key): pass ''' 使用遞歸進行查詢 ''' def find_dg(self, root, key): pass ''' 插入新結點 ''' def insert(self, value): pass ''' 中序遍歷 ''' def inorder_traversal(self): pass ''' 刪除結點 ''' def delete(self, key): pass ''' 檢查是不是空樹 ''' def is_empty(self): return self.root == None
二叉排序樹中可以有更多方法,本文隻關註與查找主題有關的方法。
1.3 實現二叉排序樹類中的方法:
__init__
初始化方法:
# 初始化樹 def __init__(self, value=None): self.root = None if value is not None: root_node = TreeNode(value) self.root = root_node
在初始化樹對象時,如果指定瞭數據信息,則創建有唯一結點的樹,否則創建一個空樹。
關鍵字查詢方法:查詢給定的關鍵字在二叉排序樹結構中是否存在。
查詢流程:
把給定的關鍵字和根結點相比較。如果相等,則返回查找成功,結束查詢. 如果根結點的值大於關鍵字,則繼續進入根結點的左子樹中開始查找。 如果根結點的值小於關鍵字,則進入根結點的右子樹中開始查找。 如果沒有查詢到關鍵字,則返回最後訪問過的結點和查詢不成功信息。
關鍵字查詢的本質是二分思想,以當前結點為分界線,然後向左或向右進行分枝查找。
非遞歸實現查詢方法:
''' 在整棵樹上查詢是否存在給定的關鍵字 key: 給定的關鍵字 ''' def find(self, key): # 從根結點開始查找。 move_node = self.root # 用來保存最後訪問過的結點 last_node = None while move_node is not None: # 保存當前結點 last_node = move_node # 把關鍵字和當前結點相比較 if self.root.value == key: # 出口一:成功查找 return move_node elif move_node.value > key: # 在左結點查找 move_node = move_node.l_child else: # 在右結點中查找 move_node = move_node.r_child # 出口二:如果沒有查詢到,則返回最後訪問過的結點及None(None 表示沒查詢到) return last_node, None
註意:當沒有查詢到時,返回的值有 2
個,最後訪問的結點和沒有查詢到的信息。
為什麼要返回最後一次訪問過的結點?
反過來想想,本來應該在這個地方找到,但是沒有,如果改成插入操作,就應該插入到此位置。
基於遞歸實現的查找:
''' 使用遞歸進行查詢 ''' def find_dg(self, root, key): # 結點不存在 if root is None: return None # 相等 if root.value == key: return root if root.value > key: return self.find_dg(root.l_child, key) else: return self.find_dg(root.r_child, key)
再看看如何把數字插入到二叉排序樹中,利用二叉排序樹進行查找的前提條件就是要把數字映射到二叉排序樹的結點上。
插入結點的流程:
當需要插入某一個結點時,先搜索是否已經存在於樹結構中。 如果沒有,則獲取到查詢時訪問過的最一個結點,並和此結點比較大小。 如果比此結點大,則插入最後訪問過結點的右子樹位置。 如果比此結點小,則插入最後訪問過結點的左子樹位置。
insert
方法的實現:
''' 插入新結點 ''' def insert(self, value): # 查詢是否存在此結點 res = self.find(value) if type(res) != TreeNode: # 沒找到,獲取查詢時最後訪問過的結點 last_node = res[0] # 創建新結點 new_node = TreeNode(value) # 最後訪問的結點是根結點 if last_node is None: self.root = new_node if value > last_node.value: last_node.r_child = new_node else: last_node.l_child = new_node
怎麼檢查插入的結點是符合二叉樹特征?
再看一下前面根據插入原則手工繪制的插入演示圖:
上圖有 4
個子結點,寫幾行代碼測試一下,看從根結點到葉子結點的順序是否正確。
測試插入方法:
if __name__ == "__main__": nums = [5, 12, 4, 45, 32, 8, 10, 50, 32, 3] tree = BinarySortTree(5) for i in range(1, len(nums)): tree.insert(nums[i]) print("測試根5 -> 左4 ->左3:") tmp_node = tree.root while tmp_node != None: print(tmp_node.value, end=" ->") tmp_node = tmp_node.l_child print("\n測試根5 -> 右12 ->右45->右50:") tmp_node = tree.root while tmp_node != None: print(tmp_node.value, end=" ->") tmp_node = tmp_node.r_child ''' 輸出結果: 測試根5 -> 左4 ->左3: 5 ->4 ->3 -> 測試根5 -> 右12 ->右45->右50: 5 ->12 ->45 ->50 -> '''
查看結果,可以初步判斷插入的數據是符合二叉排序樹特征的。當然,更科學的方式是寫一個遍歷方法。樹的遍歷方式有 3
種:
前序:根,左,右。 中序:左,根,右。 後序。左,右,根。
對二叉排序樹
進行中序遍歷,理論上輸出的數字應該是有序的。這裡寫一個中序遍歷,查看輸出的結點是不是有序的,從而驗證查詢和插入方法的正確性。
使用遞歸實現中序遍歷:
''' 中序遍歷 ''' def inorder_traversal(self, root): if root is None: return self.inorder_traversal(root.l_child) print(root.value,end="->") self.inorder_traversal(root.r_child)
測試插入的順序:
if __name__ == "__main__": nums = [5, 12, 4, 45, 32, 8, 10, 50, 32, 3] tree = BinarySortTree(5) # res = tree.find(51) for i in range(1, len(nums)): tree.insert(nums[i]) tree.inorder_traversal(tree.root) ''' 輸出結果 3->4->5->8->10->12->32->45->50-> '''
二叉排序樹很有特色的數據結構,利用其存儲特性,可以很方便地進行查找、排序。並且隨時可添加、刪除結點,而不會影響排序和查找操作。基於樹表的查詢操作稱為動態查找。
二叉排序樹中如何刪除結點
從二叉樹中刪除結點,需要保證整棵二叉排序樹的有序性依然存在。刪除操作比插入操作要復雜,下面分別討論。
如果要刪除的結點是葉子結點。
隻需要把要刪除結點的父結點的左結點或右結點的引用值設置為空就可以瞭。
刪除的結點隻有一個右子結點。如下圖刪除結點 8
。
因為結點8
沒有左子樹,在刪除之後,隻需要把它的右子結點替換刪除結點就可以瞭。
刪除的結點即存在左子結點,如下圖刪除值為 25
的結點。
一種方案是:找到結點 25
的左子樹中的最大值,即結點 20
(該結點的特點是可能會存在左子結點,但一定不會有右子結點)。用此結點替換結點25
便可。
為什麼要這麼做?
道理很簡單,既然是左子樹中的最大值,替換刪除結點後,整個二叉排序樹的特性可以繼續保持。
如果結點 20
存在左子結點,則把它的左子結點作為結點18
的右子結點。
另一種方案:同樣找到結點25
中左子樹中的最大值結點 20
,然後把結點 25
的右子樹作為結點 20
的右子樹。
再把結點 25
的左子樹移到 25
位置。
這種方案會讓樹增加樹的深度。所以,建議使用第一種方案。
刪除方法的實現:
''' 刪除結點 key 為要要刪除的結點 ''' def delete(self, key): # 從根結點開始查找,move_node 為搜索指針 move_node = self.root # 要刪除的結點的父結點,因為根結點沒有父結點,初始值為 None parent_node = None # 結點存在且沒有匹配上要找的關鍵字 while move_node is not None and move_node.value != key: # 保證當前結點 parent_node = move_node if move_node.value > key: # 在左子樹中繼續查找 move_node = move_node.l_child else: # 在右子樹中繼續查找 move_node = move_node.r_child # 如果不存在 if move_node is None: return -1 # 檢查要刪除的結點是否存在左子結點 if move_node.l_child is None: if parent_node is None: # 如果要刪除的結點是根結點 self.root = move_node.r_child elif parent_node.l_child == move_node: # 刪除結點的右結點作為父結點的左結點 parent_node.l_child = move_node.r_child elif parent_node.r_child == move_node: parent_node.r_child = move_node.r_child return 1 else: # 如果刪除的結點存在左子結點,則在左子樹中查找最大值 s = move_node.l_child q = move_node while s.r_child is not None: q = s s = s.r_child if q == move_node: move_node.l_child = s.l_child else: q.r_child = s.l_child move_node.value = s.value q.r_child = None return 1
測試刪除後的二叉樹是否依然維持其有序性。
if __name__ == "__main__": nums = [5, 12, 4, 45, 32, 8, 10, 50, 32, 3] tree = BinarySortTree(5) # res = tree.find(51) for i in range(1, len(nums)): tree.insert(nums[i]) tree.delete(12) tree.inorder_traversal(tree.root) ''' 輸出結果 3->4->5->8->10->32->45->50-> '''
無論刪除哪一個結點,其二叉排序樹的中序遍歷結果都是有序的,很好地印證瞭刪除算法的正確性。
3. 平衡二叉排序樹
二叉排序樹
中進行查找時,其時間復雜度
理論上接近二分算法
的時間復雜度,其查找時間與樹的深度有關。但是,這裡有一個問題,前面討論過,如果數列中的數字順序不一樣時,所構建出來的二叉排序樹的深度會有差異性,對最後評估時間性能也會有影響。
如有數列 [36,45,67,28,20,40]
構建的二叉排序樹如下圖:
基於上面的樹結構,查詢任何一個結點的次數不會超過 3
次。
稍調整一下數列中數字的順序 [20,28,36,40,45,67]
,由此構建出來的樹結構會出現一邊倒的現象,也增加瞭樹的深度。
此棵樹的深度為6
,最多查詢次數是 6
次。在二叉排序樹中,減少查找次數的最好辦法,就是盡可能維護樹左右子樹之間的對稱性,也就讓其有平衡性。
所謂平衡二叉排序樹,顧名思義,基於二叉排序樹的基礎之上,維護任一結點的左子樹和右子樹之間的深度之差不超過 1
。把二叉樹上任一結點的左子樹深度減去右子樹深度的值稱為該結點的平衡因子。
平衡因子隻可能是:
0
:左、右子樹深度一樣。 1
:左子樹深度大於右子樹。 -1
:左子樹深度小於右子樹。
如下圖,就是平衡二叉排序樹
,根結點的 2
個子樹深度相差為 0
, 結點 28
的左、右子樹深度為 1,結點 45
的左右子樹深度相差為 0
。
平衡二叉排序樹相比較於二叉排序樹,其 API
多瞭保持平衡的算法。
3.1 二叉平衡排序樹的數據結構
結點類:
''' 結點類 ''' class TreeNode: def __init__(self,value): self.value=value self.l_child=None self.r_child=None self.balance=0
結點類中有 4
個屬性:
value
:結點上附加的值。 l_child
:左子結點。 r_child
:右子結點。 balance
:平衡因子,默認平衡因子為 0
。
二叉平衡排序樹類:
''' 樹類 ''' class Tree: def __init__(self, value): self.root = None ''' LL型調整 ''' def ll_rotate(self, node): pass ''' RR 型調整 ''' def rr_rotate(self, node): pass ''' LR型調整 ''' def lr_rotate(self, node): pass ''' RL型調整 ''' def rl_rotate(self, node): pass ''' 插入新結點 ''' def insert(self, value): pass ''' 中序遍歷 ''' def inorder_traversal(self, root): pass def is_empty(self): pass
在插入或刪除結點時,如果導致樹結構發生瞭不平衡性,則需要調整讓其達到平衡。這裡的方案可以有 4
種。
LL型調整(順時針)
:左邊不平衡時,向右邊旋轉。
如上圖,現在根結點 36
的平衡因子為 1
。如果現插入值為 18
結點,顯然要作為結點 20
的左子結點,才符合二叉排序樹的有序性。但是破壞瞭根結點的平衡性。根結點的左子樹深度變成 3
,右子樹深度為1
,平衡被打破,結點 36
的平衡因子變成瞭2
。
這裡可以使用順時針
旋轉方式,讓其繼續保持平衡,旋轉流程:
讓結點 28
成為新根結點,結點36
成為結點28
的左子結點。 結點29
成為結點36
的新左子結點。
旋轉後,樹結構即滿足瞭有序性,也滿足瞭平衡性。
LL
旋轉算法具體實現:
''' LL型調整 順時針對調整 ''' def ll_rotate(self, p_root): # 原父結點的左子結點成為新父結點 new_p_root = p_root.l_child # 新父結點的右子結點成為原父結點的左子結點 p_root.l_child = new_p_root.r_child # 原父結點成為新父結點的右子結點 new_p_root.r_child = p_root # 重置平衡因子 p_root.balance = 0 new_p_root.balance = 0 return new_p_root
RR 型調整(逆時針旋轉)
:RR
旋轉和 LL
旋轉的算法差不多,隻是當右邊不平衡時,向左邊旋轉。
如下圖所示,結點 50
插入後,樹的平衡性被打破。
這裡使用左旋轉(逆時針)方案。結點 36
成為結點 45
的左子結點,結點45
原來的左子結點成為結點36
的右子結點。
向逆時針旋轉後,結點45
的平衡因子為 0
,結點36
的平衡因子為0
,結點 48
的平衡因子為 -1
。樹的有序性和平衡性得到保持。
RR
旋轉算法具體實現:
''' RR 型調整 ''' def rr_rotate(self, node): # 右子結點 new_p_node = p_node.r_child p_node.r_child = new_p_node.l_child new_p_node.l_child = p_node # 重置平衡因子 p_node.balance = 0 new_p_node.balance = 0 return new_p_node
**LR型調整(先逆後順)
:**如下圖當插入結點 28
後,結點 36
的平衡因子變成 2
,則可以使用 LR
旋轉算法。
以結點 29
作為新的根結點,結點27
以結點29
為旋轉中心,逆時針旋轉。
結點36
以結點29
為旋轉中心向順時針旋轉。
最後得到的樹還是一棵二叉平衡排序樹
。
LR
旋轉算法實現:
''' LR型調整 ''' def lr_rotate(self, p_node): # 左子結點 b = p_node.l_child new_p_node = b.r_child p_node.l_child = new_p_node.r_child b.r_child = new_p_node.l_child new_p_node.l_child = b new_p_node.r_child = p_node if new_p_node.balance == 1: p_node.balance = -1 b.balance = 0 elif new_p_node.balance == -1: p_node.balance = 0 b.balance = 1 else: p_node.balance = 0 b.balance = 0 new_p_node.balance = 0 return new_p_node
RL
型調整: 如下圖插入結點39
後,整棵樹的平衡打破,這時可以使用 RL
旋轉算法進行調整。
把結點40
設置為新的根結點,結點45
以結點 40
為中心點順時針旋轉,結點36
逆時針旋轉。
RL
算法具體實現:
''' RL型調整 ''' def rl_rotate(self, p_node): b = p_node.r_child new_p_node = b.l_child p_node.r_child = new_p_node.l_child b.l_child = new_p_node.r_child new_p_node.l_child = p_node new_p_node.r_child = b if new_p_node.balance == 1: p_node.balance = 0 b.balance = -1 elif new_p_node.balance == -1: p_node.balance = 1 b.balance = 0 else: p_node.balance = 0 b.balance = 0 new_p_node.balance = 0 return new_p_node
編寫完上述算法後,就可以編寫插入算法。在插入新結點時,檢查是否破壞二叉平衡排序樹的的平衡性,否則調用平衡算法。
當插入一個結點後,為瞭保持平衡,需要找到最小不平衡子樹。
什麼是最小不平衡子樹?
指離插入結點最近,且平衡因子絕對值大於 1
的結點為根結點構成的子樹。
''' 插入新結點 ''' def insert(self, val): # 新的結點 new_node = TreeNode(val) if self.root is None: # 空樹 self.root = new_node return # 記錄離 s 最近的平衡因子不為 0 的結點。 min_b = self.root # f 指向 a 的父結點 f_node = None move_node = self.root f_move_node = None while move_node is not None: if move_node.value == new_node.value: # 結點已經存在 return if move_node.balance != 0: # 尋找最小不平衡子樹 min_b = move_node f_node = f_move_node f_move_node = move_node if new_node.value < move_node.value: move_node = move_node.l_child else: move_node = move_node.r_child if new_node.value < f_move_node.value: f_move_node.l_child = new_node else: f_move_node.r_child = new_node move_node = min_b # 修改相關結點的平衡因子 while move_node != new_node: if new_node.value < move_node.value: move_node.balance += 1 move_node = move_node.l_child else: move_node.balance -= 1 move_node = move_node.r_child if min_b.balance > -2 and min_b.balance < 2: # 插入結點後沒有破壞平衡性 return if min_b.balance == 2: b = min_b.l_child if b.balance == 1: move_node = self.ll_rotate(min_b) else: move_node = self.lr_rotate(min_b) else: b = min_b.r_child if b.balance == 1: move_node = self.rl_rotate(min_b) else: move_node = self.rr_rotate(min_b) if f_node is None: self.root = move_node elif f_node.l_child == min_b: f_node.l_child = move_node else: f_node.r_child = move_node
中序遍歷: 此方法為瞭驗證樹結構還是排序的。
''' 中序遍歷 ''' def inorder_traversal(self, root): if root is None: return self.inorder_traversal(root.l_child) print(root.value, end="->") self.inorder_traversal(root.r_child)
二叉平衡排序樹本質還是二樹排序樹。如果使用中序遍歷輸出的數字是有序的。測試代碼。
if __name__ == "__main__": nums = [3, 12, 8, 10, 9, 1, 7] tree = Tree(3) for i in range(1, len(nums)): tree.inster(nums[i]) # 中序遍歷 tree.inorder_traversal(tree.root) ''' 輸出結果 1->3->7->8->9->10->12-> '''
4. 總結
利用二叉排序樹
的特性,可以實現動態查找
。在添加、刪除結點之後,理論上查找到某一個結點的時間復雜度與樹的結點在樹中的深度是相同的。
但是,在構建二叉排序樹時,因原始數列中數字順序的不同,則會影響二叉排序樹的深度。
這裡引用二叉平衡排序樹,用來保持樹的整體結構是平衡,方能保證查詢的時間復雜度為 Ologn
(n
為結點的數量)。
到此這篇關於Python 樹表查找(二叉排序樹、平衡二叉樹)的文章就介紹到這瞭,更多相關Python 樹表查找內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- C++實現LeetCode(98.驗證二叉搜索樹)
- C++實現LeetCode(99.復原二叉搜索樹)
- java基礎二叉搜索樹圖文詳解
- C++實現LeetCode(105.由先序和中序遍歷建立二叉樹)
- C++實現AVL樹的完整代碼