上課老師提問我什麼是二叉查找樹,我把這些動圖拿了出來,動圖圖解及程式碼實現。

本文為系列專題【數據結構和演算法:簡單方法】的第 12 篇文章。

  1. 數據結構 | 順序表
  2. 數據結構 | 鏈表
  3. 數據結構 | 棧
  4. 數據結構 | 隊列
  5. 數據結構 | 雙鏈表和循環鏈表
  6. 數據結構 | 二叉樹的概念和原理
  7. 數據結構 | 二叉樹的創建及遍歷實現
  8. 數據結構 | 線索二叉樹
  9. 數據結構 | 二叉堆
  10. 演算法 | 順序查找和二分查找

1. 是什麼?

二叉查找樹(Binary Search Tree)必須滿足以下特點:

  • 若左子樹不為空,則左子樹的所有結點值皆小於根結點值
  • 若右子樹不為空,則右子樹的所有結點值皆大於根結點值
  • 左右子樹也是二叉排序樹

如下圖,是一顆二叉查找樹:

如果你對二叉查找樹進行中序遍歷,可以得到:5, 10, 12, 14, 16, 22, 30, 41, 54, 66。剛好是一個從小到大的有序序列,從這一點看,二叉查找樹是可以進行排序的,所以這種樹又叫二叉排序樹(Binary Sort Tree)。

2. 查找

我們以上圖為例,說明查找成功和失敗兩種過程。

先貼出程式碼:

int bst_search(BSTNode *root, int key, BSTNode **p, BSTNode *q)
{
    if (root == NULL) { // 空樹,查找失敗
        *p = q;
        return 0;
    } 
    if (key == root->data) { // 目標值相等根結點的值,查找成功
        *p = root;
        return 1;
    } else if (key < root->data) { // 目標值小於根結點的值,遞歸進入左子樹
        return bst_search(root->left_child, key, p, root);
    } else { // 目標值大於根結點的值,遞歸進入右子樹
        return bst_search(root->right_child, key, p, root);
    }
}

該方法中的三個指針的作用如下:

  • 指針 root 始終指向根結點, 標識了與目標值比較的結點;

  • 二級指針 p 指向最終查找的結果。如果查找成功,則指向「指向『找到的結點』的指針」;如果查找失敗,則指向「指向『上次最後一個訪問的結點』的指針」。

  • 指針 q 初始為空,以後始終指向上一個根結點。

下面是查找成功的過程動圖:

查找成功動圖

下面是查找失敗的過程動圖:

查找失敗過程動圖

請注意,以上過程成立的前提是:二叉樹是一顆二叉排序樹,必須滿足二叉排序樹的三個特點。

所以,二叉排序樹的「排序」二字是為查找而服務的,上面兩個動圖足以說明,排序方便了查找,因為每次比較都在縮小範圍。

二叉排序樹和二分查找本質上做的事是一樣的,比如以下幾方面:

  1. 都是有序的。二分查找是全部有序,二叉排序數是局部有序。
  2. 都是將一組數劃分成若干區域。二分查找通過三個標誌位,二叉排序樹通過樹結構。
  3. 都是通過比較目標值和「區域分界點」的大小,從而確定目標值在哪個區域中。在二分查找中與中間標誌位比較,在二叉查找樹中與根結點比較。

我們在二分查找中說過:當涉及到頻繁地插入和刪除操作時,二分查找就不合適了。原因之一是二分查找要求元素是有序的(從小到大或從大到小),插入、刪除破壞順序後,需要額外精力維護有序狀態。原因之二是二分查找使用順序表實現,順序表的插入和刪除操作需要移動大量元素,也會影響效率。

但是二叉排序樹是一個樹,樹結構通常使用鏈式結構來實現。鏈式結構對插入和刪除操作很友好,也有利於我們維護二叉樹的有序狀態。

3. 創建和插入

現在給你一組沒有順序的數,比如 {16, 41, 14, 30, 10, 12, 54, 5, 22, 66},如何創建出一顆二叉查找樹(BST)?這裡為了方便起見,我們約定二叉查找樹中沒有重複值。

所謂創建,即向一個空樹中不斷插入結點,從而構成一個非空的二叉查找樹。所以關鍵在於如何向一個二叉查找樹中插入結點

所謂插入,即不斷比較待插入結點和樹中結點的值,使其插到合適的位置,怎麼找到「合適的位置」呢?方法前面已經給出,即 bst_search

如果我們在二叉查找樹中查找待插入值,一定會查找失敗,即 bst_search 一定返回 0,所以bst_search 方法中的 p 指針最終指向的是最後一個訪問的根結點,這個根結點的左孩子或右孩子即是「合適的位置」。

下面是創建(插入)的過程動圖,和查找失敗的過程動圖非常相似:

BST 創建動圖

插入一個結點的程式碼如下,可以看出,和 bst_search 的程式碼很相似:

int insert_bst_node(BSTNode **p_root, int key)
{
    BSTNode *p;
    if (bst_search(*p_root, key, &p, NULL) == 0) { // 沒有查找到 key
        BSTNode *new = create_bst_node(key); // 創建新結點
        if (p == NULL) { // 空樹,新節點直接作為根結點
            *p_root = new;
            return 1;
        }
        if (key < p->data) { // 插入值小於 p 的值,插入到左子樹
            p->left_child = new;
            return 1;
        }
        if (key > p->data) {  // 插入值大於 p 的值,插入到右子樹
            p->right_child = new;
            return 1;
        }
    }
    return 0; // 插入失敗
}

那麼,創建操作用一個 for 循環就搞定了:

