動態查找樹主要有二叉查找樹(Binary Search Tree),平衡二叉查找樹(Balanced Binary Search Tree), 紅黑樹 (Red-Black Tree ),
都是典型的二叉查找樹結構,查找的時間復雜度 O(log2-N) 與樹的深度相關,降低樹的深度會提高查找效率,於是有了多路的B-tree/B+-tree/ B*-tree (B~Tree)。
二叉查找樹
二叉查找樹即搜索二叉樹,或者二叉排序樹(BSTree)。
一、關於二叉查找樹
二叉查找樹(Binary Search Tree)是指一棵空樹或者具有下列性質的二叉樹:
1. 若任意節點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
2. 若任意節點的右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;
3. 任意節點的左、右子樹也分別為二叉查找樹。
4. 沒有鍵值相等的節點,這個特征很重要,可以幫助理解二叉排序樹的很多操作。
二叉查找樹具有很高的靈活性,對其優化可以生成平衡二叉樹,紅黑樹等高效的查找和插入數據結構。
二、基本性質
(1)二叉查找樹是一個遞歸的數據結構,對二叉查找樹進行中序遍歷,可以得到一個遞增的有序序列。
(2)二叉查找樹上基本操作的執行時間和樹的高度成正比。
對一棵n個節點的完全二叉樹來說,樹的高度為lgn,這些操作的最壞情況運行時間為O(lg n),而如果是線性鏈表結構,這些操作的最壞運行時間是O(n)。
一棵隨機構造的二叉查找樹的期望高度為O(lg n),但實際中並不能總是保證二叉查找樹是隨機構造的,
有些二叉查找樹的變形能保證各種基本操作的最壞情況性能,比如紅黑樹的高度為O(lg n),而B樹對維護隨機訪問的二級存儲器上的數據庫特別有效。
注意對復雜度的理解,所謂的O(lg n)就是指復雜度是對數級別,是數量級的比較,和對數的底數其實沒關系,
只要底數是大於1的,就是相同的數量級,有些書上說二叉查找樹的復雜度是O(log2-n),指的是相同的時間復雜度。
三、前驅和后繼節點
一個節點的后繼是該節點的后一個,即比該節點鍵值稍大的節點。
給定一個二叉查找樹中的節點,找出在中序遍歷順序下某個節點的前驅和后繼。
如果樹中所有關鍵字都不相同,則某一節點x的前驅就是小於key[x]的所有關鍵字中最大的那個節點,后繼即是大於key[x]中的所有關鍵字中最小的那個節點。根據二叉查找樹的結構和性質,不用對關鍵字做任何比較,就可以找到某個節點的前驅和后繼。
四、查找、插入與刪除
(1)查找
利用二叉查找樹左小右大的性質,可以很容易實現查找任意值和最大/小值。
在二叉查找樹中查找一個給定的關鍵字k的過程與二分查找很類似,
首先是關鍵字k與樹根的關鍵字進行比較,如果k比根的關鍵字大,則在根的右子樹中查找,否則在根的左子樹中查找,重復此過程,直到找到與遇到空節點為止。
在二叉查找樹中查找x的過程如下:
1.若二叉樹是空樹,則查找失敗。
2.若x等於根節點的數據,則查找成功,否則。
3.若x小於根節點的數據,則遞歸查找其左子樹,否則。
4.遞歸查找其右子樹。
(2)插入
二叉樹查找樹b插入操作x的過程如下:
1.若b是空樹,則直接將插入的節點作為根節點插入。
2.x等於b的根節點的數據的值,則直接返回,否則。
3.若x小於b的根節點的數據的值,則將x要插入的節點的位置改變為b的左子樹,否則。
4.將x要出入的節點的位置改變為b的右子樹。
(3)刪除
假設從二叉查找樹中刪除給定的結點z,分三種情況討論:
1.節點z為葉子節點,沒有孩子節點,那么直接刪除z,修改父節點的指針即可。
2.節點z只有一個子節點或者子樹,將節點z刪除,根據二叉查找樹的性質,將z的父節點與子節點關聯就可以了。
3.節點Z有兩個子節點,刪除Z該怎樣將Z的父結點與這兩個孩子結點關聯呢?
在刪去節點Z之后,為保持其它元素之間的相對位置不變,可按中序遍歷保持有序進行調整。
這種情況下可以用Z的后繼節點來替代Z。
實現方法就是將后繼從二叉樹中刪除,將后繼的數據覆蓋到Z中。
五、代碼實現
public class BinarySearchTree <T extends Comparable<? super T>>{ //節點數據結構 靜態內部類 static class BinaryNode<T>{ T data; BinaryNode<T> left; BinaryNode<T> right; public BinaryNode(){ data=null; } public BinaryNode(T data) { this(data,null,null); } public BinaryNode(T data,BinaryNode<T> left,BinaryNode<T> right){ this.data=data; this.left=left; this.right=right; } } //私有的頭結點 private BinaryNode<T> root; //構造一棵空二叉樹 public BinarySearchTree(){ root=null; } //二叉樹判空 public boolean isEmpty(){ return root==null; } //清空二叉樹 public void clear(){ root=null; } //檢查某個元素是否存在 public boolean contains(T t){ return contains(t,root); } /** * 從某個節點開始查找某個元素是否存在 * 在二叉查找樹中查找x的過程如下: * 1、若二叉樹是空樹,則查找失敗。 * 2、若x等於根結點的數據,則查找成功,否則。 * 3、若x小於根結點的數據,則遞歸查找其左子樹,否則。 * 4、遞歸查找其右子樹。 */ public boolean contains(T t,BinaryNode<T> node){ if(node==null){ return false; } /** * 這就是為什么使用Comparable的泛型 * compareTo的對象也必須是實現了Comparable接口的泛型, * 所以參數必須是BinaryNode<T> node格式 */ int result=t.compareTo(node.data); if(result>0){//去右子樹查找 return contains(t,node.right); }else if(result<0){//去左子樹查找 return contains(t,node.left); }else{ return false; } } //插入元素 public void insert(T t){ root=insert(t,root); } /** * 將節點插入到以某個節點為頭的二叉樹中 * 這個插入其實也是一個遞歸的過程 * 遞歸最深層的返回結果一個包含要插入的節點子樹的頭節點 */ public BinaryNode insert(T t,BinaryNode<T> node){ //如果是空樹,直接構造一棵新的二叉樹 if(node==null){ return new BinaryNode<T>(t); } int result=t.compareTo(node.data); if(result<0){ node.left=insert(t,node.left); }else if(result>0){ node.right=insert(t,node.right); }else{ ;//即要插入的元素和頭節點值相等,直接返回即可 } return node; } /** * 刪除元素 * 返回調整后的二叉樹頭結點 */ public BinaryNode delete(T t){ return delete(t,root); } /** * 在以某個節點為頭結點的樹結構中刪除元素 * 首先需要找到該關鍵字所在的節點p,然后具體的刪除過程可以分為幾種情況: * p沒有子女,直接刪除p * p有一個子女,直接刪除p * p有兩個子女,刪除p的后繼q(q至多只有一個子女) * 確定了要刪除的節點q之后,就要修正q的父親和子女的鏈接關系, * 然后把q的值替換掉原先p的值,最后把q刪除掉 */ public BinaryNode delete(T t,BinaryNode<T> node){ if(node==null){//節點為空還要啥自行車 return node; } /** * 首先要找到這個節點,所以還是得比較 */ int result=t.compareTo(node.data); /** * 去左半部分找這個節點, * 找到節點result==0,這個遞歸就停止 */ if(result<0){ node.left=delete(t,node.left); }else if(result>0){//去右半部分找這個節點 node.right=delete(t,node.right); } /** * 如果這個節點的左右孩子都不為空,那么找到當前節點的后繼節點, * */ if(node.left!=null && node.right!=null){ /** * node節點的右子樹部分的最小節點,實際上就是它的后繼節點 * 得到后繼節點的值 */ node.data = findMin(node.right).data; /** * 這個過程並不是刪除后繼節點,是一步一步的把所有的節點都替換上來 */ node.right = delete(node.data,node.right); }else{ /** * 如果二叉搜索樹中一個節點是完全節點, * 那么它的前驅和后繼節點一定在以它為頭結點的子樹中,應該是這樣的 * 來到了只有一個頭節點和一個子節點的情況 */ node = (node.left!=null)?node.left:node.right; } //此處的node,是經過調整后的傳入的root節點 return node; } /** * 返回二叉樹中的最小值節點 * 此時無比想念大根堆和小根堆 */ public BinaryNode<T> findMin(BinaryNode node){ if(node==null) return null; /** * 如果node不為空,就遞歸的去左邊找 * 最小值節點肯定是左孩子為空的節點 */ if(node.left!=null) node=findMin(node.left); return node; } }
B-樹、B+樹、B*樹變體
關於這B樹以及B樹的兩種變體,其實很好區分,
相比B樹,B+樹不維護關鍵字具體信息,不考慮value的存儲,所有的我們需要的信息都在葉子節點上,
B*樹在B+樹的基礎上增加了非葉子節點兄弟間的指針,在某些場景效率更高,
主要掌握B樹的操作,也就掌握了這兩種變體樹的操作。
1.B樹(B-tree),即B-樹
B-樹是為了磁盤或其它存儲設備而設計的一種多叉平衡查找樹。
(1)B-Tree的接點結構
B-tree中,每個結點包含:
本結點所含關鍵字的個數;
指向父結點的指針;
關鍵字;
指向子結點的指針數組;
#define Max l000 //結點中關鍵字的最大數目:Max=m-1,m是B-樹的階 #define Min 500 //非根結點中關鍵字的最小數目:Min=m/2-1 typedef int KeyType; //KeyType關鍵字類型由用戶定義 typedef struct node{ //結點定義中省略了指向關鍵字代表的記錄的指針 int keynum; //結點中當前擁有的關鍵字的個數,keynum<<Max KeyType key[Max+1]; //關鍵字向量為key[1..keynum],key[0]不用。 struct node *parent; //指向雙親結點 struct node *son[Max+1];//指向孩子結點的指針數組,孩子指針向量為son[0..keynum] }BTreeNode; typedef BTreeNode *BTree;
(2)B-tree的特點
- B-tree是一種多路搜索樹(並不是二叉的),對於一棵M階樹:
- 定義任意非葉子結點最多只有M個孩子;且M>2;
- 根結點的孩子數為[2, M],除非根結點為葉子節點;
- 除根結點以外的非葉子結點的兒子數為[M/2, M];
- 非葉子結點的關鍵字個數=指向兒子的指針個數-1;
- 每個非葉子結點存放至少M/2-1(取上整)和至多M-1個關鍵字;
- 非葉子結點的關鍵字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
- 非葉子結點的指針:P[1], P[2], …, P[M];其中P[1]指向關鍵字小於K[1]的子樹,P[M]指向關鍵字大於K[M-1]的子樹,其它P[i]指向關鍵字屬於(K[i-1], K[i])的子樹;
- 所有葉子結點位於同一層;
以M=3的一棵3階B樹為例:
一棵包含了24個英文字母的5階B樹的結構:
(3)B-tree高度與復雜度
B樹的高度是,而不是其它幾種樹的H=log2n,其中T為度數(每個節點包含的元素個數),即所謂的階數,n為總元素個數或總關鍵字數。
B樹查找的時間復雜度為O(Log2-N),下面是參考推導過程:
其中M為設定的非葉子結點最多子樹個數,N為關鍵字總數;所以B-樹的性能總是等價於二分查找(與M值無關),也就沒有AVL樹平衡的問題。
2.B-tree的基本操作
(1)查找操作
在B-樹中查找給定關鍵字的方法類似於二叉排序樹上的查找。不同的是在每個結點上確定向下查找的路徑不一定是二路而是keynum+1路的。
對結點內的存放有序關鍵字序列的向量key[l..keynum] 用順序查找或折半查找方法查找。若在某結點內找到待查的關鍵字K,則返回該結點的地址及K在key[1..keynum]中的位置;否則,確定K在某個key[i]和key[i+1]之間結點后,從磁盤中讀son[i]所指的結點繼續查找。直到在某結點中查找成功;或直至找到葉結點且葉結點中的查找仍不成功時,查找過程失敗。
BTreeNode *SearchBTree(BTree T,KeyType K,int *pos) { //在B-樹T中查找關鍵字K,成功時返回找到的結點的地址及K在其中的位置*pos //失敗則返回NULL,且*pos無定義 int i; T→key[0]=k; //設哨兵.下面用順序查找key[1..keynum] for(i=T->keynum;K<t->key[i];i--); //從后向前找第1個小於等於K的關鍵字 if(i>0 && T->key[i]==1){ //查找成功,返回T及i *pos=i; return T; } //結點內查找失敗,但T->key[i]<K<T->key[i+1],下一個查找的結點應為 //son[i] if(!T->son[i]) //*T為葉子,在葉子中仍未找到K,則整個查找過程失敗 return NULL; //查找插入關鍵字的位置,則應令*pos=i,並返回T,見后面的插入操作 DiskRead(T->son[i]); //在磁盤上讀人下一查找的樹結點到內存中 return SearchBTree(T->Son[i],k,pos); //遞歸地繼續查找於樹T->son[i] }
(2)查找操作的時間開銷
B-樹上的查找有兩個基本步驟:
1.在B-樹中查找結點,該查找涉及讀盤DiskRead操作,屬外查找;
2.在結點內查找,該查找屬內查找。
查找操作的時間為:
1.外查找的讀盤次數不超過樹高h,故其時間是O(h);
2.內查找中,每個結點內的關鍵字數目keynum<m(m是B-樹的階數),故其時間為O(nh)。
注意:
1.實際上外查找時間可能遠遠大於內查找時間。
2.B-樹作為數據庫文件時,打開文件之后就必須將根結點讀人內存,而直至文件關閉之前,此根一直駐留在內存中,故查找時可以不計讀入根結點的時間。
(3)插入操作
插入一個元素時,首先在B樹中是否存在,如果不存在,即在葉子結點處結束,然后在葉子結點中插入該新的元素,注意:如果葉子結點空間足夠,這里需要向右移動該葉子結點中大於新插入關鍵字的元素,如果空間滿了以致沒有足夠的空間去添加新的元素,則將該結點進行“分裂”,將一半數量的關鍵字元素分裂到新的其相鄰右結點中,中間關鍵字元素上移到父結點中(當然,如果父結點空間滿了,也同樣需要“分裂”操作),而且當結點中關鍵元素向右移動了,相關的指針也需要向右移。如果在根結點插入新元素,空間滿了,則進行分裂操作,這樣原來的根結點中的中間關鍵字元素向上移動到新的根結點中,因此導致樹的高度增加一層。
(4)刪除操作
首先查找B樹中需刪除的元素,如果該元素在B樹中存在,則將該元素在其結點中進行刪除,如果刪除該元素后,首先判斷該元素是否有左右孩子結點,如果有,則上移孩子結點中的某相近元素到父節點中,然后是移動之后的情況;如果沒有,直接刪除后,移動之后的情況。
3.B+樹(B+-tree)
B+-tree是應文件系統所需而產生的一種B-tree的變形樹。
(1)B樹和B+樹的對比
一棵m階的B+樹和m階的B樹的異同點在於:
1.有n棵子樹的結點中含有n-1 個關鍵字;
2.所有的葉子結點中包含了全部關鍵字的信息,及指向含有這些關鍵字記錄的指針,且葉子結點本身依關鍵字的大小自小而大的順序鏈接。 (而B 樹的葉子節點並沒有包括全部需要查找的信息)
3.所有的非終端結點可以看成是索引部分,結點中僅含有其子樹根結點中最大(或最小)關鍵字。 (而B 樹的非終節點也包含需要查找的有效信息)
(2)為什么說B+-tree比B 樹更適合實際應用中操作系統的文件索引和數據庫索引?
- B+-tree的磁盤讀寫代價更低
B+-tree的內部結點並沒有指向關鍵字具體信息的指針。因此其內部結點相對B 樹更小。
如果把所有同一內部結點的關鍵字存放在同一盤塊中,那么盤塊所能容納的關鍵字數量也越多。
一次性讀入內存中的需要查找的關鍵字也就越多。相對來說IO讀寫次數也就降低了。
舉個例子,假設磁盤中的一個盤塊容納16bytes,而一個關鍵字2bytes,一個關鍵字具體信息指針2bytes。
一棵9階B-tree(一個結點最多8個關鍵字)的內部結點需要2個盤快。而B+ 樹內部結點只需要1個盤快。當需要把內部結點讀入內存中的時候,B 樹就比B+ 樹多一次盤塊查找時間(在磁盤中就是盤片旋轉的時間)。
- B+-tree的查詢效率更加穩定
由於非終結點並不是最終指向文件內容的結點,而只是葉子結點中關鍵字的索引。所以任何關鍵字的查找必須走一條從根結點到葉子結點的路。所有關鍵字查詢的路徑長度相同,導致每一個數據的查詢效率相當。
4.B*樹(B*-tree)
B*-tree是B+-tree的變體,在B+樹的基礎上(所有的葉子結點中包含了全部關鍵字的信息,及指向含有這些關鍵字記錄的指針),
B*樹中非根和非葉子結點再增加指向兄弟的指針;
B*樹定義了非葉子結點關鍵字個數至少為(2/3)*M,即塊的最低使用率為2/3(代替B+樹的1/2)。
下圖是一棵典型的B*樹: