1. 程式人生 > >4. 二分搜尋樹

4. 二分搜尋樹

一. 二分查詢法

  1. 對於有序數列,才能使用二分查詢法(排序的作用)
  2. 遞迴實現通常思維起來更容易, 但效能不如 while迴圈法

程式碼

#include <iostream>
#include <cassert>
#include <ctime>

using namespace std;

// 二分查詢法,在有序陣列arr中,查詢target
// 如果找到target,返回相應的索引index
// 如果沒有找到target,返回-1
template<typename T>
int binarySearch(T arr[], int n, T target){

    // 在arr[l...r]之中查詢target
    int l = 0, r = n-1;
    while( l <= r ){

        //int mid = (l + r)/2;
        // 防止極端情況下的整形溢位,使用下面的邏輯求出mid
        int mid = l + (r-l)/2;

        if( arr[mid] == target )
            return mid;

        if( arr[mid] > target )
            r = mid - 1;
        else
            l = mid + 1;
    }

    return -1;
}


// 用遞迴的方式寫二分查詢法
template<typename T>
int __binarySearch2(T arr[], int l, int r, T target){

    if( l > r )
        return -1;

    //int mid = (l+r)/2;
    // 防止極端情況下的整形溢位,使用下面的邏輯求出mid
    int mid = l + (r-l)/2;

    if( arr[mid] == target )
        return mid;
    else if( arr[mid] > target )
        return __binarySearch2(arr, l, mid-1, target);
    else
        return __binarySearch2(arr, mid+1, r, target);
}

template<typename T>
int binarySearch2(T arr[], int n, T target){

    return __binarySearch2( arr , 0 , n-1, target);
}


// 比較非遞迴和遞迴寫法的二分查詢的效率
// 非遞迴演算法在效能上有微弱優勢
int main() {

    int n = 1000000;
    int* a = new int[n];
    for( int i = 0 ; i < n ; i ++ )
        a[i] = i;

    // 測試非遞迴二分查詢法
    clock_t startTime = clock();

    // 對於我們的待查詢陣列[0...N)
    // 對[0...N)區間的數值使用二分查詢,最終結果應該就是數字本身
    // 對[N...2*N)區間的數值使用二分查詢,因為這些數字不在arr中,結果為-1
    for( int i = 0 ; i < 2*n ; i ++ ){
        int v = binarySearch(a, n, i);
        if( i < n )
            assert( v == i );
        else
            assert( v == -1 );
    }
    clock_t endTime = clock();
    cout << "Binary Search (Without Recursion): " << double(endTime - startTime) / CLOCKS_PER_SEC << " s"<<endl;


    // 測試遞迴的二分查詢法
    startTime = clock();

    // 對於我們的待查詢陣列[0...N)
    // 對[0...N)區間的數值使用二分查詢,最終結果應該就是數字本身
    // 對[N...2*N)區間的數值使用二分查詢,因為這些數字不在arr中,結果為-1
    for( int i = 0 ; i < 2*n ; i ++ ){
        int v = binarySearch2(a, n, i);
        if( i < n )
            assert( v == i );
        else
            assert( v == -1 );
    }
    endTime = clock();
    cout << "Binary Search (Recursion): " << double(endTime - startTime) / CLOCKS_PER_SEC << " s"<<endl;

    delete[] a;

    return 0;
}

二. 二分搜尋樹基礎 (Binary Search Tree)

二分搜尋數的優勢

  • 體現在 實現類似 python中的 dict 資料結構 key-value
  • 高效
不僅可以查詢資料; 還可以高效地插入, 刪除資料-動態維護資料
可以方面的回答很多資料之間的關係問題

 min, max, floor, ceil, rank, select

 

特點

 

 

二分搜尋數的基礎結構編寫

main.cpp

#include <iostream>

using namespace std;

// 二分搜尋樹
template <typename Key, typename Value>
class BST{

private:
    // 二分搜尋樹中的節點為私有的結構體, 外界不需要了解二分搜尋樹節點的具體實現
    struct Node{
        Key key;
        Value value;
        Node *left;   
        Node *right;

        Node(Key key, Value value){
            this->key = key;
            this->value = value;
            this->left = this->right = NULL;
        }
    };

    Node *root; // 根節點
    int count;  // 節點個數

public:
    // 建構函式, 預設構造一棵空二分搜尋樹
    BST(){
        root = NULL;
        count = 0;
    }
    ~BST(){
        // TODO: ~BST()     析構 比較複雜, 後面講解
    }

    // 返回二分搜尋樹的節點個數
    int size(){
        return count;
    }

    // 返回二分搜尋樹是否為空
    bool isEmpty(){
        return count == 0;
    }
};

int main() {

    return 0;
}

三. 二分搜尋數的節點插入

  • 從根節點開始尋找合適的位置
  • 找到滿足 左子節點 < 父節點 < 右子節點

程式碼 main.cpp

