B-樹是一種平衡的多路查找樹,注意:B樹就是B-樹,"-"是個連字符號,不是減號 。在大多數的平衡查找樹(Self-balancing search trees),比如 AVL 樹 和紅黑樹,都假設所有的數據放在主存當中。那爲什麼要使用 B-樹呢(或者說爲啥要有 B-樹呢)?要解釋清楚這一點,我們假設我們的數據量達到了億級別,主存當中根本存儲不下,我們只能以塊的形式從磁盤讀取數據,與主存的訪問時間相比,磁盤的 I/O 操作相當耗時,而提出 B-樹的主要目的就是減少磁盤的 I/O 操作。大多數平衡樹的操作(查找、插入、刪除,最大值、最小值等等)需要 次磁盤訪問操作,其中 是樹的高度。但是對於 B-樹而言,樹的高度將不再是 (其中 是樹中的結點個數),而是一個我們可控的高度 (通過調整 B-樹中結點所包含的鍵【你也可以叫做數據庫中的索引,本質上就是在磁盤上的一個位置信息】的數目,使得 B-樹的高度保持一個較小的值)。一般而言,B-樹的結點所包含的鍵的數目和磁盤塊大小一樣,從數個到數千個不等。由於B-樹的高度 h 可控(一般遠小於 ),所以與 AVL 樹和紅黑樹相比,B-樹的磁盤訪問時間將極大地降低。
我們之前談過紅黑樹與AVL樹相比較,紅黑樹更好一些,這裏我們將紅黑樹與B-樹進行比較,並以一個例子對第一段的內容進行解釋。
假設我們現在有 838,8608
條記錄,對於紅黑樹而言,樹的高度 ,也就是說如果要查找到葉子結點需要 23 次磁盤 I/O 操作;但是 B-樹,情況就不同了,假設每一個結點可以包含 8 個鍵(當然真實情況下沒有這麼平均,有的結點包含的鍵可能比8多一些,有些比 8 少一些),那麼整顆樹的高度將最多 8 ( ) 層,也就意味着磁盤查找一個葉子結點上的鍵的磁盤訪問時間只有 8 次,這就是 B-樹提出來的原因所在。
B 樹的特性
所有的葉子結點都出現在同一層上,並且不帶信息(可以看做是外部結點或查找失敗的結點,實際上這些結點不存在,指向這些結點的指針爲空)。
每個結點包含的關鍵字個數有上界和下界。用一個被稱爲 B-樹的 最小度數 的固定整數 來表示這些界 ,其中 取決於磁盤塊的大小:
a.除根結點以外的每個結點必須至少有 個關鍵字。因此,除了根結點以外的每個內部結點有 t 個孩子。如果樹非空,根結點至少有一個關鍵字。
b. 每個結點至多包含 個關鍵字。
一個包含 個關鍵字的結點有 個孩子;
一個結點中的所有關鍵字升序排列,兩個關鍵字 和 之間的孩子結點的所有關鍵字 key 在 的範圍之內。
與二叉排序樹不同, B-樹的搜索是從根結點開始,根據結點的孩子樹做多路分支選擇,而二叉排序樹做的是二路分支選擇,每一次判斷都會進行一次磁盤 I/O操作。
與其他平很二叉樹類似,B-樹查找、插入和刪除操作的時間複雜度爲 量級。
上圖就是一顆典型的 B-樹,其中最小度數 ,根結點至少包含一個關鍵字 P
,根結點以外的每個結點至少有 t - 1 = 1
個,每個結點最多包含 2t - 1= 3
個關鍵字;包含三個 1 關鍵字 P
的根結點有 1 + 1 = 2
個孩子結點,包含 3 個關鍵字的結點 (C、G、L)
包含有 4 個孩子。同一個結點中的所有關鍵字升序排列,比如結點 (D、E、F)
的內部結點就是升序排列,且均位於其父結點中的關鍵字 C
和 G
之間。所有的葉結點均爲空。
B-樹的查找
B-樹的查找操作與二叉排序樹(BST)極爲類似,只不多 B-樹中的每個結點包含多個關鍵字。假設待查找的關鍵字爲 k
,我們從根結點開始,遞歸向下進行查找。對每一個訪問的非葉子結點,如果結點包含待查找的關鍵字 k
,則返回結點指針;否則,我們遞歸到該結點的恰當子代(該子代結點中的關鍵字均在比 k
更大的關鍵字之前)。如果抵達了葉子結點且沒有找到 k
則返回 null .
B-樹查找操作演示
我們以查找關鍵字 F
爲例進行說明。
第一步:訪問根結點 P
,發現關鍵字 F
小於 P
,則查找結點 P
的左孩子。
第二步:訪問結點 P
的左子結點 [C、G、L]
,對於一個結點中包含多個關鍵字時,順序進行訪問,首先與關鍵字 C
進行比較,發現比 C
大;然後與關鍵字 G
進行比較,發現比 G
小,則說明待查找關鍵字 F
位於關鍵字 C
和關鍵字 G
之間的子代中。
第三步:訪問關鍵字 C
和關鍵字 G
之間的子代,該子代結點包含三個關鍵字 [D、E、F]
,進行順序遍歷,比較關鍵字 D
和 F
,F
比 D
大
順序訪問關鍵字 E
,F
比 E
大:
順序訪問關鍵字 F
,發現與待查找關鍵字相同,查找成功。則返回結點 [D、E、F]
的指針。
在此處我們順帶一起看一下 B-樹中結點的一個定義:
int *keys; // 存儲關鍵字的數組
int t; // 最小度 (定義一個結點包含關鍵字的個數 t-1 <= num <= 2t -1)
BTreeNode **C; // 存儲孩子結點指針的數組
int n; // 記錄當前結點包含的關鍵字的個數
bool leaf; // 葉子結點的一個標記,如果是葉子結點則爲true,否則false
這是一個結點所最關鍵的幾個屬性,我們對 B-樹中結點的完整定義爲:
class BTreeNode
{
int *keys; // 存儲關鍵字的數組
int t; // 最小度 (定義一個結點包含關鍵字的個數 t-1 <= num <= 2t -1)
BTreeNode **C; // 存儲孩子結點指針的數組
int n; // 記錄當前結點包含的關鍵字的個數
bool leaf; // 葉子結點的一個標記,如果是葉子結點則爲true,否則false
public:
BTreeNode(int _t, bool _leaf);
//
void traverse();
// 查找一個關鍵字
BTreeNode *search(int k); // 如果沒有出現,則返回 NULL
// 設置友元,以便訪問BTreeNode類中的私有成員
friend class BTree;
};
// B-樹
class BTree
{
BTreeNode *root; //指向B-樹根節點的指針
int t; // 最小度
public:
// 構造器(初始化一棵樹爲空樹)
BTree(int _t)
{ root = NULL; t = _t; }
// 進行中序遍歷
void traverse()
{ if (root != NULL) root->traverse(); }
// B-樹中查找一個關鍵字 k
BTreeNode* search(int k)
{ return (root == NULL)? NULL : root->search(k); }
};
這裏面可能涉及一些 C++
的基礎,不過你學算法,不必在意,只需要關注一個 B-樹結點最重要的幾個屬性定義。
B-樹的查找操作的實現
// B-樹查找操作的實現
BTreeNode *BTreeNode::search(int k)
{
// 找到第一個大於等於待查找關鍵字 k 的關鍵字
int i = 0;
while (i < n && k > keys[i])
i++;
// 如果找到的第一個關鍵字等於 k , 返回結點指針
if (keys[i] == k)
return this;
// 如果沒有找到關鍵 k 且當前結點爲葉子結點則返回NULL
if (leaf == true)
return NULL;
// 遞歸訪問恰當的子代
return C[i]->search(k);
}
B-樹的中序遍歷
B-樹的中序遍歷與二叉樹的中序遍歷也很相似,我們從最左邊的孩子結點開始,遞歸地打印最左邊的孩子結點,然後對剩餘的孩子和關鍵字重複相同的過程。最後,遞歸打印最右邊的孩子.
對於這個圖的中序遍歷結果爲:
**一定要注意,本應該是26個字母,但是這裏缺少了字母 I
** ,之後我們看插入操作時可以將其插入。
中序遍歷實現代碼
void BTreeNode::traverse()
{
// 有 n 個關鍵字和 n+1 個孩子
// 遍歷 n 個關鍵字和前 n 個孩子
int i;
for (i = 0; i < n; i++)
{
// 如果當前結點不是葉子結點, 在打印 key[i] 之前,
// 先遍歷以 C[i] 爲根的子樹.
if (leaf == false)
C[i]->traverse();
cout << " " << keys[i];
}
// 打印以最後一個孩子爲根的子樹
if (leaf == false)
C[i]->traverse();
}
B-樹的插入操作
一個新插入的關鍵字 k
總是被插入到葉子結點。與二叉排序樹的插入操作類似,我們從根結點開始,向下遍歷直到葉子結點,到達葉子結點,將關鍵字 k
插入到相應的葉子結點。與 BST 不同的是,我們通過最小度定義了一個結點可以包含關鍵字的個數的一個取值範圍,所以在插入一個關鍵字時,就需要確認插入關鍵字之後結點是否超出結點本身最大可容納的關鍵字個數。
如果判斷在插入一個關鍵字 k 之前,一個結點是否有可供當前結點插入的空間呢?
我們可以使用一個稱爲 splitChild()
的操作實現,即拆分一個結點的孩子。下圖中, x
的孩子結點 y
被拆分成了兩個結點 y
和 z
。拆分操作將一個關鍵 上移,並以上移的關鍵 對結點 y
進行拆分,拆分成包含關鍵字 [G、H]
的結點 y
和包含關鍵字 [J、K]
的結點 z
. 這一過程又稱之爲 B-樹的生長,區別於 BST 的向下生長。
綜上,B-樹在插入一個新的關鍵字 k
時,我們從根結點一直訪問到葉子結點,在遍歷一個結點之前,首先檢查這個結點是否已經滿了,即包含了 2t - 1
個關鍵字,如果結點已滿,則將其拆分並創建新的空間。插入操作的僞代碼描述如下:
插入操作僞碼
初始化
x
作爲根結點當
x
不是葉子結點,執行如下操作:
找到
x
的下一個要被訪問的孩子結點y
如果
y
沒有滿,則將結點y
作爲新的x
如果
y
已經滿了,拆分y
,結點x
的指針指向結點y
的兩部分。如果k
比y
中間的關鍵字小, 則將y
的第一部分作爲新的x
,否則將y
的第二部分作爲新的x
,當將y
拆分後,將y
中的一個關鍵字移動到它的父結點x
當中。
當 x
是葉子結點時,第二步結束;由於我們已經提前查分了所有結點,x
必定至少有一個額外的關鍵字空間,進行簡單的插入即可。
事實上 B-樹的插入操作是一種主動插入算法,因爲在插入新的關鍵字之前,我們會將所有已滿的結點進行拆分,提前拆分的好處就是,我們不必進行回溯,遍歷結點兩次。如果我們不事先拆分一個已滿的結點,而僅僅在插入新的關鍵字時才拆分它,那麼最終可能需要再次從根結點出發遍歷所有結點,比如在我們到達葉子結點時,將葉結點進行拆分,並將其中的一個關鍵字上移導致父結點分裂(因爲上移導致父結點超出可存儲的關鍵字的個數),父結點的分裂後,新的關鍵字繼續上移,將可能導致新的父結點分裂,從而出現大量的回溯操作。但是 B-樹這種主動插入算法中,就不會發生級聯效應。當然,這種主動插入的缺點也很明顯,我們可能進行很多不必要的拆分操作。
插入操作案例
我們以在上圖中插入關鍵字 I
爲例進行說明。其中最小度 t = 2
,一個結點最多可存儲 2t - 1 = 3
個結點。
第一步:訪問根結點,發現插入關鍵字 I
小於 P
, 但根結點未滿,不分裂,直接訪問其第一個孩子結點。
第二步:訪問結點 P
的第一個孩子結點 [C、G、L]
,發現第一個孩子結點已滿,將第一個孩子結點分裂爲兩個:
第三步:將結點 I
插入到結點 L
的第一個左孩子當中,發現 L
的第一個左孩子 [H、J、K]
已滿,則將其分裂爲兩個。
第四步:將結點 I
插入到結點 J
的第一個孩子當中,發現 L
的第一個孩子結點 [H]
未滿且爲葉子結點,則將 I
直接插入。
插入操作代碼實現:
關於 B-樹插入操作的實現稍微複雜一些,裏面涉及到每一個結點內部指針的移動,同時涉及到父結點中相應指針的移動,不過對照着圖和代碼中的註釋,我相信你可以看懂。
// B-樹中插入一個新的結點 k 主函數
void BTree::insert(int k)
{
// 如果樹爲空樹
if (root == NULL)
{
// 爲根結點分配空間
root = new BTreeNode(t, true);
root->keys[0] = k; //插入結點 k
root->n = 1; // 更新根結點高寒的關鍵字的個數爲 1
}
else
{
// 當根結點已滿,則對B-樹進行生長操作
if (root->n == 2*t-1)
{
// 爲新的根結點分配空間
BTreeNode *s = new BTreeNode(t, false);
// 將舊的根結點作爲新的根結點的孩子
s->C[0] = root;
// 將舊的根結點分裂爲兩個,並將一個關鍵字上移到新的根結點
s->splitChild(0, root);
// 新的根結點有兩個孩子結點
//確定哪一個孩子將擁有新插入的關鍵字
int i = 0;
if (s->keys[0] < k)
i++;
s->C[i]->insertNonFull(k);
// 新的根結點更新爲 s
root = s;
}
else //根結點未滿,調用insertNonFull()函數進行插入
root->insertNonFull(k);
}
}
// 將關鍵字 k 插入到一個未滿的結點中
void BTreeNode::insertNonFull(int k)
{
// 初始化 i 爲結點中的最後一個關鍵字的位置
int i = n-1;
// 如果當前結點是葉子結點
if (leaf == true)
{
// 下面的循環做兩件事:
// a) 找到新插入的關鍵字位置並插入
// b) 移動所有大於關鍵字 k 的向後移動一個位置
while (i >= 0 && keys[i] > k)
{
keys[i+1] = keys[i];
i--;
}
// 插入新的關鍵字,結點包含的關鍵字個數加 1
keys[i+1] = k;
n = n+1;
}
else
{
//找到第一個大於關鍵字 k 的關鍵字 keys[i] 的孩子結點
while (i >= 0 && keys[i] > k)
i--;
// 檢查孩子結點是否已滿
if (C[i+1]->n == 2*t-1)
{
// 如果已滿,則進行分裂操作
splitChild(i+1, C[i+1]);
// 分裂後,C[i] 中間的關鍵字上移到父結點,
// C[i] 分裂稱爲兩個孩子結點
// 找到新插入關鍵字應該插入的結點位置
if (keys[i+1] < k)
i++;
}
C[i+1]->insertNonFull(k);
}
}
// 結點 y 已滿,則分裂結點 y
void BTreeNode::splitChild(int i, BTreeNode *y)
{
// 創建一個新的結點存儲 t - 1 個關鍵字
BTreeNode *z = new BTreeNode(y->t, y->leaf);
z->n = t - 1;
//將結點 y 的後 t -1 個關鍵字拷貝到 z 中
for (int j = 0; j < t-1; j++)
z->keys[j] = y->keys[j+t];
// 如果 y 不是葉子結點,拷貝 y 的後 t 個孩子結點到 z中
if (y->leaf == false)
{
for (int j = 0; j < t; j++)
z->C[j] = y->C[j+t];
}
//將 y 所包含的關鍵字的個數設置爲 t -1
//因爲已滿則爲2t -1 ,結點 z 中包含 t - 1 個
//一個關鍵字需要上移
//所以 y 中包含的關鍵字變爲 2t-1 - (t-1) -1
y->n = t - 1;
// 給當前結點的指針分配新的空間,
//因爲有新的關鍵字加入,父結點將多一個孩子。
for (int j = n; j >= i+1; j--)
C[j+1] = C[j];
// 當前結點的下一個孩子設置爲z
C[i+1] = z;
//將所有父結點中比上移的關鍵字大的關鍵字後移
//找到上移結點的關鍵字的位置
for (int j = n-1; j >= i; j--)
keys[j+1] = keys[j];
// 拷貝 y 的中間關鍵字到其父結點中
keys[i] = y->keys[t-1];
//當前結點包含的關鍵字個數加 1
n = n + 1;
}
B-樹的刪除操作
B-樹的刪除操作相比於插入操作更爲複雜,如果僅僅只是刪除葉子結點中的關鍵字,也非常簡單,但是如果刪除的是內部節點的,就不得不對結點的孩子進行重新排列。
與 B-樹的插入操作類似,我們必須確保刪除操作不違背 B-樹的特性。正如插入操作中每一個結點所包含的關鍵字的個數不能超過 2t -1
一樣,刪除操作要保證每一個結點包含的關鍵字的個數不少於 t -1
個(除根結點允許包含比 t -1
少的關鍵字的個數。
接下來一一橫掃刪除操作中可能出現的所有情況。
初始的 B-樹 如圖所示,其中最小度 t = 3
每一個結點最多可包含 5 個關鍵字,至少包含 2個關鍵字(根結點除外)。
1. 待刪除的關鍵字 k 在結點 x 中,且 x 是葉子結點,刪除關鍵字k
刪除 B-樹中的關鍵字 F
2. 待刪除的關鍵字 k 在結點 x 中,且 x 是內部結點,分一下三種情況
情況一:如果位於結點 x 中的關鍵字 k 之前的第一個孩子結點 y 至少有 t 個關鍵字,則在孩子結點 y 中找到 k 的前驅結點 ,遞歸地刪除關鍵字 ,並將結點 x 中的關鍵字 k 替換爲 .
刪除 B-樹中的關鍵字 G
,G
的前一個孩子結點 y
爲 [D、E、F]
,包含 3個關鍵字,滿足情況一,關鍵字 G
的直接前驅爲關鍵 F
,刪除 F
,然後將 G
替換爲 F
.
情況二:y 所包含的關鍵字少於 t 個關鍵字,則檢查結點 x 中關鍵字 k 的後一個孩子結點 z 包含的關鍵字的個數,如果 z 包含的關鍵字的個數至少爲 t 個,則在 z 中找到關鍵字 k 的直接後繼 ,然後刪除 ,並將關鍵 k 替換爲 .
刪除 B-樹中的關鍵字 C
, y
中包含的關鍵字的個數爲 2 個,小於 t = 3
,結點 [C、G、L]
中的 關鍵字 C
的後一個孩子 z 爲 [D、E、F]
包含 3 個關鍵字,關鍵字 C
的直接後繼爲 D
,刪除 D
,然後將 C
替換爲 D
.
情況三:如果 y 和 z 都只包含 t -1 個關鍵字,合併關鍵字 k 和所有 z 中的關鍵字到 結點 y 中,結點 x 將失去關鍵字 k 和孩子結點 z,y 此時包含 2t -1 個關鍵字,釋放結點 z 的空間並遞歸地從結點 y 中刪除關鍵字 k.
爲了說明這種情況,我們將用下圖進行說明。
刪除關鍵字 C
, 結點 y 包含 2 個關鍵字 ,結點 z 包含 2 個關鍵字,均等於 t - 1 = 2
個, 合併關鍵字 C
和結點 z 中的所有關鍵字到結點 y
當中:
此時結點 y 爲葉子結點,直接刪除關鍵字 C
3. 如果關鍵字 k 不在當前在內部結點 x 中,則確定必包含 k 的子樹的根結點 x.c(i)
(如果 k 確實在 B-樹中)。如果 x.c(i)
只有 t - 1 個關鍵字,必須執行下面兩種情況進行處理:(看到這裏一頭霧水)
首先我們得確認什麼是當前內部結點 x ,什麼是 x.c(i)
,如下圖所示, P 現在不是根結點,而是完整 B-樹的一個子樹的根結點:
情況二:如果 x.c(i)
及 x.c(i)
的所有相鄰兄弟都只包含 t - 1 個關鍵字,則將 x.c(i)
與 一個兄弟合併,即將 x 的一個關鍵字移動至新合併的結點,使之成爲該結點的中間關鍵字,將合併後的結點作爲新的 x 結點 .(依舊一頭霧水)
不要驚奇,爲什麼情況二放前面,而情況一放後面,原因是這樣有助於你的理解。
情況二上面的圖標明瞭相應的 x 及 x.c(i)
,我們以刪除關鍵字 D
爲例,此時當前內部結點 x 不包含關鍵字 D
, 確定是第三種情況,我們可以確認關鍵 D
一定在結點 x 的第一個孩子結點所在的子樹中,結點 x 的第一個孩子結點所在子樹的跟結點爲 x.c(i) 即 [G、L]
. 其中 結點 [G、L]
及其相鄰的兄弟結點 [T、W]
都只包含 2 個結點(即 t - 1
) ,則將 [G、L]
與 [T、W]
合併,並將結點 x 當中僅有的關鍵字 P
合併到新結點中;然後將合併後的結點作爲新的 x 結點,遞歸刪除關鍵字 D
,發現D 此時在葉子結點 y 中,直接刪除,就是 1. 的情況。(此時清晰了很多)
情況一:x.c(i)
僅包含 t - 1 個關鍵字且 x.c(i)
的一個兄弟結點包含至少 t 個關鍵字,則將 x 的某一個關鍵字下移到 x.c(i)
中,將 x.c(i)
的相鄰的左兄弟或右兄弟結點中的一個關鍵字上移到 x 當中,將該兄弟結點中相應的孩子指針移到 x.c(i)
中,使得 x.c(i)
增加一個額外的關鍵字。(一頭霧水)
爲了去掉 “一頭霧水“,我們在上面情況二刪除後的結果上繼續進行說明:
)
我們以刪除結點 [A、B]
中的結點 B
爲例,上圖中 x.c(i)
包含 2 個關鍵字,即 t - 1 個關鍵字, x.c(i)
的一個兄弟結點 [H、J、K]
包含 3 個關鍵字(滿足至少 t 個關鍵字的要求),則將兄弟結點 [H、J、K]
中的關鍵字 H
向上移動到 x
中, 將 x 中的關鍵字 C
下移到 x.c(i)
中;刪除關鍵字 B
.
到這裏,B-樹的所有主要操作就結束了,關於 B-樹查找、遍歷、插入和刪除的完整工程代碼我就再不放在文中了,需要的朋友後臺回覆 「 B-tree」就可以獲得。
二叉樹與B-樹的比較
B-樹是一顆中序遍歷結果有序的多路平衡樹。不同於二叉樹,B-樹中的結點可以有多個孩子結點,二叉樹只能有兩個孩子結點。B-樹的高度爲 (其中 M 是B-樹的階,也就是一個結點可以最多包含關鍵字的個數,N 爲結點個數)。每一次更新高度自動調整。B-樹中的結點內的關鍵字按照從左到右升序排列。B-樹中插入一個結點或者關鍵字相比於二叉樹也更加複雜。
二叉樹是一顆典型的普通樹。與 B-樹不同,二叉樹中的結點最多可以有兩個孩子結點。二叉樹最頂端的根結點僅包含一個左子樹和右子樹。與 B-樹相同,中序遍歷結果有序,但是二叉樹的前序遍歷結果和後序遍歷結果同樣可以有序,二叉樹中結點的插入和刪除操作簡單。
N | B-Tree | Binary Tree |
---|---|---|
1 | B-樹中,一個結點最多可以包含 M 個孩子結點 | 二叉樹中,一個結點最多包含 2 個孩子結點 |
2 | B-樹是一顆中序遍歷結果有序的有序樹 | 二叉樹不是排序樹,可以按照前、中、後序遍歷進行排序 |
3 | B-樹的高度爲 | 二叉樹的高度爲 |
4 | B-樹從磁盤中加載數據 | 二叉樹從RAM中加載數據 |
5 | B-樹應用於DBMS(代碼索引等) | 二叉樹用在赫夫曼編碼,代碼優化等 |
6 | B-樹中插入一個結點或關鍵字更復雜 | 二叉樹插入樹簡單 |
祝你學習愉快,文章可能稍微長了點兒,多點兒耐心好好看看!加油呀!!!下次我們一起看看B+
推薦閱讀:
作者:景禹,一個追求極致的共享主義者,想帶你一起擁有更美好的生活,化作你的一把傘。