索引與B樹

本篇文章主要目的是講述數據庫索引的實現為什麼選用B樹(B-Tree)/B+樹(B+Tree),以及使用Java來實現一顆B樹。


索引與B樹

數據庫索引

對於多數的應用系統來說,查詢數據的頻率是遠遠高於寫入或者更新數據的頻率,在大數據量的場景中,常規的查詢方式可能在效率上達不到預期, 此時我們需要對SQL查詢語句做一些優化,或者對錶做一些改動,比如增加索引字段,以此來達到我們想要的查詢速度。

以MySQL的innodb存儲引擎為例,在建立索引的時候,有兩種選擇,一種是BTree,一種是Hash。BTree指的是B+Tree,B+Tree是B-Tree的變種(優化),而Hash 就容易理解,通過哈希值來查找記錄,這種方式在特定場景下查詢速度會比BTree要快很多,但是如果哈希衝突嚴重,或者範圍查詢的時候,性能就會下降,所以innodb存儲引擎 默認索引是BTree。

B-Tree

上面我們說到MySQL索引方法採用B+Tree這種數據結構(常用的關係型數據庫中,都是選擇B+Tree的數據結構來存儲數據), 它是B-Tree數據結構的變種,所以瞭解B+Tree之前,還是需要對B-Tree做一些瞭解。我們先看下面一張圖,它相對於其他的樹:二叉搜索樹,平衡二叉樹,紅黑樹而言, 變胖了,所以B樹也叫多路平衡樹,因為在一個結點上它存儲了更多的Key。 思考一下,為什麼索引不用平衡二叉樹或者紅黑樹?或者說變胖了的樹比其他平衡樹有哪些優勢?

索引與B樹

數據庫將數據存儲在磁盤中,讀取磁盤數據速度要比內存要慢的多(無論是機械硬盤或者固態硬盤),所以為了減少磁盤IO,通常會對數據進行預讀 (根據局部性原理:當一個數據被用到時,其附近的數據也通常會馬上被使用),那麼如果使用平衡二叉樹這種數據結構來充當索引,會出現在平衡樹中存儲邏輯相近結點, 在物理地址上可能會相隔很遠,就有可能會使得預讀這一動作沒有生效,而B樹由於其結點存儲多個Key,所以其結點大小可以設置為磁盤頁的大小,來充分利用磁盤預讀的功能。 但是我們需要注意的是內存中B樹的查詢不一定比其他平衡樹要高效,只是它更合適數據庫和文件系統。

為了測試B樹的查詢效率,這裡我存儲一百萬的數據量,一個存儲到B樹,一個存儲到List中,查詢五十萬的耗時 (B樹一毫秒,遍歷7毫秒。這個比較可能也沒什麼意義,但是這裡我不清楚如何模擬磁盤操作,所以暫時先這樣)。

索引與B樹

B-Tree的定義

對於B樹的定義通常有兩種,一種是算法導論中的度數說;另一種是維基百科的階數說。度與圖論中的度(出度)很類似,指的是每個節點的子節點(子樹)的個數就稱為該節點的度, 階定義為一個節點的子節點數目的最大值,這篇文章實現的B樹是基於階數來實現的,我們先來看一下相關概念

一顆m階的B樹定義如下:

1)根結點最少可以只有1個關鍵字。

2)每個結點最多有m-1個關鍵字。

3)非根結點至少有Math.ceil(m/2)-1個關鍵字。

4)每個結點中的關鍵字都按照從小到大的順序排列,每個關鍵字的左子樹中的所有關鍵字都小於它,而右子樹中的所有關鍵字都大於它。

5)根結點到每個葉子結點的長度都相同。

從這5個定義我們可以得到一些結論:一個B樹根結點可以只有一個關鍵字,當一個空樹生成B樹的時候,根結點不用考慮定義3,在新增關鍵字過程,需要對關鍵字進行排序, 來保證結點滿足定義4,當結點新增關鍵字的個數達到m的時候,就違反了定義2,此時就需要進行操作,來保證結點滿足定義2,這個操作我們叫做分裂,分裂過程中,我還需要注意,樹的深度, 因為定義5規定根結點到每個葉子結點的長度都相同,

當我們對生成的B樹進行刪除Key時,就需要考慮定義3了, 當非根結點不滿足至少有Math.ceil(m/2)-1個關鍵字時,我們需要從兄弟結點借關鍵字,或者與兄弟結點進行合併來保證至少有Math.ceil(m/2)-1個關鍵字。

