Python 樹表查詢_千樹萬樹梨花開,忽如一夜春風來(二叉排序樹、平衡二叉樹)

一枚大果殼發表於2022-04-28

什麼是樹表查詢?

藉助具有特殊性質的樹資料結構進行關鍵字查詢。

本文所涉及到的特殊結構性質的樹包括:

  • 二叉排序樹
  • 平衡二叉樹

使用上述樹結構儲存資料時,因其本身對結點之間的關係以及順序有特殊要求,也得益於這種限制,在查詢某一個結點時會帶來效能上的優勢和操作上的方便。

樹表查詢屬於動態查詢演算法。

所謂動態查詢,不僅僅能很方便查詢到目標結點。而且可以根據需要新增、刪除結點,而不影響樹的整體結構,也不會影響資料的查詢。

本文並不會深入講解樹資料結構的基本的概念,僅是站在使用的角度說清楚動態查詢。閱讀此文之前,請預備一些樹的基礎知識。

1. 二叉排序樹

二叉樹是樹結構中具有豔明特點的子類。

二叉樹要求樹的每一個結點(除葉結點)的子結點最多隻能有 2 個。在二叉樹的基礎上,繼續對其進行有序限制則變成二叉排序樹

二叉排序樹特點:

基於二叉樹結構,從根結點開始,從上向下,每一個父結點的值大於左子結點(如果存在左子結點)的值,而小於右子結點(如果存在右子結點)的值。則把符合這種特徵要求的樹稱為二叉排序樹

1.1 構建一棵二叉排序樹

如有數列 nums=[5,12,4,45,32,8,10,50,32,3]。通過下面流程,把每一個數字對映到二叉排序樹的結點上。

  1. 如果樹為空,把第一個數字作為根結點。如下圖,數字 5 作為根結點。

ts01.png

  1. 如果已經存在根結點,則把數字和根結點比較,小於根結點則作為根結點的左子結點,大於根結點的作為根結點的右子結點。如數字 4 插入到左邊,數字 12 插入到右邊。

ts02.png

  1. 數列中後面的數字依據相同法則,分別插入到不同子的位置。

ts03.png

原始數列中的數字是無序的,根據二叉排序樹的插入演算法,最終可得到一棵有排序性質的樹結構。對此棵樹進行中序遍歷就可得到從小到大的一個遞增有序數列。

綜觀二叉排序樹,進行關鍵字查詢時,也應該是接近於二分查詢演算法的時間度。

這裡有一個要注意的地方。

原始數列中的數字順序不一樣時,生成的二叉排序樹的結構也會有差異性。對於查詢演算法的效能會產生影響。

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
怎麼檢查插入的結點是符合二叉樹特徵?

再看一下前面根據插入原則手工繪製的插入演示圖:

ts03.png

上圖有 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->
   '''

二叉排序樹很有特色的資料結構,利用其儲存特性,可以很方便地進行查詢、排序。並且隨時可新增、刪除結點,而不會影響排序和查詢操作。基於樹表的查詢操作稱為動態查詢。

二叉排序樹中如何刪除結點

從二叉樹中刪除結點,需要保證整棵二叉排序樹的有序性依然存在。刪除操作比插入操作要複雜,下面分別討論。

  1. 如果要刪除的結點是葉子結點。

只需要把要刪除結點的父結點的左結點或右結點的引用值設定為空就可以了。

  1. 刪除的結點只有一個右子結點。如下圖刪除結點 8

ts04.png

因為結點8沒有左子樹,在刪除之後,只需要把它的右子結點替換刪除結點就可以了。

ts05.png

  1. 刪除的結點即存在左子結點,如下圖刪除值為 25 的結點。

ts06.png

一種方案是:找到結點 25 的左子樹中的最大值,即結點 20(該結點的特點是可能會存在左子結點,但一定不會有右子結點)。用此結點替換結點25 便可。

為什麼要這麼做?

道理很簡單,既然是左子樹中的最大值,替換刪除結點後,整個二叉排序樹的特性可以繼續保持。

ts07.png

如果結點 20 存在左子結點,則把它的左子結點作為結點18的右子結點。

另一種方案:同樣找到結點25中左子樹中的最大值結點 20,然後把結點 25 的右子樹作為結點 20 的右子樹。

ts08.png

再把結點 25 的左子樹移到 25 位置。

ts09.png

這種方案會讓樹增加樹的深度。所以,建議使用第一種方案。

刪除方法的實現:

 	'''
    刪除結點
    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]構建的二叉排序樹如下圖:

ts10.png

基於上面的樹結構,查詢任何一個結點的次數不會超過 3 次。

稍調整一下數列中數字的順序 [20,28,36,40,45,67],由此構建出來的樹結構會出現一邊倒的現象,也增加了樹的深度。

ts11.png

此棵樹的深度為6,最多查詢次數是 6 次。在二叉排序樹中,減少查詢次數的最好辦法,就是儘可能維護樹左右子樹之間的對稱性,也就讓其有平衡性。

所謂平衡二叉排序樹,顧名思義,基於二叉排序樹的基礎之上,維護任一結點的左子樹和右子樹之間的深度之差不超過 1。把二叉樹上任一結點的左子樹深度減去右子樹深度的值稱為該結點的平衡因子。

平衡因子只可能是:

  • 0 :左、右子樹深度一樣。
  • 1:左子樹深度大於右子樹。
  • -1:左子樹深度小於右子樹。

如下圖,就是平衡二叉排序樹,根結點的 2 個子樹深度相差為 0, 結點 28 的左、右子樹深度為 1,結點 45 的左右子樹深度相差為 0

ts10.png

平衡二叉排序樹相比較於二叉排序樹,其 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型調整(順時針)左邊不平衡時,向右邊旋轉。

ts12.png

如上圖,現在根結點 36 的平衡因子為 1。如果現插入值為 18 結點,顯然要作為結點 20 的左子結點,才符合二叉排序樹的有序性。但是破壞了根結點的平衡性。根結點的左子樹深度變成 3,右子樹深度為1,平衡被打破,結點 36 的平衡因子變成了2

ts13.png

這裡可以使用順時針旋轉方式,讓其繼續保持平衡,旋轉流程:

  • 讓結點 28 成為新根結點,結點36成為結點28的左子結點。
  • 結點29成為結點36的新左子結點。

ts14.png

旋轉後,樹結構即滿足了有序性,也滿足了平衡性。

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 插入後,樹的平衡性被打破。

ts15.png

這裡使用左旋轉(逆時針)方案。結點 36 成為結點 45 的左子結點,結點45 原來的左子結點成為結點36的右子結點。

ts16.png

向逆時針旋轉後,結點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 旋轉演算法。

ts17.png

以結點 29 作為新的根結點,結點27以結點29為旋轉中心,逆時針旋轉。

ts18.png

結點36以結點29為旋轉中心向順時針旋轉。

ts19.png

最後得到的樹還是一棵二叉平衡排序樹

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 旋轉演算法進行調整。

ts20.png

把結點40設定為新的根結點,結點45以結點 40 為中心點順時針旋轉,結點36逆時針旋轉。

ts21.png

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 為結點的數量)。

相關文章