class BST{

...
private:
...
    // 向二分搜尋樹中插入一個新的(key, value)資料對
    void insert(Key key, Value value){
        root = insert(root, key, value);
    }

private:
    // 向以node為根的二分搜尋樹中, 插入節點(key, value), 使用遞迴演算法
    // 返回插入新節點後的二分搜尋樹的根
    Node* insert(Node *node, Key key, Value value){

        if( node == NULL ){
            count ++;
            return new Node(key, value);
        }

        if( key == node->key )
            node->value = value;
        else if( key < node->key )
            node->left = insert( node->left , key, value);
        else    // key > node->key
            node->right = insert( node->right, key, value);

        return node;
    }
};

...

四. 二分搜尋樹的查詢

  • 查詢的邏輯和insert 類似
  • 實現 contain(是否包含) 和 search(查詢key對應的value)

程式碼 main.cpp

// 二分搜尋樹
template <typename Key, typename Value>
class BST{

...

public:
    ...
    // 檢視二分搜尋樹中是否存在鍵key
    bool contain(Key key){
        return contain(root, key);
    }

    // 在二分搜尋樹中搜索鍵key所對應的值。如果這個值不存在, 則返回NULL
    Value* search(Key key){
        return search( root , key );
    }

private:
    
    ...

    // 檢視以node為根的二分搜尋樹中是否包含鍵值為key的節點, 使用遞迴演算法
    bool contain(Node* node, Key key){

        if( node == NULL )
            return false;

        if( key == node->key )
            return true;
        else if( key < node->key )
            return contain( node->left , key );
        else // key > node->key
            return contain( node->right , key );
    }

    // 在以node為根的二分搜尋樹中查詢key所對應的value, 遞迴演算法
    // 若value不存在, 則返回NULL
    Value* search(Node* node, Key key){

        if( node == NULL )
            return NULL;

        if( key == node->key )
            return &(node->value);
        else if( key < node->key )
            return search( node->left , key );
        else // key > node->key
            return search( node->right, key );
    }
};




五. 二分搜尋樹的遍歷(深度優先遍歷)

二分搜尋樹的 前中後序遍歷

  • 前序遍歷: 先訪問當前節點, 再依次遞迴訪問左右子樹
  • 中序遍歷(可以實現排序): 先遞迴訪問左子樹, 再訪問當前節點和 遞迴訪問右子樹
  • 後序遍歷(可以實現釋放): 先遞迴訪問左右子樹, 再訪問當前節點

程式碼 main.cpp


// 二分搜尋樹
template <typename Key, typename Value>
class BST{

private:
    ...

    // 二分搜尋樹的前序遍歷
    void preOrder(){
        preOrder(root);
    }

    // 二分搜尋樹的中序遍歷
    void inOrder(){
        inOrder(root);
    }

    // 二分搜尋樹的後序遍歷
    void postOrder(){
        postOrder(root);
    }

private:
    ...

    // 對以node為根的二叉搜尋樹進行前序遍歷, 遞迴演算法
    void preOrder(Node* node){

        if( node != NULL ){
            cout<<node->key<<endl;
            preOrder(node->left);
            preOrder(node->right);
        }
    }

    // 對以node為根的二叉搜尋樹進行中序遍歷, 遞迴演算法
    void inOrder(Node* node){

        if( node != NULL ){
            inOrder(node->left);
            cout<<node->key<<endl;
            inOrder(node->right);
        }
    }

    // 對以node為根的二叉搜尋樹進行後序遍歷, 遞迴演算法
    void postOrder(Node* node){

        if( node != NULL ){
            postOrder(node->left);
            postOrder(node->right);
            cout<<node->key<<endl;
        }
    }

    // 釋放以node為根的二分搜尋樹的所有節點
    // 採用後續遍歷的遞迴演算法
    void destroy(Node* node){

        if( node != NULL ){
            destroy( node->left );
            destroy( node->right );

            delete node;
            count --;
        }
    }
};


// 測試二分搜尋樹的前中後序遍歷
int main() {

    srand(time(NULL));
    BST<int,int> bst = BST<int,int>();

    // 取n個取值範圍在[0...m)的隨機整數放進二分搜尋樹中
    int N = 10;
    int M = 100;
    for( int i = 0 ; i < N ; i ++ ){
        int key = rand()%M;
        // 為了後續測試方便,這裡value值取和key值一樣
        int value = key;
        cout<<key<<" ";
        bst.insert(key,value);
    }
    cout<<endl;

    // 測試二分搜尋樹的size()
    cout<<"size: "<<bst.size()<<endl<<endl;

    // 測試二分搜尋樹的前序遍歷 preOrder
    cout<<"preOrder: "<<endl;
    bst.preOrder();
    cout<<endl;

    // 測試二分搜尋樹的中序遍歷 inOrder
    cout<<"inOrder: "<<endl;
    bst.inOrder();
    cout<<endl;

    // 測試二分搜尋樹的後序遍歷 postOrder
    cout<<"postOrder: "<<endl;
    bst.postOrder();
    cout<<endl;

    return 0;
}