下面我們來看具體如何實現一顆B-Tree(完整代碼有點長,文章只附帶部分代碼,完整代碼通過公眾號每天學Java,加群獲取)

定義B-Tree實體

B-Tree組成:

Node:B-Tree的組成結點

Entry:結點中存儲的關鍵字

SearchResult: 查詢結果

<code>/<code>

新增操作

我們以5階B-Tree為例,插入3, 8, 31, 11, 23, 29, 50, 28, 1, 2,來看一下新增過程。

對於5階的B樹每個結點最多存儲4個key,所以對於3,8,31,11的新增(排序)很好理解

索引與B樹

但是當我們新增23的時候,就會打破這種規則,所以我們需要對結點進行分裂,來保證定義2成立,分裂操作很簡單, 將中間元素進行提取,充當父結點,左右元素一份為2,分別充當孩子結點。

索引與B樹

我們繼續新增29,50

索引與B樹

新增到28的時候,又會出現分裂的動作,但是與第一次分裂不同,這個時候分裂出來的中間結點需要進行上升,然後左右元素,與父級上升的結點進行關聯, 這樣才能保證定義5成立:根結點到每個葉子結點的長度都相同

索引與B樹

繼續新增1,2

索引與B樹

到這裡就新增完成了,可以發現為了保證關鍵字不超過m-1,需要進行分裂,而分裂的操作需要保證根結點到任意葉子結點距離是相等的

分裂代碼:

<code>/<code>

刪除操作

相對於新增操作,刪除操作比新增要複雜一些,因為新增只涉及到分裂,但是刪除會涉及到替換結點,借結點,合併結點等操作。 這些操作的目的同樣是為了保證結構滿足定義

首先看替換結點,當我們要刪除非葉子結點的時候,比如圖上的29,我們使用它的前繼或者後繼的結點來替換它,這裡前繼/後繼指的是, 中序遍歷結果的前一個/後一個結點,而且在B樹中,該結點必然是葉子結點,比如29的前繼結點是28,後繼結點是31。

當上面替換成功之後,我們就要把當前的葉子結點刪掉,刪除動作很簡單,但是 當我們把關鍵字刪掉之後,需要判斷當前結點的關鍵字是否滿足我們所說的至少有Math.ceil(m/2)-1個的要求,如果滿足,刪除成功,不滿足時為了保證 滿足這樣的條件,我們看看兄弟結點有沒有富裕(大於Math.ceil(m/2)-1)的關鍵字,給他借過來。這一步叫借結點。

那麼合併結點就是當兄弟結點不足的時候,我們需要找兄弟結點來進行合併,從而滿足B樹的定義。

下面具體看下實現過程:

首先我們刪除關鍵字8:因為是葉子結點,且刪除後關鍵字滿足 >=Math.ceil(m/2)-1 的條件

索引與B樹

我們繼續刪除關鍵字29:首先找到他的前繼結點關鍵字28來替換刪除

索引與B樹

刪除之後我們會發現當前結點關鍵字已經不足2了,所以需要借結點或者合併結點,我們會發現,左兄弟結點是富裕的,所以可以向左兄弟結點借關鍵字。 借的過程:父結點下移一個關鍵字,左兄弟結點上移一個關鍵字,從而使B樹平衡。

索引與B樹

刪完29之後,我們再來刪除關鍵字11,此時兄弟結點沒有富裕的關鍵字來讓該結點滿足 >=Math.ceil(m/2)-1 的條件,所以需要進行結點合併, 這裡我們選擇合併左孩子來進行合併,合併過程:將父結點關鍵字下移,到合併結點,失衡的被合併結點關鍵字插入到合併結點,父結點刪除失衡結點。 由於父結點是根結點,所以即使一個關鍵字也符合要求。

索引與B樹

到這裡就把B樹的增加和刪除操作給完成了,搜索的過程就較為簡單了採用二分法+遞歸即可。

總結

在實現B樹的過程中,有一點容易讓人誤解的是孩子結點與父親結點的關係,實際上孩子是整個結點的孩子,而不是結點某個關鍵字的孩子。

索引與B樹

但是孩子結點與父親結點關鍵字之間是存在一定的關係的,比如父結點有兩個關鍵字,那麼就會有是三個孩子,而父結點關鍵字所在的索引序號,比如下標是0, 那麼孩子中下標為0的結點所有關鍵字都會小於父親結點下標為0的關鍵字,如下圖,這也是為什麼使用二分可以找到相應關鍵字的原因。

索引與B樹

代碼這裡就不放了,太長也沒人看,需要完整代碼可以通過公眾號:每天學Java,來加群獲取。


分享到:


相關文章: