之前寫過一篇關於索引的文章《SQL夯實基礎(五):索引的數據結構》,這次我們主要詳細討論下B-Tree。
B-樹
B-tree,即B樹,而不要讀成B減樹,它是一種多路搜索樹(並不是二叉的):
1.定義任意非葉子結點最多只有M個兒子;且M>2;
2.根結點的兒子數為[2, M];
3.除根結點以外的非葉子結點的兒子數為[M/2, M];
4.每個結點存放至少M/2-1(取上整)和至多M-1個關鍵字;(至少2個關鍵字)
5.非葉子結點的關鍵字個數=指向兒子的指針個數-1;
6.非葉子結點的關鍵字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
7.非葉子結點的指針:P[1], P[2], …, P[M];其中P[1]指向關鍵字小於K[1]的子樹,P[M]指向關鍵字大於K[M-1]的子樹,其它P[i]指向關鍵字屬於(K[i-1], K[i])的子樹;
8.所有葉子結點位於同一層;
如:(M=3)
B-樹的搜索,從根結點開始,對結點內的關鍵字(有序)序列進行二分查找,如果命中則結束,否則進入查詢關鍵字所屬范圍的兒子結點;重復,直到所對應的兒子指針為空,或已經是葉子結點;
B-樹的特性:
1.關鍵字集合分布在整顆樹中;
2.任何一個關鍵字出現且只出現在一個結點中;
3.搜索有可能在非葉子結點結束;
4.其搜索性能等價於在關鍵字全集內做一次二分查找;
5.自動層次控制;
由於限制了除根結點以外的非葉子結點,至少含有M/2個兒子,確保了結點的至少利用率,其最底搜索性能為:O(logN)
所以B-樹的性能總是等價於二分查找(與M值無關),也就沒有B樹平衡的問題;
由於M/2的限制,在插入結點時,如果結點已滿,需要將結點分裂為兩個各占M/2的結點;刪除結點時,需將兩個不足M/2的兄弟結點合並;
B-樹與二叉搜索樹對比
基礎:磁盤IO與預讀
磁盤讀取依靠的是機械運動,分為尋道時間、旋轉延遲、傳輸時間三個部分,這三個部分耗時相加就是一次磁盤IO的時間,大概9ms左右。這個成本是訪問內存的十萬倍左右;正是由於磁盤IO是非常昂貴的操作,所以計算機操作系統對此做了優化:預讀;每一次IO時,不僅僅把當前磁盤地址的數據加載到內存,同時也把相鄰數據也加載到內存緩沖區中。因為局部預讀原理說明:當訪問一個地址數據的時候,與其相鄰的數據很快也會被訪問到。每次磁盤IO讀取的數據我們稱之為一頁(page)。一頁的大小與操作系統有關,一般為4k或者8k。這也就意味着讀取一頁內數據的時候,實際上發生了一次磁盤IO。
我們知道二叉查找樹查詢的時間復雜度是O(logN),查找速度最快和比較次數最少,既然性能已經如此優秀,但為什么實現索引是使用B-Tree而不是二叉查找樹,關鍵因素是磁盤IO的次數。
數據庫索引是存儲在磁盤上,當表中的數據量比較大時,索引的大小也跟着增長,達到幾個G甚至更多。當我們利用索引進行查詢的時候,不可能把索引全部加載到內存中,只能逐一加載每個磁盤頁,這里的磁盤頁就對應索引樹的節點。
二叉樹
我們先來看二叉樹查找時磁盤IO的次:定義一個樹高為4的二叉樹,查找值為10:
第一次磁盤IO:
第二次磁盤IO
第三次磁盤IO:
第四次磁盤IO:
從二叉樹的查找過程了來看,樹的高度和磁盤IO的次數都是4,所以最壞的情況下磁盤IO的次數由樹的高度來決定。
從前面分析情況來看,減少磁盤IO的次數就必須要壓縮樹的高度,讓瘦高的樹盡量變成矮胖的樹,所以B-Tree就在這樣偉大的時代背景下誕生了。
B-Tree
m階B-Tree滿足以下條件:
1、每個節點最多擁有m個子樹
2、根節點至少有2個子樹
3、分支節點至少擁有m/2顆子樹(除根節點和葉子節點外都是分支節點)
4、所有葉子節點都在同一層、每個節點最多可以有m-1個key,並且以升序排列
如下有一個3階的B樹,觀察查找元素21的過程:
第一次磁盤IO:
第二次磁盤IO:
這里有一次內存比對:分別跟3與12比對
第三次磁盤IO:
這里有一次內存比對,分別跟14與21比對
從查找過程中發現,B樹的比對次數和磁盤IO的次數與二叉樹相差不了多少,所以這樣看來並沒有什么優勢。
但是仔細一看會發現,比對是在內存中完成中,不涉及到磁盤IO,耗時可以忽略不計。另外B樹種一個節點中可以存放很多的key(個數由樹階決定)。
相同數量的key在B樹中生成的節點要遠遠少於二叉樹中的節點,相差的節點數量就等同於磁盤IO的次數。這樣到達一定數量后,性能的差異就顯現出來了。
B樹的新增
在剛才的基礎上新增元素4,它應該在3與9之間:
B樹的刪除
除元素9:
總結
插入或者刪除元素都會導致節點發生裂變反應,有時候會非常麻煩,但正因為如此才讓B樹能夠始終保持多路平衡,這也是B樹自身的一個優勢:自平衡。B樹主要應用於文件系統以及部分數據庫索引,如MongoDB,大部分關系型數據庫索引則是使用B+樹實現。