一、背景
1、分級存儲
現代電子計算機發展速度空前,然而從實際應用的需求來看,問題規模的膨脹卻遠遠快於存儲能力的增長。以數據庫爲例,在20世紀80年代初,典型數據庫的規模爲10~100MB,而三十年後,典型數據庫的規模已需要以TB爲單位來計量。實踐證明,分級存儲纔是行之有效的方法。在由內存與外存(磁盤)組成的二級存儲系統中,數據全集往往存放於外存中,計算過程中則可將內存作爲外村的高速緩存,存放最常用數據項的副本。藉助高效的調度算法,如此便可將內存的“高速度”與外存的“大容量”結合起來。兩個相鄰存儲級別之間的數據傳輸,統稱I/O操作。各級存儲器的訪問速度相差懸殊,故應儘可能地減少I/O操作。也正因爲此,在衡量相關算法的性能時,基本可以忽略對內存的訪問,轉而更多地關注對外存的訪問次數。
2、多路搜索樹
當數據規模大到內存已不足以容納時,常規平衡二叉搜索樹的效率將大打折扣。因爲查找過程對外存的訪問次數過多。例如,若將10^9個記錄在外存中組織爲AVL樹,則每次查找大致需做30次外存訪問。爲此,需要充分利用磁盤之類外部存儲器的另一特性:就時間成本而言,讀取物理地址連續的一千個字節,與讀取單個字節幾乎沒有區別。既然外部存儲器更適宜於批量式訪問,不妨通過時間成本相對極低的多次內存操作,來代替時間成本相對極高的單次外存操作。相應,需要將通常的二叉搜索樹改造爲多路搜索樹----在中序遍歷意義下也是一種等價變換。
如圖1.1所示,比如可以兩層爲間隔,將各節點與其左、右孩子合併爲“大節點”,改造後的每個“大節點“擁有四個分支,故稱作四路搜索樹。這一策略還可進一步推廣,比如以三層爲間隔合併,進而得到八路搜索樹。一般地,以k層爲間隔如此重組,可將二叉搜索樹轉化爲等價的2^k路搜索樹,統稱多路搜索樹。不難驗證,多路搜索樹同樣支持查找等操作,且效果與原二叉搜索樹完全等同;然而重要的是,其對外存的訪問方式已發生本質變化。實際上,在此時的搜索每下降一層,都以”大節點“爲單位從外存讀取一組(而不再是單個)關鍵碼。更爲重要的是,這組關鍵碼在邏輯上與物理上都彼此相鄰,故可以批量方式從外存一次性讀出,且所需時間與讀取單個關鍵碼幾乎一樣。每組關鍵碼的最佳數目,取決於不同外存的批量訪問特性。比如旋轉式磁盤的讀寫操作多以扇區爲單位,故可根據扇區的容量和關鍵碼的大小,經換算得出每組關鍵碼的最佳規模。
二、概念
B樹英文原文爲B-tree,也譯爲B-樹。所謂m階B樹,即爲m路平衡搜索樹(m>=2),其宏觀結構如圖2.1所示。
-
所有外部節點均深度相等。
-
每個內部節點都存有不超過m-1個關鍵碼,以及用以指示對應分支的不超過m個引用。具體地,存有n<=m-1個關鍵碼:K1<K2<K3<K4< ...<Kn的內部節點,同時還配有n+1<=m個引用:A0<A1<A2<A3<A4<...<An。(也即每個節點至多有m棵子樹。)
-
在非空B樹中,根節點應滿足:n+1 >= 2。(即非空B樹根節點至少有兩棵子樹。)
-
除根節點以外的所有內部節點,都應滿足:n+1 >= Γm/2˥。(即除根節點以外的所有內部節點至少有Γm/2˥棵子樹。)
由於各節點的分支數介於Γm/2˥至m之間,故m階B樹也稱作(Γm/2˥,m)樹,如(2,3)樹、(3,6)樹等。
B樹的外部節點實際上未必意味着查找失敗,而可能表示目標關鍵碼存在於更低層次的某一外部存儲系統中,順着該節點的指示,即可深入至下一級存儲系統並繼續查找。如圖2.2即爲一棵由9個內部節點、15個外部節點以及14個關鍵碼組成的4階B樹,其高度h=3,其中每個節點包含1~3個關鍵碼,擁有2~4個分支。
三、實現
1、B樹節點
#define BTNodePosi(T) BTNode<T>* //B-樹節點位置
template<typename T>
struct BTNode { //B-樹節點模板類
//成員
BTNodePosi(T) parent; //父節點
vector<T> key; //關鍵碼向量
vector<BTNodePosi(T)> child; //孩子向量(其長度總比key多一)
//構造函數(注意:BTNode只能作爲根節點創建,而且初始時有0個關鍵碼和1個空孩子指針)
BTNode() { parent = NULL; child.insert(child.begin(), NULL); }
BTNode(T e, BTNodePosi(T) lc = NULL, BTNodePosi(T) rc = NULL) {
parent = NULL; //作爲根節點,而且初始時
key.insert(0, e); //只有一個關鍵碼,以及
child.insert(0, lc); child.insert(1, rc); //兩個孩子
if (lc) lc->parent = this;
if (rc) rc->parent = this;
}
};
2.B樹模板類聲明
#include"btnode.h" //引入B-樹節點類
template<typename T>
class BTree { //B-樹模板類
protected:
int _size; //存放的關鍵碼總數
int _order; //B-樹的階次,至少爲3----創建時指定,一般不能修改
BTNodePosi(T) _root; //根節點
BTNodePosi(T) _hot; //BTree::search()最後訪問的非空(除非樹空)的節點位置
void release(BTNodePosi(T) v); //遞歸釋放根節點爲v的子樹
void solveOverflow(BTNodePosi(T) v); //因插入而上溢之後的分裂處理
void solveUnderflow(BTNodePosi(T) v); //因刪除而下溢之後的合併處理
public:
BTree(int order = 3):_order(order),_size(0) //構造函數:默認爲最低的3階
{
_root = new BTNode<T>();
}
~BTree() { //析構函數:釋放所有節點
if (_root)
release(_root);
}
int const order() { return _order; } //階次
int const size() { return _size; } //規模
BTNodePosi(T) & root() { return _root; } //樹根
bool empty() const { return !_root; } //判空
BTNodePosi(T) search(const T& e); //查找
bool insert(const T& e); //插入
bool remove(const T& e); //刪除
};//BTree
template<typename T>
void BTree<T>::release(BTNodePosi(T) v)
{
for (int i = 0; i < v->child.size(); ++i) {
if (v->child[i])
release(v->child[i]);
}
delete v;
}
3、關鍵碼查找
B樹 的查找過程與二叉搜索樹的查找過程基本類似,不同之處在於,因此時各節點內通常都包含多個關鍵碼,故有可能需要經過(在內存中)多次比較,才能確定應該轉向下一層的哪個節點並繼續查找。整個過程如圖3.1所示。
代碼實現:
template<typename T>
BTNodePosi(T) BTree<T>::search(const T& e) //在B-樹中查找關鍵碼e
{
BTNodePosi(T) v = _root; _hot = NULL; //從根節點出發
while (v) { //逐層查找
int r = VecSearch(v->key,e); //在當前節點中,找到不大於e的最大關鍵碼
if ((0 <= r) && (e == v->key[r]))
return v; //成功:在當前節點中命中目標關鍵碼
_hot = v; v = v->child[r + 1]; //否則,轉入對應子樹(_hot指向其父)----需做I/O,最費時間
}//在同一節點內部的查找完全在內存中進行,在考慮各節點所含關鍵碼數量通常在128~512之間,故可直接使用順序查找
return NULL;
}
4、關鍵碼插入
1.B樹的關鍵碼插入算法:
template<typename T>
bool BTree<T>::insert(const T& e) //將關鍵碼e插入B-樹中
{
BTNodePosi(T) v = search(e); if (v) return false; //確認目標節點不存在
int r = VecSearch(_hot->key, e); //在節點_hot的有序關鍵碼向量中查找合適的插入位置
VecInsert(_hot->key, r + 1, e); //將新關鍵碼插至對應的位置
VecInsert(_hot->child, r + 2, (BTNodePosi(T))NULL); //創建一個空子樹指針
_size++; //更新全樹規模
solveOverflow(_hot); //如有必要,需做分裂
return true; //插入成功
}
插入一個關鍵碼後,若該節點內關鍵碼的總數依然合法(即不超過m-1個),則插入操作隨即完成。否則,稱該節點發生了一次上溢(overflow),此時做一些調整工作,使該節點以及整樹重新滿足B樹的條件。
2.上溢與分裂:
一般地,剛發生上溢的節點,應恰好含有m個關鍵碼。若取s = Լm/2˩,則它們依次爲:{,...,;;;...,}
可見,以爲界,可將該節點分前後兩個子節點,且二者大致等長。於是,可令關鍵碼上升一層,歸入其父節點(若存在)中的適當位置,並分別以這兩個子節點作爲其左、右孩子。這一過程,稱作節點的分裂(split)。不難驗證,如此分裂所得的兩個孩子節點,均符合m階B樹關於節點分支數的條件。被提升的關鍵碼可能有三種進一步的處置方式:
- 原上溢節點的父節點存在,且足以接納一個關鍵碼,則被提升的關鍵碼直接插入父節點;
- 原上溢節點的父節點存在,但已處於飽和狀態,提升後會使父節點繼而發生上溢(上溢的向上傳遞),上溢的傳遞最遠至樹根;
- 若上溢果真傳遞至根節點,則可令被提升的關鍵碼自成一個節點,並作爲新的樹根。至此上溢修復完畢,全樹增高一層。
代碼實現:
template<typename T> //關鍵碼插入後若節點上溢,則做節點分裂處理
void BTree<T>::solveOverflow(BTNodePosi(T) v)
{
if (_order >= v->child.size()) return; //遞歸基:當前節點並未上溢
int s = _order / 2; //軸點(此時應有_order = key.size() = child.size() - 1)
BTNodePosi(T) u = new BTNode<T>(); //注意:新節點已有一個空孩子
for (int j = 0; j < _order - s - 1; ++j) { //v右側_order-s-1個孩子及關鍵碼分裂爲右側節點u
VecInsert(u->child, j, VecRemove(v->child, s + 1)); //逐個移動效率低
VecInsert(u->key, j, VecRemove(v->key, s + 1)); //此策略可改進
}
u->child[_order - s - 1] = VecRemove(v->child, s + 1); //移動v最靠右的孩子
if (u->child[0]) //若u的孩子們非空
for (int j = 0; j < _order - s; ++j) //令它們的父節點統一
u->child[j]->parent = u; //指向u
BTNodePosi(T) p = v->parent; //v當前的父節點p
if (!p) { //若p空則創建之
_root = p = new BTNode<T>();
p->child[0] = v;
v->parent = p;
}
int r = 1 + VecSearch(p->key, v->key[0]); //p中指向v的指針的秩
VecInsert(p->key, r, VecRemove(v->key, s)); //軸點關鍵碼上升
VecInsert(p->child, r + 1, u); u->parent = p; //新節點u與父節點p互聯
solveOverflow(p); //上升一層,如有必要則繼續分裂----至多遞歸O(logn)層
}
5、關鍵碼刪除
1.B樹的關鍵碼刪除算法:
template<typename T>
bool BTree<T>::remove(const T& e) //從B-樹中刪除關鍵碼e
{
BTNodePosi(T) v = search(e); if (!v) return false; //確認目標關鍵碼存在
int r = VecSearch(v->key, e); //確定目標關鍵碼在節點v中的秩(由上,肯定合法)
if (v->child[0]) { //若v非葉子,則e的直接後繼必屬於某葉子節點
BTNodePosi(T) u = v->child[r + 1]; //在右子樹中一直向左,即可
while (u->child[0]) u = u->child[0]; //找出e的直接後繼
v->key[r] = u->key[0]; v = u; r = 0; //並與之交換位置
} //至此,v必然位於最底層,且其中第r個關鍵碼就是待刪除者
VecRemove(v->key, r); VecRemove(v->child, r + 1); _size--; //刪除e,以及其下兩個外部節點之一
solveUnderflow(v); //如有必要,需做旋轉或合併
return true;
}
從B樹中刪除關鍵碼e,e所在的節點爲v,假定v是葉節點,則可直接將e(及其左側的外部空節點)從v中刪去;若v不是葉節點,e的直接前驅(後繼)在其左(右)子樹中必然存在,而且可在O(h(v))時間內確定它們的位置,其中 h(v)爲節點v的高度。此處選用直接後繼。於是,e的直接後繼關鍵碼所屬的節點u必爲葉節點,且該關鍵碼就是其中的最小者u[0]。只要另e與u[0]互換位置,即可確保待刪除的關鍵碼e所屬的節點v是葉節點。
此時,若該節點所含關鍵碼的總數依然合法(即不少於Γm/2˥-1),則刪除操作隨機完成。否則,稱該節點發生了一次下溢(underflow),此時做一些調整工作,使該節點以及整樹重新滿足B樹的條件。
2.下溢與合併
在m階B樹中,剛發生下溢的節點V必恰好包含Γm/2˥-2個關鍵碼和Γm/2˥-1個分支。根據其左、右兄弟所含關鍵碼個數,分三種情況:
- V的左兄弟L存在,且至少包含Γm/2˥個關鍵碼:做如圖3.2所示調整;
- V的右兄弟R存在,且至少包含Γm/2˥個關鍵碼:做如圖3.3所示調整;
- V的左、右兄弟L和R或者不存在,或者其包含的關鍵碼均不足 Γm/2˥個:
實際上,此時的L和R不可能同時不存在。不失一般性地假設左兄弟節點L存在,此時節點L應恰好包含Γm/2˥-1個關鍵碼,做如圖3.4所示調整。
接下來,還需檢查父節點P----關鍵碼y的刪除可能致使該節點出現下溢(下溢的傳遞)。需要繼續按圖3.4所示的方式修復。當下溢傳遞至根節點且其中不在含有任何關鍵碼時,即可將其刪除並代之以唯一的孩子節點,全樹高度下降一層。
代碼實現:
template<typename T> //關鍵碼刪除後若節點下溢,則做節點旋轉或合併處理
void BTree<T>::solveUnderflow(BTNodePosi(T) v)
{
if ((_order + 1) / 2 <= v->child.size()) return; //遞歸基:當前節點並未下溢
BTNodePosi(T) p = v->parent;
if (!p) { //遞歸基:已到根節點,沒有孩子的下限
if (!v->key.size() && v->child[0]) {
//但倘若作爲樹根的v已不含關鍵碼,卻有(唯一的)非空孩子,則
_root = v->child[0]; _root->parent = NULL; //這個節點可被跳過
v->child[0] = NULL; release(v); //並因不再有用而被銷燬
} //整樹高度降低一層
return;
}
int r = 0; while (p->child[r] != v) r++;
//確定v是p的第r個孩子----此時v可能不含關鍵碼,故不能通過關鍵碼查找
//情況1:向左兄弟借關鍵碼
if (0 < r) { //若v不是p的第一個孩子,則
BTNodePosi(T) ls = p->child[r - 1]; //左兄弟必存在
if ((_order + 1) / 2 < ls->child.size()) { //若該兄弟足夠“胖”,則
VecInsert(v->key, 0, p->key[r - 1]); //p借出一個關鍵碼給v(作爲最小關鍵碼)
p->key[r - 1] = VecRemove(ls->key, ls->key.size() - 1); //ls的最大關鍵碼轉入p
VecInsert(v->child, 0, VecRemove(ls->child, ls->child.size() - 1)); //同時ls的最右側孩子過繼給v
if (v->child[0])
v->child[0]->parent = v; //作爲v的最左側孩子(當v爲葉子節點時v->child[0]爲NULL)
return; //至此,通過右旋已完成當前層(以及所有層)的下溢處理
}
} //至此,左兄弟要麼爲空,要麼太“瘦”
//情況2:向右兄弟借關鍵碼
if (p->child.size() - 1 > r) { //若v不是p的最後一個孩子,則
BTNodePosi(T) rs = p->child[r + 1]; //右兄弟必存在
if ((_order + 1) / 2 < rs->child.size()) { //若該兄弟足夠“胖”,則
VecInsert(v->key, v->key.size(), p->key[r]); //p借出一個關鍵碼給v(作爲最大關鍵碼)
p->key[r] = VecRemove(rs->key, 0); //rs的最小關鍵碼轉入p
VecInsert(v->child, v->child.size(), VecRemove(rs->child, 0)); //同時rs的最左側孩子過繼給v
if (v->child[v->child.size() - 1]) //作爲v的最右側孩子
v->child[v->child.size() - 1]->parent = v;
return; //至此,通過左旋已完成當前層(以及所有層)的下溢處理
}
} //至此,右兄弟要麼爲空,要麼太“瘦”
//情況3:左、右兄弟要麼爲空(但不可能同時),要麼都太“瘦”----合併
if (0 < r) { //與左兄弟合併
BTNodePosi(T) ls = p->child[r - 1]; //左兄弟必存在
VecInsert(ls->key, ls->key.size(), VecRemove(p->key, r - 1));
VecRemove(p->child, r); //p的第r-1個關鍵碼轉入ls,v不再是p的第r個孩子
VecInsert(ls->child, ls->child.size(), VecRemove(v->child, 0));
if (ls->child[ls->child.size() - 1]) //v的最左側孩子過繼給ls做最右側孩子
ls->child[ls->child.size() - 1]->parent = ls;
while (!v->key.empty()) { //v剩餘的關鍵碼和孩子,依次轉入ls
VecInsert(ls->key, ls->key.size(), VecRemove(v->key, 0));
VecInsert(ls->child, ls->child.size(), VecRemove(v->child, 0));
if (ls->child[ls->child.size() - 1])
ls->child[ls->child.size() - 1]->parent = ls;
}
release(v); //釋放v
}
else { //與右兄弟合併
BTNodePosi(T) rs = p->child[r + 1]; //右兄弟必存在
VecInsert(rs->key, 0, VecRemove(p->key, r)); VecRemove(p->child, r);
//p的第r個關鍵碼轉入rs,v不再是p的第r個孩子
VecInsert(rs->child, 0, VecRemove(v->child, v->child.size() - 1));
if (rs->child[0])
rs->child[0]->parent = rs; //v的最右側孩子過繼給rs做最左側孩子
while (!v->key.empty()) { //v剩餘的關鍵碼和孩子,依次轉入rs
VecInsert(rs->key, 0, VecRemove(v->key, v->key.size() - 1));
VecInsert(rs->child, 0, VecRemove(v->child, v->child.size() - 1));
if (rs->child[0])
rs->child[0]->parent = rs;
}
release(v); //釋放v
}
solveUnderflow(p); //上升一層,如有必要則繼續分裂----至多遞歸O(logn)層
return;
}
6、其他
完整代碼可訪問GitHub(Github)獲取。
四、總結
m階B樹每次查找過程共需訪問O()個節點,相應需做O()次外存讀取操作,因此耗時不超過O()。儘管沒有漸進意義上的改進,但相對而言及其耗時的I/O操作的次數,卻已大致縮減爲原先的1/。鑑於m通常取值在256至1024之間,較之此前大致降低一個數量級,實際的訪問效率將有十分可觀的提高。而對於插入和刪除操作,時間通常主要消耗於對目標關鍵碼的查找,因此都可在O()時間內完成。
五、拓展
1、B*樹
極端情況下,B樹中根以外所有節點只有Γm/2˥個分支,空間使用率大致僅有50%。若簡單地將上溢節點一分爲二,則有較大的概率會出現或接近這種極端情況。
爲了提高空間利用率,可將內部節點的分支數下限從Γm/2˥提高至Γ2m/3˥。於是,一旦節點v發生上溢且無法通過旋轉完成修復(理論上上溢也可優先考慮旋轉操作,實際應用中優先使用分裂更簡明,性能也不會顯著降低),即可將v與其(已經飽和的某一)兄弟合併,再將合併節點等分爲三節點。採用這一策略之後,即得到了B樹的一個變種,稱作B*樹。當然實際上不必真的先合二爲一,再一分爲三。可通過更爲快捷的方式達到同樣的效果:從來自原先兩個節點及其父節點的共計m+(m-1)+1=2m個關鍵碼中取出兩個上交給父節點,其餘2m-2個儘可能均衡分攤給三個新節點。對稱地,若節點v發生下溢,且其左、右兄弟均無法借出關鍵碼,則先將v與左、右兄弟合併,再將合併節點等分爲兩個節點。同樣地,實際上不必真地先合三爲一,再一分爲二。可通過更爲快捷的方式達到同樣的效果:從來自原先三個節點及其父節點的共計
(Γ2m/3˥-1)+1+(Γ2m/3˥-2)+1+(Γ2m/3˥-1)= 3 * Γ2m/3˥ - 2
個關鍵碼中取出一個上交給父節點,其餘3 * Γ2m/3˥ - 3個則儘可能均衡地分攤給兩個新節點。
2、B+樹
在基於磁盤的大型系統中,廣泛使用的是B樹的一個變體,稱爲B+樹。一棵m階的B+樹在結構上與m階的B樹相同,但在關鍵碼的內部安排上有所不同。具體如下:
- 具有m棵子樹的節點含有m個關鍵碼,即每一個關鍵碼對應一棵子樹;
- 關鍵碼Ki是它所對應的子樹的根節點中的最大(或最小)關鍵碼;
- 所有的葉節點中包含了全部關鍵碼信息,及指向關鍵碼記錄的指針;
- 各葉節點按關鍵碼的大小次序鏈在一起,形成單鏈表,並設置頭指針。
B+樹的優勢在於:
- 單一節點存儲更多的元素,使得查詢的IO次數更少;
- 所有查詢都要查找到葉子節點,查詢性能穩定;
- 所有葉子節點形成有序鏈表,便於範圍查詢。
3、紅黑樹
箴言錄
知人者智,自知者明。勝人者有力,自勝者強。