void create_bst(BSTNode **root, int *array, int length)
{
    // 循環向空二叉查找樹中插入若干結點
    for (int i = 0; i < length; i++) {
        insert_bst_node(root, array[i]);
    }
}

4. 刪除

至此,我們已經介紹過好幾種數據結構了,每種都涉及到增加(插入)和刪除操作。就像加法和減法一樣,插入和刪除操作可以看作是一對「反操作」。這意味著,它們在原理、程式碼上有相似之處,能夠做到觸類旁通。但這不是絕對了,必須具體問題結合具體環境進行具體分析。比如刪除二叉查找樹中的某個結點,可能會破壞二叉查找樹的結構,破壞其順序,所以我們就需要分情況討論。

【情況一】

待刪除的結點沒有孩子,即刪除葉子結點。這是最簡單的情況,因為刪除葉子不會破壞剩下的結構,也不會破壞剩下的結點的順序。

【情況二】

待刪除的結點只有左子樹或者右子樹。這種情況也好做,將其左子樹或右子樹移動到刪除結點處即可。由於二叉查找樹使用了鏈式結構,所以刪除操作的代價很小,只需要改變指針的指向。

【情況三】

待刪除的結點既有左子樹又有右子樹。這種情況比較複雜,因為刪除結點後,剩下了該結點的左子樹和右子樹「在風中飄蕩」,一方面我們要維持樹結構,另一方面我們要維持二叉查找樹的結點大小順序,所以刪除結點會涉及到整個樹結構的調整,即將「剩下的」左子樹和右子樹重新插到樹結構中去。下面是過程:

二叉查找樹刪除

從上圖過程就可以看出,這有點太過打動干戈了。我們先是改變 結點 16 的右孩子指針,然後刪除 結點 41,並將其右子樹「摘出來」,然後把剩下的右子樹中的所有結點重新插入到二叉查找樹中。這樣確實挺累人的。

不妨換一種思路:

二叉查找樹刪除(採用)

我們將 32 賦值給結點 41,然後刪除結點 32,同樣能夠達到刪除結點 41 的效果。這種方式大大簡化了操作,不需要移動甚至改變樹的結構,代價較小。我稱這種方式為——狸貓換太子。

那麼為什麼偏偏是把結點 32 賦值給待刪除結點 41 呢?前面說過,二叉查找樹的中序遍歷是一個有序序列,這也是為什麼叫它二叉排序樹的原因。結點 32 剛好是結點 41 在中序遍歷序列下的直接前驅結點,所以,將 32 賦值給結點 41 後,並不會影響二叉查找樹的「左子樹值小於根結點,右子樹值大於根結點」的定義。

所以,如何找到待刪除結點在中序序列下的直接前驅是關鍵。

在中序遍歷序列下,根結點的直接前驅是其左子樹中最右最下的那個結點,如上例中結點 41 的直接前驅是結點 32.

到此,如何寫程式碼就很清晰了。

一、先遞歸地找到待刪除結點:

/**
 * @description: 刪除目標值結點
 * @param {BSTNode**} 二級指針,指向 指向樹根結點的指針 的指針
 * @param {int} key 目標值
 * @return {int} 刪除成功返回1;否則返回0
 */
int delete_bst_node(BSTNode **p_root, int key)
{
    if (*p_root == NULL) {
        return 0;
    }
    if (key == (*p_root)->data) { // 找到要刪除的目標值 key
        rebuild_bst_after_delete(p_root);
    } else if (key < (*p_root)->data) { // 目標值小於根結點,遞歸進入左子樹
        return delete_bst_node(&(*p_root)->left_child, key);
    } else { // 目標值大於右子樹,遞歸進入右子樹
        return delete_bst_node(&(*p_root)->right_child, key);
    }
}

可以看出,這段程式碼和刪除操作的程式碼沒有太大區別,本質仍是查找。

二、找到待刪除結點後,將結點刪除,然後調整以維持二叉查找樹的結構:

/**
 * @description: 刪除某個結點後重新調整二叉查找樹
 * @param {BSTNode**} p_node 指向待刪除結點的二級指針
 * @return {int} 成功返回1;失敗返回0
 */
int rebuild_bst_after_delete(BSTNode **p_node)
{
    BSTNode *prev, *tmp;
    tmp = *p_node;
    if ((*p_node)->left_child == NULL) { // 左子樹為空,重接右子樹
        *p_node = (*p_node)->right_child;
        free(tmp);
        return 1;
    }
    if ((*p_node)->right_child == NULL) { // 右子樹為空,重接左子樹
        *p_node = (*p_node)->left_child;
        free(tmp);
        return 1;
    }
    // 左右子樹均不為空
    if ((*p_node)->left_child != NULL && (*p_node)->right_child != NULL) {
        prev = (*p_node)->left_child;
        while (prev->right_child != NULL) { // 找到待刪除結點的直接前驅
            tmp = prev;
            prev = prev->right_child;
        }
        (*p_node)->data = prev->data; // 賦值
        if (tmp != *p_node) { // 待刪除結點有子孫結點
            tmp->right_child = prev->left_child;
        } else { // 待刪除結點只有兩個孩子結點,沒有子孫結點
            tmp->left_child = prev->left_child;
        }
        free(prev);
        return 1;
    }
    return 0;
}

以上就是二叉查找樹的基本原理及實現

完整程式碼請移步至 GitHub | Gitee 獲取。

如有錯誤,還請指正。

如果覺得寫的不錯,可以點個贊和關注。後續會有更多數據結構和演算法相關文章。
微信掃描下方二維碼,一起學習更多電腦基礎知識。