爲什麼我們需要掌握這些“高端”的樹型結構
事實上,大型數據庫的組織結構一般採用樹型結構,我們必須要解決頻繁更新數據的能力,要求支持高效的動態查找能力,包括記錄的插入,刪除,精確匹配查詢,範圍查詢和最大值、最小值查詢。但是由於數據庫中包含了大量的記錄,所以線性表的查詢本身會因爲記錄太大而無法存儲到主存之中,另外對於記錄的插入和刪除操作更需要移動大量的元素,這本身的效率是非常低下的。
二叉查找樹(BST)的定義
二叉查找樹要麼是一顆空樹,要麼滿足以下的定義:
- 若它的左子樹不爲空,那麼它左子樹上所有節點的值均小於等於根節點
- 若它的右子樹不爲空,那麼它右子樹上所有節點的值均大於根節點
它的左右子樹均是二叉查找樹
這明顯是一個遞歸定義,因此我們對於BST的操作大多是建立在遞歸之上的。
二叉查找樹有一個重要的特徵:對一顆二叉查找樹進行中序遍歷,可以得到一個遞增序列,那麼我們只要將中序遍歷的遍歷順序反過來,那麼我們就會得到一個遞減序列,這也正是二叉查找樹得名的原因。
BST的數據結構
typedef int keyType;
typedef struct Node {
keyType data;
struct Node *lchild, *rchild;
} BSTNode, BSTree;
BST的建立
首先我們討論一下怎麼建立一顆二叉查找樹,根據二叉查找樹的定義,如果現在有一串序列,那麼它的建立過程如下:
- 首先建立一顆空樹
- 使節點插入正確的位置(BST的建立正是建立在BST的插入算法之上的)
- 序列爲空爲止
那麼我們現在應該如何給一顆二叉查找樹中插入新的節點呢?
- 從樹的根節點開始查找
- 插入的關鍵字小於等於根節點則進入左子樹,否則進入右子樹
- 由於新插入的節點必定是新的葉子節點,所以當此時的節點指針爲空的時候,進行插入
插入算法:
void insert(BSTree **bstree, keyType data) {
BSTNode *bstnode;
if(*bstree == NULL) {
bstnode = (BSTNode *)malloc(sizeof(BSTNode));
bstnode -> data = data;
bstnode -> lchild = NULL;
bstnode -> rchild = NULL;
*bstree = bstnode;
} else if(data <= (*bstree) -> data) {
insert(&((*bstree)->lchild), data);
} else {
insert(&((*bstree)->rchild), data);
}
}
在進行節點插入的時候,我們並不希望指向根節點的指針隨着遍歷的進行也跟着移動,並且因爲是對於指針的參數傳遞,這裏必然使用二級指針,這樣頭結點纔會被真正的初始化。然後我們在進行遞歸遍歷節點的時候,我們也不能影響二級指針的指向,我之所以在這裏着重強調,說實話,我這個bug還是小夥伴幫我改出來的,對於二級指針,一直都是感覺自己懂了,一碰到繁雜的操作,就又糊塗了。。。
建立算法:
void create_BSTree(int array[], BSTree **bstree) {
//二叉查找樹的創建是建立在插入上面的
for(int i = 0; i < N; i++) {
insert(bstree, array[i]);
}
}
隨後我們便可以對二叉查找樹進行樹狀打印了:
void print_tree(BSTree *bstree, int h) {
if(bstree == NULL) {
return ;
}
if(bstree -> rchild != NULL) {
print_tree(bstree -> rchild, h+1);
}
for(int i = 0; i < h; i++) {
cout << " ";
}
cout << bstree -> data << endl;
if(bstree -> lchild != NULL) {
print_tree(bstree -> lchild, h+1);
}
}
仔細看上面的打印代碼,沒錯,將中序遍歷的順序顛倒一下就行了。
BST的查找
BST的查找我們可以用遞歸和循環分別實現。
非遞歸實現:
BSTNode *find(BSTree *bstree, keyType key) {
while(bstree != NULL) {
if(bstree -> data == key) {
return bstree;
} else if(bstree -> data >= key){
bstree = bstree -> lchild;
} else {
bstree = bstree -> rchild;
}
}
return bstree;
}
非遞歸不再分析。
遞歸實現:
BSTNode *find_recursive(BSTree *bstree, keyType key) {
if(bstree == NULL) {
return NULL;
}
if(bstree -> data == key) {
return bstree;
} else if(key <= bstree -> data) {
return find(bstree -> lchild, key);
} else {
return find(bstree -> rchild, key);
}
}
由於我們對二叉查找樹進行查找的時候不需要回溯,所以我們每次進入新子樹的時候,直接這樣:
return find(bstree -> lchild, key);
return 語句阻止了回溯的發生。
BST的刪除
BST節點的刪除,情況比較複雜,我在這裏分三種情況說明:
在進行節點刪除的時候,我們需要兩個指針,它們指向待刪節點的父節點與待刪節點。
//定義父節點和孩子節點(待刪節點)
BSTree *root, *child;
child = root = bstree;
首先我們考慮特殊情況,代碼如下:
//首先考慮特殊情況(根節點爲空|只有根節點)
if(root == NULL) {
cout << "這是棵空樹" << endl;
} else if (root -> lchild == NULL && root -> rchild == NULL) {
if(root -> data == key) {
root = NULL;
} else {
cout << "沒要找到您要刪除的節點" << endl;
}
}
1.待刪節點是葉子節點
- 找待刪節點,並不斷記錄它的父節點
- 將父節點的相應孩子域進行置空並free待刪節點所佔的空間
先進行待刪節點的查找:
while(child != NULL && child -> data != key) {
if(key <= child -> data) {
root = child;
hild = root -> lchild;
} else {
root = child;
child = root -> rchild;
}
}
if(child == NULL) {
cout << "沒有找到您要刪除的節點" << endl;
}
進行葉子節點的刪除:
//待刪節點是葉子節點
if(child -> lchild == NULL && child -> rchild == NULL) {
if(root -> lchild == child) {
root -> lchild = NULL;
free(child);
} else {
root -> rchild = NULL;
free(child);
}
}
2.待刪節點只有左子樹或只有右子樹
- 找到待刪節點並判斷此節點是否只有左子樹或右子樹
- 將待刪節點父節點的相應孩子域設置爲該待刪節點對應的左子樹或右子樹
- 將待刪節點置空
//待刪節點只有左子樹或者右子樹
else if(child -> lchild == NULL || child -> rchild == NULL) {
if(root -> lchild == child && child -> lchild != NULL) {
root -> lchild = child -> lchild;
free(child);
} else if(root -> lchild == child && child -> rchild != NULL) {
root -> lchild = child -> rchild;
free(child);
} else if(root -> rchild == child && child -> lchild != NULL) {
root -> rchild = child -> lchild;
free(child);
} else {
root -> rchild = child -> rchild;
free(child);
}
}
3.待刪節點既有左子樹也有右子樹
當碰到這種情況的時候,我們需要額外引入兩個指針,一個用來記錄找到的替換節點,一個用來記錄替換節點的父節點(替換節點就是用來替換被刪除的節點)。
那麼我們應該如何找到替換節點呢?來看一張圖:
我們知道對二叉查找樹進行中序遍歷所得到的是遞增序列。
假設我們現在要刪除節點5,這棵BST的中序遍歷結果爲:1,2,3,4,5,6,7,根據中序遍歷的性質可知,5之左是它的左子樹,5之右是它的右子樹,現在我們要刪除5,那麼我們就要找可以替換5的4或6,也就是和5相鄰的兩個數,我們再來看一下這兩個節點在樹中的位置,4的位置在左子樹中最右下角,6的位置在右子樹的最左下角,我們選擇查找任一節點進行待刪節點的替換就行。仔細觀察會發現這一規律對任意節點適用。
理解了上面尋找替換節點的兩種方式之後,來看一下實現代碼,我尋找的是左子樹中最右下角的替換節點並同時記錄替換節點的父節點:
BSTree *alter;
BSTNode *alterParent = child;
alter = alterParent;
while(alter -> rchild != NULL) {
alterParent = alter;
alter = alterParent -> rchild;
}
先重申一點,我們現在擁有四個指針,root->用來指向待刪節點的父節點,child->用來指向待刪節點,alter->用來指向我們找到的替換節點,alterParent->用來指向我們找到的替換節點的父節點。下面我們具體討論四種情況:
對這四種情況,我希望大家可以認真考慮,畫出一棵二叉查找樹,然後去一個個進行實現。
1.替換節點的父節點等於待刪節點且刪除節點是根節點的時候
if(alterParent == child && child == bstree) {
/**由於我查找替換節點的方式是左子樹的最右邊,所以當滿足上面的if語句之後,我的替換節點必定在
待刪除節點的左邊*/
alter -> lchild = child -> lchild -> lchild;
alter -> rchild = child -> rchild;
//更新根節點
bstree = alter;
free(child);
}
2.替換節點的父節點等於待刪節點但刪除節點不是根節點的時候
else if(alterParent == child && child != bstree) {
alter -> lchild = child -> lchild -> lchild;
alter -> rchild = child -> rchild;
//我們要判斷待刪節點是在待刪節點父節點的哪顆子樹上
if(child == root -> lchild) {
root -> lchild = alter;
free(child);
} else {
root -> rchild = alter;
free(child);
}
}
3.替換節點的父節點不等於待刪節點但刪除節點是根節點的時候
else if(alterParent != child && child == bstree) {
//如果是這種情況,那麼替換節點就在替換父節點的右子樹上
alter -> lchild = child -> lchild;
alter -> rchild = child -> rchild;
free(child);
//更新根節點
bstree = alter;
alterParent -> rchild = NULL;
}
4.替換節點的父節點不等於待刪節點並且刪除節點不是根節點的時候
else {
alter -> lchild = child -> lchild;
alter -> rchild = child -> rchild;
if(child == root -> lchild){
root -> lchild = alter;
free(child);
} else {
root -> rchild = alter;
free(child);
}
alterParent -> rchild = NULL;
}
如上,可以看到每種情況的處理過程並不複雜,只要將每種情況都處理到,二叉查找樹的刪除問題也就迎刃而解了。
我把每種刪除情況都測試了一下,都刪除成功,具體的源碼鏈接我放在下面,有興趣的小夥伴可以研究研究。
BST性能分析
二叉查找樹查找的最差情況爲ASL(平均查找長度)=((1+n)*n/2)/n = (n+1)/2,和順序查找相同,如圖:
最好的情況與折半查找相同,ASL爲log2N(圖片不再貼出)。
對於BST的插入和刪除來說,只需修改某些節點的指針域,不需要大量移動其它記錄,動態查找的效率很高。