二叉查詢樹

HuDu發表於2020-07-16

查詢的過程,簡而言之就是一個查表的過程,這這裡說的表是泛指,他可以是是小檔案,也可以是一張大的表格,比如資料庫檔案。

查詢的方式有很多,我們最早接觸的是順序查詢、對半查詢等,斐波那契查詢這種方式可能比較少見。

本文介紹幾種以二叉樹為底層資料的查詢方式,如二叉查詢樹,平衡樹等幾種。

1. 二叉查詢樹


顧名思義,二叉查詢樹就是一種二叉樹,形式,構造等屬性相對於二叉樹來說並麼有什麼變化,只是增加了一項規則——對於二叉查詢樹中任意結點P,其上所儲存的關鍵字大於左子樹上的所有結點所儲存的關鍵字,其右子樹上的所有結點儲存的關鍵字都大於P上所儲存的關鍵字。

本文所述的二叉查詢樹是以連結結構實現的,並且做出如下約定:
key(p):存放結點p的關鍵字資訊。
llink(p):存放結點p的左子樹指標。
rlink(p):存放結點p的右子樹指標。

三個結點的二叉查詢樹
三個結點的二叉查詢樹

知道了定義,但是如何構建一個二叉查詢樹,這就是我們所需要思考的第一個問題:

構造二叉查詢樹1
構造二叉查詢樹1

構造二叉查詢樹2
構造二叉查詢樹2

一個無序的序列可以通過構造一棵二叉查詢樹而成為有序序列,這被稱為二叉查詢樹的順序屬性。

而二叉查詢樹會因為輸入檔案所包含記錄對應的關鍵詞序列不同而有不同的二叉樹形態。準確的說,一個包含N個記錄的集合,其對應的關鍵詞有N!中不同排列,可以構成C(2N, N) / (N+1)種不同的二叉查詢樹。所以我們可以知道,上述二叉查詢樹的構造過程只是其中一種。

既然這種二叉樹被稱為二叉查詢樹,那麼我們構建好了一個二叉查詢樹又該怎麼去查詢某一個元素呢?假設我們現在在上述已經構造成功的二叉查詢樹中尋找一個關鍵字為21的記錄:

  • 21 > 13,應該在以元素值為13的結點的右子樹中查詢。
  • 21 < 25,應該在以元素值為25的結點的左子樹中查詢。
  • 21 < 23,應該在以元素值為23的結點的左子樹中查詢。
  • 21 > 19,應該在以元素值為19的結點的右子樹中查詢。
  • 元素值為19的結點無右子樹,查詢失敗,陣列中不存在該元素。

以上是查詢元素值為21的過程,查詢一個已經存在於該陣列中的元素就交給讀者來獨自驗證了。

構造和查詢只是二叉查詢樹的兩種基本操作,一個二叉查詢樹,首先是樹,其次是二叉樹,然後才是二叉查詢樹,所以我們可以知道,二叉查詢樹也應該具有和二叉樹相同的基本操作——插入刪除。但由於二叉查詢樹元素的儲存是有自身的規則,所以我們對於二叉查詢樹的插入和刪除實現就略有些麻煩了。

先說插入一個元素K:

  • 插入前先進行查詢,如果在查詢樹中找到該元素,則結束插入;
  • 否則,在查詢不成功的最後一個位置處插入該結點;
    • if (K > key(p) && rlink(p) != NULL) 在rlink(p)處插入該元素。
    • if (K < key(p) && llink(p) != NULL) 在llink(p)處插入該元素。

其次是刪除一個元素K:

我們分兩種不同的情況進行討論,第一種是rlink(p)=NULL。

  • 刪除前先進行查詢,如果未能在查詢樹中找到該元素,則結束刪除;
  • 否則,在查詢成功的情況下,進行如下判斷;
    • if (rlink(p) == NULL && llink(p) != NULL) 則將左子樹上移,替換原結點,即將指向被刪除結點的指標修改為指向被刪除結點的左子樹。
    • if (rlink(p) == NULL && llink(p) == NULL) 則建立一個空的外結點,替換原結點。

