B樹詳解及其模板類實現

一、背景

1、分級存儲

        現代電子計算機發展速度空前,然而從實際應用的需求來看,問題規模的膨脹卻遠遠快於存儲能力的增長。以數據庫爲例,在20世紀80年代初,典型數據庫的規模爲10~100MB,而三十年後,典型數據庫的規模已需要以TB爲單位來計量。實踐證明,分級存儲纔是行之有效的方法。在由內存與外存(磁盤)組成的二級存儲系統中,數據全集往往存放於外存中,計算過程中則可將內存作爲外村的高速緩存,存放最常用數據項的副本。藉助高效的調度算法,如此便可將內存的“高速度”與外存的“大容量”結合起來。兩個相鄰存儲級別之間的數據傳輸,統稱I/O操作。各級存儲器的訪問速度相差懸殊,故應儘可能地減少I/O操作。也正因爲此,在衡量相關算法的性能時,基本可以忽略對內存的訪問,轉而更多地關注對外存的訪問次數。

2、多路搜索樹

        當數據規模大到內存已不足以容納時,常規平衡二叉搜索樹的效率將大打折扣。因爲查找過程對外存的訪問次數過多。例如,若將10^9個記錄在外存中組織爲AVL樹,則每次查找大致需做30次外存訪問。爲此,需要充分利用磁盤之類外部存儲器的另一特性:就時間成本而言,讀取物理地址連續的一千個字節,與讀取單個字節幾乎沒有區別。既然外部存儲器更適宜於批量式訪問,不妨通過時間成本相對極低的多次內存操作,來代替時間成本相對極高的單次外存操作。相應,需要將通常的二叉搜索樹改造爲多路搜索樹----在中序遍歷意義下也是一種等價變換

圖1.1 二叉搜索樹與四路搜索樹

        如圖1.1所示,比如可以兩層爲間隔,將各節點與其左、右孩子合併爲“大節點”,改造後的每個“大節點“擁有四個分支,故稱作四路搜索樹。這一策略還可進一步推廣,比如以三層爲間隔合併,進而得到八路搜索樹。一般地,以k層爲間隔如此重組,可將二叉搜索樹轉化爲等價的2^k路搜索樹,統稱多路搜索樹。不難驗證,多路搜索樹同樣支持查找等操作,且效果與原二叉搜索樹完全等同;然而重要的是,其對外存的訪問方式已發生本質變化。實際上,在此時的搜索每下降一層,都以”大節點“爲單位從外存讀取一組(而不再是單個)關鍵碼。更爲重要的是,這組關鍵碼在邏輯上與物理上都彼此相鄰,故可以批量方式從外存一次性讀出,且所需時間與讀取單個關鍵碼幾乎一樣。每組關鍵碼的最佳數目,取決於不同外存的批量訪問特性。比如旋轉式磁盤的讀寫操作多以扇區爲單位,故可根據扇區的容量和關鍵碼的大小,經換算得出每組關鍵碼的最佳規模。

二、概念

        B樹英文原文爲B-tree,也譯爲B-樹。所謂m階B樹,即爲m路平衡搜索樹(m>=2),其宏觀結構如圖2.1所示。

圖2.1 B樹的宏觀結構(外部節點爲深色,深度完全一致,且都同處於最底層)
  1. 所有外部節點均深度相等。

  2. 每個內部節點都存有不超過m-1個關鍵碼,以及用以指示對應分支的不超過m個引用。具體地,存有n<=m-1個關鍵碼:K1<K2<K3<K4< ...<Kn的內部節點,同時還配有n+1<=m個引用:A0<A1<A2<A3<A4<...<An。(也即每個節點至多有m棵子樹。)

  3. 在非空B樹中,根節點應滿足:n+1 >= 2。(即非空B樹根節點至少有兩棵子樹。)

  4. 除根節點以外的所有內部節點,都應滿足: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個分支。

圖2.2 B樹最緊湊表示(簡化分支並省略外部節點)

三、實現

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所示。

圖3.1 B樹的查找過程

代碼實現:

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˩,則它們依次爲:{k_{0},...,k_{s-1}k_{s}k_{s+1};...,k_{m-1}

可見,以k_{s}爲界,可將該節點分前後兩個子節點,且二者大致等長。於是,可令關鍵碼k_{s}上升一層,歸入其父節點(若存在)中的適當位置,並分別以這兩個子節點作爲其左、右孩子。這一過程,稱作節點的分裂(split)。不難驗證,如此分裂所得的兩個孩子節點,均符合m階B樹關於節點分支數的條件。被提升的關鍵碼可能有三種進一步的處置方式:

  1. 原上溢節點的父節點存在,且足以接納一個關鍵碼,則被提升的關鍵碼直接插入父節點;
  2. 原上溢節點的父節點存在,但已處於飽和狀態,提升後會使父節點繼而發生上溢(上溢的向上傳遞),上溢的傳遞最遠至樹根;
  3. 若上溢果真傳遞至根節點,則可令被提升的關鍵碼自成一個節點,並作爲新的樹根。至此上溢修復完畢,全樹增高一層。

代碼實現:

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個分支。根據其左、右兄弟所含關鍵碼個數,分三種情況:

  1. V的左兄弟L存在,且至少包含Γm/2˥個關鍵碼:做如圖3.2所示調整;
    圖3.2 下溢節點向父親”借“一個關鍵碼,父親再向左兄弟”借“一個關鍵碼
  2. V的右兄弟R存在,且至少包含Γm/2˥個關鍵碼:做如圖3.3所示調整;
    圖3.3 下溢節點向父親”借“一個關鍵碼,父親再向右兄弟”借“一個關鍵碼
  3. V的左、右兄弟L和R或者不存在,或者其包含的關鍵碼均不足 Γm/2˥個:

        實際上,此時的L和R不可能同時不存在。不失一般性地假設左兄弟節點L存在,此時節點L應恰好包含Γm/2˥-1個關鍵碼,做如圖3.4所示調整。

圖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(log_{m}N)個節點,相應需做O(log_{m}N)次外存讀取操作,因此耗時不超過O(log_{m}N)。儘管沒有漸進意義上的改進,但相對而言及其耗時的I/O操作的次數,卻已大致縮減爲原先的1/log_{2}m。鑑於m通常取值在256至1024之間,較之此前大致降低一個數量級,實際的訪問效率將有十分可觀的提高。而對於插入和刪除操作,時間通常主要消耗於對目標關鍵碼的查找,因此都可在O(log_{m}N)時間內完成。

五、拓展

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樹相同,但在關鍵碼的內部安排上有所不同。具體如下:

  1. 具有m棵子樹的節點含有m個關鍵碼,即每一個關鍵碼對應一棵子樹;
  2. 關鍵碼Ki是它所對應的子樹的根節點中的最大(或最小)關鍵碼;
  3. 所有的葉節點中包含了全部關鍵碼信息,及指向關鍵碼記錄的指針;
  4. 各葉節點按關鍵碼的大小次序鏈在一起,形成單鏈表,並設置頭指針。

B+樹的優勢在於:

  1. 單一節點存儲更多的元素,使得查詢的IO次數更少;
  2. 所有查詢都要查找到葉子節點,查詢性能穩定;
  3. 所有葉子節點形成有序鏈表,便於範圍查詢。

3、紅黑樹

 

箴言錄

        知人者智,自知者明。勝人者有力,自勝者強。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章