1. 程式人生 > >二叉樹基礎下

二叉樹基礎下

1. 二叉查詢樹(Binary Search Tree)

二叉查詢樹是二叉樹中最常用的一種型別,也叫二叉搜尋樹。二叉查詢樹是為了實現快速查詢而生的,它不僅僅支援快速查詢一個數據,還支援快速地插入、刪除一個數據。

二叉查詢樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值

1.1. 二叉查詢樹的查詢操作

在二叉查詢樹中查詢時,我們先取根節點,如果其值正好等於要查詢的數,就直接返回;如果大於要查詢的數,我們就遞迴在左子樹中進行查詢;如果小於要查詢的數,我們就遞迴在右子樹中進行查詢。

int Find_Tree
(TreeNode *tree, int data) { TreeNode *temp = tree; while(temp != NULL) { if (temp->val < data) temp = temp->right; else if (temp->val > data) temp = temp->left; else return temp->val; } return -1; }
1.2. 二叉查詢樹的插入操作

二叉查詢樹的插入操作和查詢操作類似,新插入的資料一般都是在葉子節點上,因此我們需要從根節點開始,依次比較新插入的資料和節點資料的大小關係。

如果節點的資料小於新插入的資料,並且節點的右子樹為空,我們就將新資料插入到右子節點的位置;如果右子樹不為空,我們就繼續遞迴查詢右子樹,直到找到正確的位置。同理,如果節點的資料大於新插入的資料,並且節點的左子樹為空,我們就將新資料插入到左子節點的位置;如果左子樹不為空,我們就繼續遞迴查詢左子樹,直到找到正確的位置。

void Insert_Tree(TreeNode *tree, int data)
{
    TreeNode *temp = tree;

    if (tree == NULL)
    {
        tree = new TreeNode(data)
; return; } while (temp != NULL) { if (temp->val < data) { if (temp->right == NULL) { temp->right = new TreeNode(data); return; } temp = temp->right; } else { if (temp->left == NULL) { temp->left = new TreeNode(data); return; } temp = temp->left; } } }
1.3. 二叉查詢樹的刪除操作

二叉查詢樹的刪除操作相對查詢和插入操作來說比較複雜,可以分為以下幾種情況。

如果待刪除的節點沒有子節點,我們直接刪除掉這個節點,讓父節點指向這個節點的指標指向 NULL 即可,如下圖中的節點 55。

如果待刪除的節點只有一個子節點,我們需要刪除掉這個節點,然後讓其子節點移到該節點位置,也即讓父節點指向該節點的指標重新指向該節點的子節點,如下圖中的節點 13。

如果待刪除的節點同時具有左右子節點,我們需要找到這個節點的右子樹中最小的節點,把它替換到待刪除的節點上,然後再刪除這個最小節點。因為這個最小節點肯定沒有左子節點,因此我們可以應用上面的兩條規則來刪除這個最小節點。如下圖中的節點 18。

void Delete_Tree(TreeNode *tree, int data)
{
    TreeNode *deleted_node = tree;      // 指向待刪除節點
    TreeNode *parent = NULL;    // 指向待刪除節點的父節點
    TreeNode *child = NULL;     // 指向待刪除節點的子節點

    while (deleted_node != NULL && deleted_node->val != data)
    {
        parent = deleted_node;
        if (deleted_node->val < data) deleted_node = deleted_node->right;
        else deleted_node = deleted_node->left;
    }

    if (deleted_node == NULL) return; // 待刪除節點為空,沒找到

    TreeNode *min_node = tree;      // 指向右子樹最小節點
    TreeNode *min_parent = NULL;    // 指向待右子樹最小節點的父節點

    // 待刪除節點有左右子節點,查詢右子樹的最小節點
    if (deleted_node->right != NULL && deleted_node->left != NULL)
    {
        min_node = deleted_node->right;
        while (min_node->left != NULL)
        {
            min_parent = min_node;
            min_node = min_node->left;
        }

        deleted_node->val = min_node->val; // 待刪除節點的值等於右子樹最小節點的值
        // 接下來刪除最小節點即可
        deleted_node = min_node;
        parent = min_parent;
    }

    // 待刪除結點只有一個子結點或者是葉節點沒有子節點
    else if(deleted_node->right == NULL) child = deleted_node->left;
    else if(deleted_node->left == NULL) child = deleted_node->right;
    else child = NULL;

    if (deleted_node == tree) tree = child; // 待刪除節點是根節點
    else if (parent->left == deleted_node)   parent->left = child;
    else   parent->right = child;
}

另外,我們還可以只將待刪除節點標記為“已刪除”,而不是真正從樹中刪除掉這個節點,這樣操作就會簡單很多,但比較浪費記憶體。

