二叉搜索樹 Binary Search Tree

二叉搜索樹(Binary Search Tree,簡稱BST)也稱爲二叉查找樹、有序二叉樹(Ordered Binary Tree),或排序二叉樹(Sorted Binary Tree)。二叉搜索樹是一顆空樹,或具有以下性質的二叉樹:

  • 如果任意節點的左子樹不爲空,則左子樹上所有節點的值小於它的根節點的值。
  • 如果任意節點的右子樹不爲空,則右子樹上所有節點的值均大於或等於它的根節點的值。
  • 任意節點的左、右子樹也分別爲二叉搜索樹。

二叉查找樹相比於其他數據結構,優勢在於查找、插入、刪除的時間複雜度低,爲O(log n),比數組、鏈表等線性錶快很多。二叉查找樹是基礎性數據結構,用於構建更爲基礎的數據結構,如集合、多重集、關聯數組等。

請看以下決策樹,選擇一方時,放棄了另一方的所有可能性,從而將問題減半。

一旦做出決定,選擇了一個分支,就放棄了另一個分支,這樣可以減少一半的選擇。

這篇文章將介紹 BST 相對於數組的優勢,並實現一個二叉搜索樹。

1. 數組 VS 二叉搜索樹

爲了說明 BST 的性能優勢,將分析搜索、刪除、添加常見操作,並與數組的這些操作進行對比。

集合如下:

1.1 查找

要查找無序數組元素,只能從開始位置一個一個比較。

這就是爲什麼array.contains(_:)操作的複雜度爲O(n)

二叉查找樹查找過程如下:

搜索算法訪問每個節點時,都可以做出以下假設:

  • 如果要查找的值小於當前節點的值,它必定在當前節點的左子樹。
  • 如果要查找的值大於當前節點的值,它必定在當前節點的右子樹。

藉助 BST 的上述規則,你可以避免對比每一個元素,每次做出選擇時將範圍縮小一半。因此,二叉搜索樹查找複雜度爲O(log n)

1.2 插入

二叉搜索樹在插入方面的性能與查找類似。假設你要將0插入數組:

向數組插入元素就像插隊,所有插入點後面的元素都要後移,以便爲插入點提供空間。上圖向index爲零位置插入元素,所有元素都需後移一位。向數組插入元素複雜度爲O(n)

向二叉搜索樹插入元素如下:

藉助 BST,只需查找三次就可以定位到插入點,無需移動其他元素。BST 插入元素複雜度爲O(log n)

1.3 移除

與插入類似,移除數組的元素也可能觸發移動其它元素。

與排隊情景類似,中間有人離開,後面所有人都需要前移填補空隙。

下面是 BST 中移除元素:

當要移除的元素有子節點時,情況會複雜些,但其複雜度依然是O(log n)

Binary Search Tree大量減少了查找、插入、移除元素的操作步驟,下面將實現一個二叉搜索樹。

2. 實現二叉搜索樹 Binary Search Tree

創建一個 playground,導入上一篇文章二叉樹 Binary Tree創建的二叉樹BinaryNode。並創建BinarySearchTree.swift文件,其代碼如下:

public struct BinarySearchTree<Element: Comparable> {
    public private(set) var root: BinaryNode<Element>?
    
    public init() {}
}

extension BinarySearchTree: CustomStringConvertible {
    public var description: String {
        guard let root = root else { return "empty tree" }
        return String(describing: root)
    }
}

二叉搜索樹只接受可比較(comparable)的值。

2.1 插入

根據 BST 規則,節點左子樹值必須小於當前節點值,節點右子樹值必須大於等於當前節點的值。實現插入方法時必須遵守這一規則。

