B樹
具體講解之前,有一點,再次強調下:B-樹,即為B樹。因為B樹的原英文名稱為B-tree,而國內很多人喜歡把B-tree譯作B-樹,其實,這是個非常不好的直譯,很容易讓人產生誤解。如人們可能會以為B-樹是一種樹,而B樹又是一種一種樹。而事實上是,B-tree就是指的B樹。特此說明。
我們知道,B 樹是為了磁盤或其它存儲設備而設計的一種多叉(下面你會看到,相對於二叉,B樹每個內結點有多個分支,即多叉)平衡查找樹。與本blog之前介紹的紅黑樹很相似,但在降低磁盤I/0操作方面要更好一些。許多數據庫系統都一般使用B樹或者B樹的各種變形結構,如下文即將要介紹的B+樹,B*樹來存儲信息。
B樹與紅黑樹最大的不同在於,B樹的結點可以有許多子女,從幾個到幾千個。那為什么又說B樹與紅黑樹很相似呢?因為與紅黑樹一樣,一棵含n個結點的B樹的高度也為O(lgn),但可能比一棵紅黑樹的高度小許多,應為它的分支因子比較大。所以,B樹可以在O(logn)時間內,實現各種如插入(insert),刪除(delete)等動態集合操作。
如下圖所示,即是一棵B樹,一棵關鍵字為英語中輔音字母的B樹,現在要從樹種查找字母R(包含n[x]個關鍵字的內結點x,x有n[x]+1]個子女(也就是說,一個內結點x若含有n[x]個關鍵字,那么x將含有n[x]+1個子女)。所有的葉結點都處於相同的深度,帶陰影的結點為查找字母R時要檢查的結點):
相信,從上圖你能輕易的看到,一個內結點x若含有n[x]個關鍵字,那么x將含有n[x]+1個子女。如含有2個關鍵字D H的內結點有3個子女,而含有3個關鍵字Q T X的內結點有4個子女。
B 樹又叫平衡多路查找樹。一棵m階的B 樹 (m叉樹)的特性如下:
- 樹中每個結點最多含有m個孩子(m>=2);
- 除根結點和葉子結點外,其它每個結點至少有[ceil(m / 2)]個孩子(其中ceil(x)是一個取上限的函數);
- 若根結點不是葉子結點,則至少有2個孩子(特殊情況:沒有孩子的根結點,即根結點為葉子結點,整棵樹只有一個根節點);
- 所有葉子結點都出現在同一層,葉子結點不包含任何關鍵字信息(可以看做是外部接點或查詢失敗的接點,實際上這些結點不存在,指向這些結點的指針都為null);(讀者反饋@冷岳:這里有錯,葉子節點只是沒有孩子和指向孩子的指針,這些節點也存在,也有元素。@JULY:其實,關鍵是把什么當做葉子結點,因為如紅黑樹中,每一個NULL指針即當做葉子結點,只是沒畫出來而已)。
- 每個非終端結點中包含有n個關鍵字信息: (n,P0,K1,P1,K2,P2,......,Kn,Pn)。其中:
a) Ki (i=1...n)為關鍵字,且關鍵字按順序升序排序K(i-1)< Ki。
b) Pi為指向子樹根的接點,且指針P(i-1)指向子樹種所有結點的關鍵字均小於Ki,但都大於K(i-1)。
c) 關鍵字的個數n必須滿足: [ceil(m / 2)-1]<= n <= m-1。如下圖所示:
針對上面第5點,再闡述下:B樹中每一個結點能包含的關鍵字(如之前上面的D H和Q T X)數有一個上界和下界。這個下界可以用一個稱作B樹的最小度數(算法導論中文版上譯作度數,最小度數即內節點中節點最小孩子數目)t(t>=2)表示。
- 每個非根的結點必須至少含有t-1個關鍵字。每個非根的內結點至少有t個子女。如果樹是非空的,則根結點至少包含一個關鍵字;
- 每個結點可包含之多2t-1個關鍵字。所以一個內結點至多可有2t個子女。如果一個結點恰好有2t-1個關鍵字,我們就說這個結點是滿的(而稍后介紹的B*樹作為B樹的一種常用變形,B*樹中要求每個內結點至少為2/3滿,而不是像這里的B樹所要求的至少半滿);
- 當關鍵字數t=2(t=2的意思是,tmin=2,t可以>=2)時的B樹是最簡單的(有很多人會因此誤認為B樹就是二叉查找樹,但二叉查找樹就是二叉查找樹,B樹就是B樹,B樹的真正最准確的定義為:一棵含有t(t>=2)個關鍵字的平衡多路查找樹)。每個內結點可能因此而含有2個、3個或4個子女,亦即一棵2-3-4樹,然而在實際中,通常采用大得多的t值。
B樹中的每個結點根據實際情況可以包含大量的關鍵字信息和分支(當然是不能超過磁盤塊的大小,根據磁盤驅動(disk drives)的不同,一般塊的大小在1k~4k左右);這樣樹的深度降低了,這就意味着查找一個元素只要很少結點從外存磁盤中讀入內存,很快訪問到要查找的數據。
B樹的類型和節點定義如下圖所示:
為了簡單,這里用少量數據構造一棵3叉樹的形式,實際應用中的B樹結點中關鍵字很多的。上面的圖中比如根結點,其中17比表示一個磁盤文件的文件名;小紅方塊表示這個17文件內容在硬盤中的存儲位置;p1表示指向17左子樹的指針。
其結構可以簡單定義為:
typedef struct {
/*文件數*/
int file_num;
/*文件名(key)*/
char * file_name[max_file_num];
/*指向子節點的指針*/
BTNode * BTptr[max_file_num+1];
/*文件在硬盤中的存儲位置*/
FILE_HARD_ADDR offset[max_file_num];
}BTNode;
假如每個盤塊可以正好存放一個B樹的結點(正好存放2個文件名)。那么一個BTNODE結點就代表一個盤塊,而子樹指針就是存放另外一個盤塊的地址。
下面,咱們來模擬下查找文件29的過程:
- 根據根結點指針找到文件目錄的根磁盤塊1,將其中的信息導入內存。【磁盤IO操作 1次】
- 此時內存中有兩個文件名17、35和三個存儲其他磁盤頁面地址的數據。根據算法我們發現17<29<35,因此我們找到指針p2。
- 根據p2指針,我們定位到磁盤塊3,並將其中的信息導入內存。【磁盤IO操作 2次】
- 此時內存中有兩個文件名26,30和三個存儲其他磁盤頁面地址的數據。根據算法我們發,26<29<30,因此我們找到指針p2。
- 根據p2指針,我們定位到磁盤塊8,並將其中的信息導入內存。【磁盤IO操作 3次】
- 此時內存中有兩個文件名28,29。根據算法我們查找到文,29,並定位了該文件內存的磁盤地址。
分析上面的過程,發現需要3次磁盤IO操作和3次內存查找操作。關於內存中的文件名查找,由於是一個有序表結構,可以利用折半查找提高效率。至於IO操作時影響整個B樹查找效率的決定因素。
當然,如果我們使用平衡二叉樹的磁盤存儲結構來進行查找,磁盤4次,最多5次,而且文件越多,B樹比平衡二叉樹所用的磁盤IO操作次數將越少,效率也越高。
B樹的高度
根據上面的例子我們可以看出,對於輔存做IO讀的次數取決於B樹的高度。而B樹的高度由什么決定的呢?
其中T為度數(每個節點包含的元素個數),即所謂的階數,N為總元素個數或總關鍵字數。
我們可以看出T對於樹的高度有決定性的影響。因此如果每個節點包含更多的元素個數,在元素個數相同的情況下,則更有可能減少B樹的高度。這也是為什么SQL Server中需要盡量以窄鍵建立聚集索引。因為SQL Server中每個節點的大小為8092字節,如果減少鍵的大小,則可以容納更多的元素,從而減少了B樹的高度,提升了查詢的性能。
上面B樹高度的公式也可以進行推導得出,將每一層級的的元素個數加起來,比如度為T的節點,根為1個節點,第二層至少為2個節點,第三層至少為2t個節點,第四層至少為2t*t個節點。將所有最小節點相加,從而得到節點個數N的公式:
兩邊取對數,則可以得到樹的高度公式。
這也就是說每個節點必須至少有兩個子元素,因為根據高度公式,如果每個節點只有一個元素,也就是T=1的話,那么高度將會趨於正無窮。
B+-tree:是應文件系統所需而產生的一種B-tree的變形樹。
一棵m階的B+樹和m階的B樹的差異在於:
1.有n棵子樹的結點中含有n個關鍵字; (而B 樹是n棵子樹有n-1個關鍵字)
2.所有的葉子結點中包含了全部關鍵字的信息,及指向含有這些關鍵字記錄的指針,且葉子結點本身依關鍵字的大小自小而大的順序鏈接。 (而B 樹的葉子節點並沒有包括全部需要查找的信息)
3.所有的非終端結點可以看成是索引部分,結點中僅含有其子樹根結點中最大(或最小)關鍵字。 (而B 樹的非終節點也包含需要查找的有效信息)
a) 為什么說B+-tree比B 樹更適合實際應用中操作系統的文件索引和數據庫索引?
1) B+-tree的磁盤讀寫代價更低
B+-tree的內部結點並沒有指向關鍵字具體信息的指針。因此其內部結點相對B 樹更小。如果把所有同一內部結點的關鍵字存放在同一盤塊中,那么盤塊所能容納的關鍵字數量也越多。一次性讀入內存中的需要查找的關鍵字也就越多。相對來說IO讀寫次數也就降低了。
舉個例子,假設磁盤中的一個盤塊容納16bytes,而一個關鍵字2bytes,一個關鍵字具體信息指針2bytes。一棵9階B-tree(一個結點最多8個關鍵字)的內部結點需要2個盤快。而B+ 樹內部結點只需要1個盤快。當需要把內部結點讀入內存中的時候,B 樹就比B+ 樹多一次盤塊查找時間(在磁盤中就是盤片旋轉的時間)。
2) B+-tree的查詢效率更加穩定
由於非終結點並不是最終指向文件內容的結點,而只是葉子結點中關鍵字的索引。所以任何關鍵字的查找必須走一條從根結點到葉子結點的路。所有關鍵字查詢的路徑長度相同,導致每一個數據的查詢效率相當。
b) B+-tree的應用: VSAM(虛擬存儲存取法)文件(來源論文 the ubiquitous Btree 作者:D COMER - 1979 )
5.B*-tree
B*-tree是B+-tree的變體,在B+ 樹非根和非葉子結點再增加指向兄弟的指針;B*樹定義了非葉子結點關鍵字個數至少為(2/3)*M,即塊的最低使用率為2/3(代替B+樹的1/2)。給出了一個簡單實例,如下圖所示:
B+樹的分裂:當一個結點滿時,分配一個新的結點,並將原結點中1/2的數據復制到新結點,最后在父結點中增加新結點的指針;B+樹的分裂只影響原結點和父結點,而不會影響兄弟結點,所以它不需要指向兄弟的指針。
B*樹的分裂:當一個結點滿時,如果它的下一個兄弟結點未滿,那么將一部分數據移到兄弟結點中,再在原結點插入關鍵字,最后修改父結點中兄弟結點的關鍵字(因為兄弟結點的關鍵字范圍改變了);如果兄弟也滿了,則在原結點與兄弟結點之間增加新結點,並各復制1/3的數據到新結點,最后在父結點增加新結點的指針。
所以,B*樹分配新結點的概率比B+樹要低,空間使用率更高;
6、B樹的插入、刪除操作
- 樹中每個結點含有最多含有m個孩子,即m滿足:ceil(m/2)<=m<=m。
- 除根結點和葉子結點外,其它每個結點至少有[ceil(m / 2)]個孩子(其中ceil(x)是一個取上限的函數);
- 若根結點不是葉子結點,則至少有2個孩子(特殊情況:沒有孩子的根結點,即根結點為葉子結點,整棵樹只有一個根節點);
- 所有葉子結點都出現在同一層,葉子結點不包含任何關鍵字信息(可以看做是外部接點或查詢失敗的接點,實際上這些結點不存在,指向這些結點的指針都為null);
- 每個非終端結點中包含有n個關鍵字信息: (n,P0,K1,P1,K2,P2,......,Kn,Pn)。其中:
a) Ki (i=1...n)為關鍵字,且關鍵字按順序升序排序K(i-1)< Ki。
b) Pi為指向子樹根的接點,且指針P(i-1)指向子樹種所有結點的關鍵字均小於Ki,但都大於K(i-1)。
c) 除根結點之外的結點的關鍵字的個數n必須滿足: [ceil(m / 2)-1]<= n <= m-1(葉子結點也必須滿足此條關於關鍵字數的性質,根結點除外)。
ok,下面咱們以一棵5階(即樹中任一結點至多含有4個關鍵字,5棵子樹)B樹實例進行講解(如下圖所示):
備注:
- 關鍵字數(2-4個)針對--非根結點(包括葉子結點在內),孩子數(3-5個)--針對根結點和葉子結點之外的內結點。當然,根結點是必須至少有2個孩子的,不然就成直線型搜索樹了。
- 曾在一次面試中被問到,一棵含有N個總關鍵字數的m階的B樹的最大高度是多少?答曰:log_ceil(m/2)N (上面中關於m階B樹的第1點特性已經提到:樹中每個結點含有最多含有m個孩子,即m滿足:ceil(m/2)<=m<=m。而樹中每個結點含孩子數越少,樹的高度則越大,故如此)。在2012微軟4月份的筆試中也問到了此問題。更多原理請看上文第3小節末:B樹的高度。
下圖中關鍵字為大寫字母,順序為字母升序。
結點定義如下:
typedef struct{
int Count; // 當前節點中關鍵元素數目
ItemType Key[4]; // 存儲關鍵字元素的數組
long Branch[5]; // 偽指針數組,(記錄數目)方便判斷合並和分裂的情況
} NodeType;
6.1、插入(insert)操作
插入一個元素時,首先在B樹中是否存在,如果不存在,即在葉子結點處結束,然后在葉子結點中插入該新的元素,注意:如果葉子結點空間足夠,這里需要向右移動該葉子結點中大於新插入關鍵字的元素,如果空間滿了以致沒有足夠的空間去添加新的元素,則將該結點進行“分裂”,將一半數量的關鍵字元素分裂到新的其相鄰右結點中,中間關鍵字元素上移到父結點中(當然,如果父結點空間滿了,也同樣需要“分裂”操作),而且當結點中關鍵元素向右移動了,相關的指針也需要向右移。如果在根結點插入新元素,空間滿了,則進行分裂操作,這樣原來的根結點中的中間關鍵字元素向上移動到新的根結點中,因此導致樹的高度增加一層。如下圖所示:
1、OK,下面咱們通過一個實例來逐步講解下。插入以下字符字母到一棵空的B 樹中(非根結點關鍵字數小了(小於2個)就合並,大了(超過4個)就分裂):C N G A H E K Q M F W L T Z D P R X Y S,首先,結點空間足夠,4個字母插入相同的結點中,如下圖:
2、當咱們試着插入H時,結點發現空間不夠,以致將其分裂成2個結點,移動中間元素G上移到新的根結點中,在實現過程中,咱們把A和C留在當前結點中,而H和N放置新的其右鄰居結點中。如下圖:
3、當咱們插入E,K,Q時,不需要任何分裂操作
4、插入M需要一次分裂,注意M恰好是中間關鍵字元素,以致向上移到父節點中
5、插入F,W,L,T不需要任何分裂操作
6、插入Z時,最右的葉子結點空間滿了,需要進行分裂操作,中間元素T上移到父節點中,注意通過上移中間元素,樹最終還是保持平衡,分裂結果的結點存在2個關鍵字元素。
7、插入D時,導致最左邊的葉子結點被分裂,D恰好也是中間元素,上移到父節點中,然后字母P,R,X,Y陸續插入不需要任何分裂操作(別忘了,樹中至多5個孩子)。
8、最后,當插入S時,含有N,P,Q,R的結點需要分裂,把中間元素Q上移到父節點中,但是情況來了,父節點中空間已經滿了,所以也要進行分裂,將父節點中的中間元素M上移到新形成的根結點中,注意以前在父節點中的第三個指針在修改后包括D和G節點中。這樣具體插入操作的完成,下面介紹刪除操作,刪除操作相對於插入操作要考慮的情況多點。
6.2、刪除(delete)操作
(1)刪除操作的兩個步驟
第一步驟:在樹中查找被刪關鍵字K所在的地點
第二步驟:進行刪去K的操作
(2)刪去K的操作
B-樹是二叉排序樹的推廣,中序遍歷B-樹同樣可得到關鍵字的有序序列(具體遍歷算法【參見練習】)。任一關鍵字K的中序前趨(后繼)必是K的左子樹(右子樹)中最右(左)下的結點中最后(最前)一個關鍵字。
若被刪關鍵字K所在的結點非樹葉,則用K的中序前趨(或后繼)K'取代K,然后從葉子中刪去K'。從葉子*x開始刪去某關鍵字K的三種情形為:
情形一:若x->keynum>Min,則只需刪去K及其右指針(*x是葉子,K的右指針為空)即可使刪除操作結束。
注意:
情形二:若x->keynum=Min,該葉子中的關鍵字個數已是最小值,刪K及其右指針后會破壞B-樹的性質(3)。若*x的左(或右)鄰兄弟結點*y中的關鍵字數目大於Min,則將*y中的最大(或最小)關鍵字上移至雙親結點*parent中,而將*parent中相應的關鍵字下移至x中。顯然這種移動使得雙親中關鍵字數目不變;*y被移出一個關鍵字,故其keynum減1,因它原大於Min,故減少1個關鍵字后keynum仍大於等於Min;而*x中已移入一個關鍵字,故刪K后*x中仍有Min個關鍵字。涉及移動關鍵字的三個結點均滿足B-樹的性質(3)。 請讀者驗證,上述操作后仍滿足B-樹的性質(1)。移動完成后,刪除過程亦結束。
情形三:若*x及其相鄰的左右兄弟(也可能只有一個兄弟)中的關鍵字數目均為最小值Min,則上述的移動操作就不奏效,此時須*x和左或右兄弟合並。不妨設*x有右鄰兄弟*y(對左鄰兄弟的討論與此類似),在*x中刪去K后,將雙親結點*parent中介於*x和*y之間的關鍵字K,作為中間關鍵字,與並x和*y中的關鍵字一起"合並"為一個新的結點取代*x和*y。因為*x和*y原各有Min個關鍵字,從雙親中移人的K'抵消了從*x中刪除的K,故新結點中恰有2Min(即2「m/2」-2≤m-1)個關鍵字,沒有破壞B-樹的性質(3)。但由於K'從雙親中移到新結點后,相當於從*parent中刪去了K',若parent->keynum原大於Min,則刪除操作到此結束;否則,同樣要通過移動*parent的左右兄弟中的關鍵字或將*parent與其 左右兄弟合並的方法來維護B-樹性質。最壞情況下,合並操作會向上傳播至根,當根中只有一個關鍵字時,合並操作將會使根結點及其兩個孩子合並成一個新的根,從而使整棵樹的高度減少一層。
分析:
第1個被刪的關鍵字h是在葉子中,且該葉子的keynum>Min(5階B-樹的Min=2),故直接刪去即可。第2個刪去的r不在葉子中,故用中序后繼s取代r,即把s復制到r的位置上,然后從葉子中刪去s。第3個刪去的p所在的葉子中的關鍵字數目是最小值Min,但其右兄弟的keynum>Min,故可以通過左移,將雙親中的s移到p所在的結點,而將右兄弟中最小(即最左邊)的關鍵字t上移至雙親取代s。當刪去d時,d所在的結點及其左右兄弟均無多余的關鍵字,故需將刪去d后的結點與這兩個兄弟中的一個(圖中是選擇左兄弟(ab))及其雙親中分隔這兩個被合並結點的關鍵字c合並在一起形成一個新結點(abce)。但因為雙親中失去c后keynum<Min,故必須對該結點做調整操作,此時它只有一個右兄弟,且右兄弟無多余的關鍵字,不可能通過移動關鍵字來解決。因此引起再次合並,因根只有一個關鍵字,故合並后樹高度減少一層,從而得到上圖的最后一個圖。
B-樹的高度及性能分析
B-樹上操作的時間通常由存取磁盤的時間和CPU計算時間這兩部分構成。B-樹上大部分基本操作所需訪問盤的次數均取決於樹高h。關鍵字總數相同的情況下B-樹的高度越小,磁盤I/O所花的時間越少。
與高速的CPU計算相比,磁盤I/O要慢得多,所以有時忽略CPU的計算時間,只分析算法所需的磁盤訪問次數(磁盤訪問次數乘以一次讀寫盤的平均時間(每次讀寫的時間略有差別)就是磁盤I/O的總時間)。
1、B-樹的高度
定理9.1 若n≥1,m≥3,則對任意一棵具有n個關鍵字的m階B-樹,其樹高h至多為:
logt((n+1)/2)+1。
這里t是每個(除根外)內部結點的最小度數,即
由上述定理可知:B-樹的高度為O(logtn)。於是在B-樹上查找、插入和刪除的讀寫盤的次數為O(logtn),CPU計算時間為O(mlogtn)。
2、性能分析
①n個結點的平衡的二叉排序的高度H(即lgn)比B-樹的高度h約大lgt倍。
【例】若m=1024,則lgt=lg512=9。此時若B-樹高度為4,則平衡的二叉排序樹的高度約為36。顯然,若m越大,則B-樹高度越小。
②若要作為內存中的查找表,B-樹卻不一定比平衡的二叉排序樹好,尤其當m較大時更是如此。
因為查找等操作的CPU計算時間在B-樹上是
O(mlogtn)=0(lgn·(m/lgt))
而m/lgt>1,所以m較大時O(mlogtn)比平衡的二叉排序樹上相應操作的時間O(lgn)大得多。因此,僅在內存中使用的B-樹必須取較小的m。(通常取最小值m=3,此時B-樹中每個內部結點可以有2或3個孩子,這種3階的B-樹稱為2-3樹)。
B+樹是B-樹的變體,也是一種多路搜索樹:
1.其定義基本與B-樹同,除了:
2.非葉子結點的子樹指針與關鍵字個數相同;
3.非葉子結點的子樹指針P[i],指向關鍵字值屬於[K[i], K[i+1])的子樹(B-樹是開區間);
5.為所有葉子結點增加一個鏈指針;
6.所有關鍵字都在葉子結點出現;
如:(M=3)