六. 層序遍歷(廣度優先遍歷)

程式碼

main.cpp

...
#include <queue>

...
    // 二分搜尋樹的層序遍歷
    void levelOrder(){

        queue<Node*> q;
        q.push(root);  // 
        while( !q.empty() ){
            // 被遍歷到的節點 出隊, 並將其子節點放入佇列
            Node *node = q.front(); 
            q.pop();

            cout<<node->key<<endl;

            if( node->left )
                q.push( node->left );
            if( node->right )
                q.push( node->right );
        }
    }
...

    

七. 刪除最大值, 最小值

  • 最左的node 即最小值
  • 最右邊的node 即 最大值

程式碼 main.cpp


 // 尋找二分搜尋樹的最小的鍵值
    Key minimum(){
        assert( count != 0 );
        Node* minNode = minimum( root );
        return minNode->key;
    }

    // 尋找二分搜尋樹的最大的鍵值
    Key maximum(){
        assert( count != 0 );
        Node* maxNode = maximum(root);
        return maxNode->key;
    }

    // 從二分搜尋樹中刪除最小值所在節點
    void removeMin(){
        if( root )
            root = removeMin( root );
    }

    // 從二分搜尋樹中刪除最大值所在節點
    void removeMax(){
        if( root )
            root = removeMax( root );
    }
    
    ...
    
    // 返回以node為根的二分搜尋樹的最小鍵值所在的節點
    Node* minimum(Node* node){
        if( node->left == NULL )
            return node;

        return minimum(node->left);
    }

    // 返回以node為根的二分搜尋樹的最大鍵值所在的節點
    Node* maximum(Node* node){
        if( node->right == NULL )
            return node;

        return maximum(node->right);
    }

    // 刪除掉以node為根的二分搜尋樹中的最小節點
    // 返回刪除節點後新的二分搜尋樹的根
    Node* removeMin(Node* node){

        if( node->left == NULL ){

            Node* rightNode = node->right;
            delete node;
            count --;
            return rightNode;
        }

        node->left = removeMin(node->left);
        return node;
    }

    // 刪除掉以node為根的二分搜尋樹中的最大節點
    // 返回刪除節點後新的二分搜尋樹的根
    Node* removeMax(Node* node){

        if( node->right == NULL ){

            Node* leftNode = node->left;
            // 為什麼node-left  一定比 node的父節點小?   
            // 因為insert的時候都是從root開始查詢合適的位置的
            delete node;
            count --;
            return leftNode;
        }

        node->right = removeMax(node->right);
        return node;
    }

八. 二分搜尋樹節點的刪除

 

程式碼 main.cpp

// 二分搜尋樹
template <typename Key, typename Value>
class BST{

private:
    // 樹中的節點為私有的結構體, 外界不需要了解二分搜尋樹節點的具體實現
    struct Node{
        Key key;
        Value value;
        Node *left;
        Node *right;

        Node(Key key, Value value){
            this->key = key;
            this->value = value;
            this->left = this->right = NULL;
        }

        Node(Node *node){
            this->key = node->key;
            this->value = node->value;
            this->left = node->left;
            this->right = node->right;
        }
    };

    ...
     // 從二分搜尋樹中刪除鍵值為key的節點
    void remove(Key key){
        root = remove(root, key);
    }
    
    ...
    
     // 刪除掉以node為根的二分搜尋樹中鍵值為key的節點, 遞迴演算法
    // 返回刪除節點後新的二分搜尋樹的根
    Node* remove(Node* node, Key key){

        if( node == NULL )
            return NULL;

        if( key < node->key ){
            node->left = remove( node->left , key );
            return node;
        }
        else if( key > node->key ){
            node->right = remove( node->right, key );
            return node;
        }
        else{   // key == node->key

            // 待刪除節點左子樹為空的情況
            if( node->left == NULL ){
                Node *rightNode = node->right;
                delete node;
                count --;
                return rightNode;
            }

            // 待刪除節點右子樹為空的情況
            if( node->right == NULL ){
                Node *leftNode = node->left;
                delete node;
                count--;
                return leftNode;
            }

            // 待刪除節點左右子樹均不為空的情況

            // 找到比待刪除節點大的最小節點, 即待刪除節點右子樹的最小節點
            // 用這個節點頂替待刪除節點的位置
            Node *successor = new Node(minimum(node->right));
            count ++;

            successor->right = removeMin(node->right);
            successor->left = node->left;

            delete node;
            count --;

            return successor;
        }
    }

補充說明

  • 我們除了使用 s = min(d->right) 代替被刪除節點d,
  • 也可以用 p = max(d->left) 代替被刪除節點d

九. 二分搜尋樹的侷限性

如果二分搜尋樹 極不平衡, 會導致演算法從O(logn)退化為O(n)級別

極不平衡的二分搜尋樹
        1
         \
          2
           \
            3
             \
              4
               \
                5

為了解決二分搜尋樹的缺陷,可以使用平衡二叉樹 例如 紅黑樹