1.4. 二叉查詢樹的其它操作

除了查詢、插入和刪除操作,二叉查詢樹還可以支援快速地查詢最大節點和最小節點、前驅節點和後繼節點。此外,如果我們中序遍歷二叉查詢樹,就可以輸出一個有序的資料序列,時間複雜度為 O(n),非常高效。

2. 支援重複資料的二叉查詢樹

我們前面講的二叉查詢樹,其節點儲存的都是數字。在實際開發中,二叉查詢樹中儲存的都是一個包含很多欄位的物件,我們利用物件的其中一個欄位作為鍵值(key)來構建二叉查詢樹,而其它欄位稱為衛星資料。

而且,上面的分析我們都是針對不存在鍵值相同的情況,如果鍵值相同的話,我們有以下兩種解決辦法。

第一種方法比較簡單,就是在每個節點不會僅儲存一個數據,還會通過連結串列和支援動態擴容的陣列等資料結構,把值相同的資料都儲存在同一個節點上。

第二種方法不好理解,但更加優雅。如果插入的時候遇到一個和當前節點值相同的資料,我們就把這個值相同的資料放到這個節點的右子樹中去,也就是當作大於這個節點的值來處理。

查詢的時候,遇到值相同的節點,我們並不停止查詢,而是繼續在右子樹中查詢,直到遇到葉子節點才停止,這樣就可以把所有鍵值等於要查詢值的節點都找出來。

對於刪除操作,我們也需要查詢到所有要刪除的節點,然後再按照前面講的刪除節點的方法,依次對節點進行刪除。

3. 二叉查詢樹的時間複雜度分析

實際上,二叉查詢樹的形態各式各樣。對於同一組資料,我們可以構造出下面這三種二叉查詢樹。

不同的二叉樹結構,其查詢、插入和刪除操作的執行效率都是不一樣的。針對第一個二叉樹,根節點的左右子樹嚴重不平衡,已經退化成了連結串列,所以查詢的時間複雜度就變成了 O(n)。

相反,如果是最理想的情況,二叉查詢樹就是一棵完全二叉樹(或滿二叉樹),這時候,其時間複雜度是多少呢?

由前面的程式碼和圖中都可以看出,二叉查詢樹的時間複雜度其實都和樹的高度成正比,而樹的高度也就是樹的層數減一。

針對一個包含 n 個節點的完全二叉樹,第一層包含 1 個節點,第二層包含 2 個節點,以此類推,第 k 層就包含 2 k 1 2^{k-1} 個節點。除了最後一層,因為完全二叉樹的最後一層可能包含 [ 1 , 2 L 1 ] [1, 2^{L-1}] 個節點,L 為最大層數。因此,二叉樹的節點個數 n 和二叉樹的最大層數 L 之間存在如下關係:

n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)

我們可以計算出 L 的範圍為 [ l o g 2 ( n + 1 ) , l o g 2 ( n ) + 1 ] [log_2(n+1), log_2(n)+1] ,也就是說二叉樹的高度小於等於 l o g 2 n log_2n 。因此,極度不平衡的二叉查詢樹,它的查詢效能肯定不能滿足我們的要求。我們需要構建一種不管怎麼刪除、插入資料,它都能保持任意節點左右子樹都比較均衡的二叉查詢樹。

4. 散列表和二叉查詢樹的對比

散列表的插入、刪除和查詢操作的時間複雜度都可以做到常量級,但二叉查詢樹在最好情況下也才是 O(logn),那我們為什麼還要用二叉查詢樹呢?

  • 散列表中的資料時無序儲存的,要輸出有序的資料則需要先進行排序,而二叉查詢樹則可以通過中序遍歷在 O(n) 時間複雜度內輸出有序的資料。

  • 散列表擴容耗時很多,而且遇到雜湊衝突時,效能不穩定,但實際中我們最常用的二叉平衡查詢樹的效能非常穩定,時間複雜度穩定在 O(logn)。

  • 由於雜湊衝突的存在,散列表實際的查詢速度可能並不一定比 O(logn) 快,再加上雜湊函式的計算耗時,其效率也就不一定比平衡二叉查詢樹要好。

  • 散列表的構造比較複雜,要考慮雜湊函式的設計、雜湊衝突的解決、擴容、縮容等問題,而平衡二叉樹只需要考慮平衡性這一個問題,而且這個問題解決方案也比較成熟、固定。

  • 為了避免過多的雜湊衝突,散列表裝載因子一般不能太大,特別是基於開放定址法解決衝突的散列表,這樣就會浪費一定的儲存空間。

因此,在實際的開發過程中,我們需要結合具體的需求來選擇使用哪一種資料結構。

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!