B+的搜索與B-樹也基本相同,區別是B+樹只有達到葉子結點才命中(B-樹可以在非葉子結點命中),其性能也等價於在關鍵字全集做一次二分查找;
B+的特性:
1.所有關鍵字都出現在葉子結點的鏈表中(稠密索引),且鏈表中的關鍵字恰好是有序的;
2.不可能在非葉子結點命中;
3.非葉子結點相當於是葉子結點的索引(稀疏索引),葉子結點相當於是存儲(關鍵字)數據的數據層;
4.更適合文件索引系統;
B*樹
是B+樹的變體,在B+樹的非根和非葉子結點再增加指向兄弟的指針;

B*樹定義了非葉子結點關鍵字個數至少為(2/3)*M,即塊的最低使用率為2/3(代替B+樹的1/2);
B+樹的分裂:當一個結點滿時,分配一個新的結點,並將原結點中1/2的數據復制到新結點,最后在父結點中增加新結點的指針;B+樹的分裂只影響原結點和父結點,而不會影響兄弟結點,所以它不需要指向兄弟的指針;
B*樹的分裂:當一個結點滿時,如果它的下一個兄弟結點未滿,那么將一部分數據移到兄弟結點中,再在原結點插入關鍵字,最后修改父結點中兄弟結點的關鍵字(因為兄弟結點的關鍵字范圍改變了);如果兄弟也滿了,則在原結點與兄弟結點之間增加新結點,並各復制1/3的數據到新結點,最后在父結點增加新結點的指針;
所以,B*樹分配新結點的概率比B+樹要低,空間使用率更高;