添加以下插入方法:

    /// 插入元素
    public mutating func insert(_ value: Element) {
        root = insert(from: root, value: value)
    }
    
    private func insert(from node: BinaryNode<Element>?, value: Element) -> BinaryNode<Element> {
        // 如果節點爲nil,則找到了插入點,返回節點,結束遞歸。
        guard let node = node else { return BinaryNode(value: value) }
        
        // Element 遵守 Comparable,比較值大小。
        if value < node.value { // 值小於當前節點,繼續與左子樹比較。
            node.leftChild = insert(from: node.leftChild, value: value)
        } else {    // 大於等於當前節點值,繼續與右子樹比較。
            node.rightChild = insert(from: node.rightChild, value: value)
        }
        
        return node
    }

在 playground page 添加以下代碼:

example(of: "building a BST") {
    var bst = BinarySearchTree<Int>()
    for i in 0..<5 {
        bst.insert(i)
    }
    print(bst)
}

輸出如下:

--- Example of building a BST ---
   ┌──4
  ┌──3
  │ └──nil
 ┌──2
 │ └──nil
┌──1
│ └──nil
0
└──nil

該樹遵守了二叉搜索樹規則,但並不平衡。不平衡的樹性能開銷會變大,使用二叉搜索樹時始終希望樹儘可能平衡。

不平衡的二叉樹很影響性能。例如,插入5時間複雜度會變爲O(n)

你可以創建自平衡的樹,具體細節將在下一篇文章AVL樹中介紹。目前,避免創建不平衡的二叉搜索樹即可。

在 playground page 中添加以下代碼:

var exampleTree: BinarySearchTree<Int> {
    var bst = BinarySearchTree<Int>()
    bst.insert(3)
    bst.insert(1)
    bst.insert(4)
    bst.insert(0)
    bst.insert(2)
    bst.insert(5)
    return bst
}

example(of: "building a BST") {
    print(exampleTree)
}

輸出如下:

--- Example of building a BST ---
 ┌──5
┌──4
│ └──nil
3
│ ┌──2
└──1
 └──0

2.2 查找

可以使用遍歷二叉樹的方式查找 BST 中的節點,但其複雜度爲O(n),與在數組中查找無異。

可以藉助 BST 自身的規則,避免無盡的比較。方法如下:

    /// 查找
    public func contains(_ value: Element) -> Bool {
        var current = root
        
        // 只要節點不爲空,繼續查找。
        while let node = current {
            if node.value == value {    // 值相等時返回 true。
                return true
            }
            
            // 根據值大小,決定與左子樹還是右子樹比較。
            if value < node.value {
                current = node.leftChild
            } else {
                current = node.rightChild
            }
        }
        
        return false
    }

平衡二叉樹查找複雜度爲O(log n)

2.3 移除

移除元素要相對複雜些,需分多種情況單獨處理:

2.3.1 移除葉子節點

移除葉子節點很簡單,只需分離葉子節點:

2.3.2 移除度爲一的節點

移除度爲一的節點時,需將子節點重新鏈接到整個樹。

2.3.3 移除度爲二的節點

假設有以下二叉搜索樹,想要移除25節點:

如果直接移除,會進入以下困境:

有兩個子樹需要鏈接到父節點,但父節點已經有一個子樹。

想要解決這個問題,可以執行一個交換。使用要移除節點右子樹的最小值(即右子樹最左側節點)覆蓋當前節點的值,這樣可以確保二叉搜索樹依然是二叉搜索樹。因爲新節點是右子樹最小值,右子樹其他節點值都會大於該節點。因爲新節點來自於右子樹,它會比左子樹所有節點值大。

交換後,只需移除被交換的節點即可,其是葉子節點,與移除葉子節點操作相同。

2.3.4 實現移除

使用以下方法實現移除:

    public mutating func remove(_ value: Element) {
        root = remove(node: root, value: value)
    }
    
    private func remove(node: BinaryNode<Element>?, value: Element) -> BinaryNode<Element>? {
        guard let node = node else { return nil }
        
        if value == node.value {
            // 葉子節點直接返回nil,即移除。
            if node.leftChild == nil && node.rightChild == nil {
                return nil
            }
            
            // 度爲一的節點,左子樹爲nil,返回右子樹。
            if node.leftChild == nil {
                return node.rightChild
            }
            
            // 度爲一的節點,右子樹爲nil,返回左子樹。
            if node.rightChild == nil {
                return node.leftChild
            }
            
            // 度爲二的節點。
            node.value = node.rightChild!.min.value
            node.rightChild = remove(node: node.rightChild, value: node.value)
            
        } else if value < node.value {
            node.leftChild = remove(node: node.leftChild, value: value)
        } else {
            node.rightChild = remove(node: node.rightChild, value: value)
        }
        
        return node
    }
    
private extension BinaryNode {
    var min: BinaryNode {
        leftChild?.min ?? self
    }
}

該方法也使用遞歸查找要移除的node,與insert類似。使用min遞歸查找子樹最小值節點。

使用以下方法測試移除:

example(of: "removing a node") {
    var tree = exampleTree
    print("Tree before removal:")
    print(tree)
    tree.remove(3)
    print("Tree after removing root:")
    print(tree)
}

輸出如下:

--- Example of removing a node ---
Tree before removal:
 ┌──5
┌──4
│ └──nil
3
│ ┌──2
└──1
 └──0

Tree after removing root:
┌──5
4
│ ┌──2
└──1
 └──0

3. 算法題

3.1 判斷二叉樹是否是二叉搜索樹

根據前面介紹,二叉搜索樹有以下規則:

  • 如果該二叉樹左子樹不爲空,則左子樹上所有節點的值小於它的根節點的值;
  • 如果該二叉樹右子樹不爲空,則右子樹上所有節點的值大於、等於它的根節點的值。
  • 它的左右子樹也是二叉搜索樹。

因此,可以設計一個遞歸函數isBST(tree, min, max)來遞歸判斷,該函數以tree爲根的子樹,子樹所有節點值是否都在(min, max)範圍內。如果不滿足,直接返回;如果滿足,繼續遞歸檢查它的左右子樹,都滿足時纔是一棵二叉搜索樹。

根據二叉搜索樹的性質,遞歸調用左子樹時,需要把上限改爲tree.value。遞歸調用右子樹時,需要把下限改爲tree.value

算法如下:

extension BinaryNode where Element: Comparable {
    var isBinarySearchTree: Bool {
        isBST(self, min: nil, max: nil)
    }
    
    private func isBST(_ tree: BinaryNode<Element>?, min: Element?, max: Element?) -> Bool {
        guard let tree = tree else {
            return true
        }
        
        if let min = min, tree.value <= min {
            return false
        } else if let max = max, tree.value > max {
            return false
        }
        
        return isBST(tree.leftChild, min: min, max: tree.value) && isBST(tree.rightChild, min: tree.value, max: max)
    }
}

在遞歸調用時,二叉樹的每個節點最多被訪問一次。因此時間複雜度爲O(n)。遞歸函數在遞歸過程中,需要爲每一層遞歸函數分配棧空間,所以這裏需要額外的空間,且該空間取決於遞歸的深度,即二叉樹的高度。最壞情況下二叉樹退化爲鏈表,樹的高度爲n,遞歸最深達到n層。因此,最壞情況下空間複雜度爲O(n)

中序遍歷先遍歷左子樹,後遍歷當前節點,最後遍歷右子樹。因此,二叉搜索樹中序遍歷得到的值一定是升序的。因此,也可以藉助中序遍歷檢查當前節點值是否大於前一箇中序遍歷到的節點值,來檢查其是否爲二叉搜索樹,具體實現可以點擊這裏查看。

3.2 相同的樹

給定兩個二叉樹,編寫一個函數來檢驗其是否相同。

當且僅當兩個二叉樹結構完全相同,對應節點的值相同,才認爲兩個二叉樹相同。因此,可以通過搜索的方式判斷兩個二叉樹是否相同。

如果兩個二叉樹都不爲空,那麼首先判斷其根節點值是否相同,若不相同則兩個二叉樹一定不同;若相同,則繼續遞歸判斷它的左右子樹是否相同。

extension BinarySearchTree: Equatable {
    public static func ==(lhs: BinarySearchTree, rhs: BinarySearchTree) -> Bool {
        isEqual(lhs.root, rhs.root)
    }
    
    private static func isEqual<Element: Equatable>( _ node1: BinaryNode<Element>?, _ node2: BinaryNode<Element>?) -> Bool {
        guard let leftNode = node1, let rightNode = node2 else { return node1 == nil && node2 == nil }
        
        return leftNode.value == rightNode.value && isEqual(leftNode.leftChild, rightNode.leftChild) && isEqual(leftNode.rightChild, rightNode.rightChild)
    }
}

該算法時間複雜度爲O(min(m,n))。其中,m、n爲二叉樹的節點數。對兩個二叉樹進行深度優先搜索時,只有當二叉樹節點都不爲空時,纔會訪問該節點。因此,最終訪問節點數不會超過較小二叉樹節點數。

該算法空間複雜度爲O(min(m,n))。其中,m、n爲二叉樹節點數。空間複雜度取決於遞歸調用的層數,遞歸調用的層數不會超過較小二叉樹的最大高度。最壞情況下,二叉樹的高度等於節點數。

也可以使用廣度優先遍歷兩個二叉樹是否相同。使用兩個隊列分別存儲兩個二叉樹節點,每次各取出一個節點進行比較,具體實現可以點擊這裏查看。

3.3 樹的子結構

輸入兩棵二叉樹A和B,判斷B是不是A的子結構。空樹不是任何一個樹的子結構。

B是A的子結構,即A中有出現和B結構、值相同的子樹。

如下面樹A:

     3
    / \
   4   5
  / \
 1   2

樹B:

   4 
  /
 1

上面的樹B是樹A的子結構。

如果樹B是樹A的子結構,則B的根節點可能是樹A的任意節點。因此,判斷樹B是否是樹A的子結構,需以下兩步:

  1. 先序遍歷樹A中的每個節點,是否包含樹B的根節點。
  2. 判斷樹A中查找到的節點是否包含樹B。

樹A的根節點計作節點a,樹b的根節點計作節點b。

算法如下:

    public static func isSubStructure(_ a: BinaryNode?, _ b: BinaryNode?) -> Bool {
        // 樹a、b爲空時,直接返回false。
        guard let a = a, let b = b else {
            return false
        }
        
        // 滿足以下三種情況之一即可
        return recur(a, b) || isSubStructure(a.leftChild, b) || isSubStructure(a.rightChild, b)
    }

    private static func recur(_ a: BinaryNode?, _ b: BinaryNode?) -> Bool {
        // b爲空時匹配完成,返回true。
        guard let b = b else { return true }
        // a 爲空,或a、b值不相等,匹配失敗。
        guard let a = a, a.value == b.value else { return false }
        
        // 繼續匹配子樹
        return recur(a.leftChild, b.leftChild) && recur(a.rightChild, b.rightChild)
    }

樹的子結構算法時間複雜度爲O(mn),m、n爲樹a、b的節點數量。前序遍歷樹a佔用o(m),調用recur(a, b)佔用O(n)。空間複雜度爲O(m)

總結

二叉搜索樹處理有序數據非常高效,其元素必須可比較。BST 插入、刪除、查找時間複雜度都是O(log n),但樹不平衡的話其性能會變差,最差變爲O(n)。下一篇文章AVL樹將會介紹一種平衡樹。

數據結構和算法相關問題有時不易理解,你可以藉助可視化網站查看其操作過程:

  • VISUALGO 提供了鏈表、哈希表、二叉搜索樹、排序、圖等多種數據結構和算法的可視化演示。
  • 小碼哥在線工具提供了二叉樹、二叉搜索樹、AVL樹、紅黑樹、二叉堆工具。
  • Data Structure Visualizations以動畫的形式提供了更多數據結構和算法的執行過程。

Demo名稱:BinarySearchTree
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/BinarySearchTree

歡迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/二分查找%20Binary%20Search.md

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