第二種則是rlink(p) != NULL && rlink(llink(p)) == NULL。

  • 刪除前進行查詢,如果未能在查詢樹中找到該元素,則結束刪除;
  • 否則,在查詢成功的情況下,進行如下判斷;
    • if (rlink(p) !== NULL && llink(rlink((p)) == NULL) 則將rlink(p)指向的子樹上移,替換原結點。
    • if (rlink(p) == NULL && llink(rliink(p)) != NULL) 則不斷的取llink(p),直到找到一個結點r使得llink(r)=NULL,即while (llink(p) != NULL) { p = llink(p); r = p; } 然後用結點r替換結點p,結點r的右子樹上升成為結點s的左子樹(結點s滿足llink(s) = r 條件)。

二叉查詢樹的刪除操作在文末給出,僅做參考。

2. 最優二叉查詢樹


一般的二叉查詢樹操作的平均時間代價為2的對數階O(logN),如果二叉樹退化(左右子樹不均勻)那麼就會出現最壞的情況,時間代價為線性階。

仔細想想,一般情況下,我們資料的訪問頻率是不同的,就像計算機中各個指令的使用頻率也有不同,從CISC指令集中刪去部分不常用的指令之後就形成了RISC指令集,並不影響使用者的使用。

這裡所舉CISC的例子可能有不妥,但是可以作為一個對比參考。

對資料訪問頻率的不同意味著,有些資料可能在整個程式的生命週期內都訪問不到,有些訪問的頻率會達到100%(當然,這種情況過於極端),那麼我們如果將訪問頻率達到100%的資料放在靠近根結點的位置,那麼對於這些資料的查詢耗時就大大降低,從而達到增強整個程式的效果。如此一來,我們便可以想到根據資料訪問頻率來構造一棵最優二叉查詢樹

如何構造這種查詢樹變成了問題,根據數學中的包含關係,我們可以知道,一棵最優樹的所有子樹都是最優的,那麼根據這一個性質,我們可以想到,構造一個最優樹可以從最優子樹開始,一步一步的構造,可以描述為,系統的尋找越來越大的最優子樹,轉化為動態規劃問題。

如何實現,腦子裡第一反應應該是為每一個元素結點域增加一個計數器用來記錄訪問次數,計數器值越大,說明該元素的訪問頻率高,擺放位置距離根結點越近,這樣想是覺得沒有什麼問題,因為我們是基於歷史記錄進行構造。但是實現時,我們會發現要做到這幾點:

  • 按照計數器值的大小進行排序再錄入。
  • 程式執行期間計數器值變化,不能讓二叉樹結構也跟著一起變化。
  • 本次程式執行時產生的資料計數值成為下一次程式執行時的參考。

如果需要實現這種方式,我們需要對資料進行一次排序,然後再構造二叉樹,每次程式的執行結果需要儲存到本地。不難看出,我們排序、構造以及資料的儲存時間代價非常高,如果資料非常多的時候,那麼這種方式就會影響整個程式的效能。

但是如果設定的更新頻率很低,比如一個月進行一次資料的更新,或是設定成程式開啟多少次之後再進行更新,這種方式倒也不是不可以接受。

3. 平衡樹


對於一個二叉查詢樹來說,如果資料的輸入是隨機的,那麼我們可以得到一個比較好的查詢樹,但是我們向其中插入多個新元素,這個二叉樹依舊可能會出現退化導致查詢的時間代價變為線性階。

在前文中,我們說過,一個二叉樹如果不退化(左右子樹的分佈較為均勻),那麼這個二叉查詢樹的效率就會比較高。所以我們想到了保持其左右子樹的均勻分佈來達到不使二叉查詢樹退化的目的——亦即保持樹的平衡。

這時候我們可以把樹視為一個天平,插入操作就相當於向天平兩端加砝碼,刪除操作即為拿掉砝碼。
對半查詢以及斐波那契查詢樹的判定樹就是高度平衡樹,平衡樹本質上依舊是一棵二叉查詢樹,所以它具有二叉查詢樹的所有性質。

平衡樹又細分為高度平衡樹以及重量平衡樹,其中高度平衡樹是由單一外結點或是由兩個子樹T1和T2組成,並且滿足如下兩個條件:

  • |h(T1) - h(T2)|<=1,其中h(T)表示樹T的高度。
  • T1和T2都是高度平衡樹。

平衡樹的查詢路徑絕對不會超過最優二叉樹查詢路徑長度的45%,由Adelson-Velsky和Landis定理可知,一棵具有N個內結點的平衡樹T,其高度h (T)由下式所限定log 2 (N +1) <= h(T) <= 1.4404 x log 2 (N +2) - 0.3277。

在知道了高度平衡樹的性質之後,我們需要在進行插入、刪除操作之後依舊能夠使其保持平衡,我們在這裡主要討論高度平衡樹在平衡被破壞之後如何調整以保持平衡:

  • LL型(R旋轉): 新結點P 插到A 的左子樹的左子樹上

    R旋轉
    R旋轉

  • RR型(L旋轉): 新結點P 插入到A 的右子樹的右子樹上

    L旋轉
    L旋轉

  • LR型(LR旋轉): 結點P 插入到A 的左子樹的右子樹上

    LR旋轉
    LR旋轉

LR旋轉過程分解
LR旋轉過程分解

  • RL型(RL旋轉): 結點P 插入到A 的右子樹的左子樹上
    RL旋轉
    RL旋轉

RL旋轉過程分解
RL旋轉過程分解

以上四種情況的部分圖示參考自劉大有版《資料結構》。

4. 二叉查詢樹的部分函式實現


本節僅做參考,可以略去。

//二叉查詢樹的插入與查詢
BSTNode *SearchAndInsert(int k)
{
    if (root == NULL)  //root表示根結點
    {
        //如果樹為空,則直接插入結點,返回空
        root = new BSTNode(k, NULL, NULL);  //結點的建立,key=k,llink=rlink=NULL
        return NULL;
    }

    BSTNode *p = root;
    while (p != NULL)
    {
        if (k == p->key) return p;

        if (k < p->key)
        {
            if (p->llink == NULL) break;
            else p = p->llink;
        }
        else
        {
            if (p->rlink == NULL) break;
            else p = p->rlink;
        }
    }

    //到此處位置,查詢不成功,將包含關鍵詞k的新結點插入樹中
    BSTNode *q = new BSTNode(k, NULL, NULL);
    if (k < q->key) p->llink = q;
    else p->rlink = q;
    return NULL;
}

//二叉查詢樹中刪除指標q指向的結點
void Delete(BSTNode *q)
{
    if (q == NULL) return;

    BSTNode *t = q;
    if (t->rlink == NULL) t = t->llink;    //q的位置由他的左孩子llink(q)取代
    else
    {
        BSTNode *r = t->rlink;
        if (r->llink == NULL)   
        {
            r->llink = q->llink;
            t = r;
        }
        else
        {
            BSTNode *s = r->llink;
            while (s->llink != NULL)
            {
                r = s;
                s = r->llink;
            }
            s->llink = t->llink;
            r->llink = s->rlink;
            s->rlink = t->rlink;
            t = s;
        }
    }

    if (q == root) root = t;   //把以t為根的子樹嫁接到樹中,同時釋放結點q
    else
    {
        BSTNode *f = Father(root, q);   //尋找q的父結點
        if (f->llink == q) f->llink = t;
        else f->rlink = t;
    }

    delete q;
}複製程式碼

5. 總結


本文主要講了二叉查詢樹的主要結構,以及如何構造一棵二叉查詢樹,並且根據實際情況進行最優二叉樹的構造。

之前說過,二叉樹的應用非常廣,本文所說的二叉查詢樹以及平衡樹都只是其中一種應用,至於紅黑樹,B樹,B樹及其變形樹,這些用途比較廣的都已經被很多人講過了,我就不去湊那個熱鬧了。

還寫什麼程式,一起種樹啊!

相關文章