測試開發基礎之算法(12):支持動態數據集合快速插入、刪除、查找的二叉查找樹

上一篇文章,學習了二叉樹的前序、中序、後序和按層遍歷方法,以及如何求二叉樹的最大最小深度。
今天我們再來看一種更加特殊二叉樹——二叉查找樹。二叉查找樹最大的特點是,支持動態數據集合的快速插入、刪除、查找操作。
不過,你應該還記得,之前介紹的線性數據結構散列表,也支持數據的快速插入、刪除、查找操作,而且時間複雜度是 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

根據上述例子,我們可以得到下述規則:

  • 前驅節點
  1. 若一個節點有左子樹,那麼該節點的前驅節點是其左子樹中value值最大的節點。
  2. 若一個節點沒有左子樹,那麼判斷該節點和其父節點的關係。
    2.1 若該節點是其父節點的右節點,它的父節點就是它的前驅節點。
    2.2 若該節點是其父節點的左節點,那麼沿着其父親節點往根節點找,直到找到一個節點p,p節點是p的父節點pp的右節點(可參考例子2的前驅節點是1),那麼pp就是該節點的前驅節點。

類似,可以得到求後繼節點的規則如下。

  • 後繼節點
  1. 若一個節點有右子節點,那麼該節點的後繼節點是其右子樹中value值最小的節點。
  2. 若一個節點沒有右子節點,那麼判斷該節點和其父節點的關係。
    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.重複數據的二叉查找樹

前面介紹的二叉查找樹是不包含重複數據的情況。當有重複數據時,查找、插入和刪除高如何操作呢?

在查找插入位置的過程中,如果碰到一個節點的值,與要插入數據的值相同,我們就將這個要插入的數據放到這個節點的右子樹,也就是說,把這個新插入的數據當作大於這個節點的值來處理。
在這裏插入圖片描述
當要查找數據的時候,遇到值相同的節點,我們並不停止查找操作,而是繼續在右子樹中查找,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查找值的所有節點都找出來。
在這裏插入圖片描述
對於刪除操作,我們也需要先查找到每個要刪除的節點,然後再按前面講的刪除操作的方法,依次刪除。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章