B樹簡介node
B樹,是爲磁盤或其餘直接存取輔助存儲設備二設計的一種平衡查找樹,因爲它的特殊結構,能夠大大減小訪問磁盤I/O的次數,所以在數據庫系統常使用B數或B樹的變形來存儲信息。算法
B樹知足某種條件,與紅黑樹或其餘搜索樹不一樣,一棵M(M>2)的B樹,是一棵M路的平衡搜索樹,它容許有多條分支子樹,它能夠是一條空樹,或者知足如下性質:數據庫
一、根節點至少有兩個孩子數組
二、每一個非根節點有[ M/2,M ]個孩子數據庫設計
三、每一個非根節點有[ (M/2) -1,M-1 ]個關鍵字,而且以升序排序ide
四、key[i]和key[i+1]之間的孩子節點的值介於二者之間函數
五、全部的葉子節點都在同一層測試
B樹是一棵向上生長的樹,當一個節點中的關鍵字個數達到上限以後,會進行分裂,同時會向上產生一個新的節點,分裂獲得兩個子節點和一個父節點,父節點只有原來節點中的中間key值,兩個子節點將平分原來節點中剩下的key和孩子。這些緣由使得B樹知足上述條件2~5。接下來看張圖,理解一下B樹是如何生長的。 沒有徹底看明白先放下,這裏只須要知道B樹是一棵多路的平衡搜索樹。在樹不爲空樹的前提下,若是M=2,那麼全部節點內最多會有M-1個關鍵字key值,每一個節點都會有M個孩子。在一個節點內,關鍵字是從小到大排列的,關鍵字和孩子是插空分佈的,這也保證了B樹的平衡搜索性。spa
接下來經過B樹的基本算法來了解一下樹。
設計
B樹算法
關鍵字:分裂算法、插入算法、查找算法、中序遍歷算法
首先,根據上述B樹的要求,這裏給出一張B樹的示意圖(M=3)
上面說過,M表示的是每一個結點孩子的個數,可是很明顯,在上圖中,孩子給出了4個,那麼對應的關鍵字會有3個,和一開始的理論不相符。這裏須要說明一下,由於每次向B樹中插入節點以後,會進行判斷,該節點的關鍵字個數是否超過了M,若是超過,咱們須要進行分裂算法(後面會提到)。
通過簡單分析,這裏給出B樹的節點的定義及構造函數。
template <typename K,int M> struct BTreeNode { K _key[M];//關鍵字數組 BTreeNode<K,M>* _sub[M+1];//指向孩子節點的指針數組 BTreeNode<K,M>* _parent;//指向父節點的指針 size_t _size; //該節點中已經插入的關鍵字的個數 BTreeNode() :_parent(NULL) ,_size(0) { size_t i = 0; for(i = 0; i < M; i++) { _key[i] = K(); _sub[i] = NULL; } _sub[i] = NULL; } }
第一步:查找算法 Find()
爲何這裏要先來實現B樹的查找呢?由於對一棵樹的查找來講,並不會影響到樹的結構,另外,經過查找,也能夠幫助咱們獲得一些其餘的更有利的信息,方便其餘功能的實現。
以上面給出的B樹爲例,在B樹中查找一個結點,和普通的平衡樹基本思路同樣,比該點的key大就向右查找,比該點的key值小,就向左查找。只不過對於B樹而言,每一個節點有M-1個關鍵字。所以在向下查找的同時,須要對每一個節點中的每一個key進行比較。
因爲每一個節點這裏有M個關鍵字,下標從0~M-1,每一個節點有M+1個孩子,指針數組的下標從0~M,仔細觀察上樹,對於某個節點node而言,比節點中的某個key小的一個值,下一次查找的孩子應該和該key的下標相同。
還須要注意的一點,就是我這裏的Find函數是但願可以被其餘函數使用的,不只僅是但願獲得一個bool值或找到的Node*,在這裏設計Find函數,是但願當找到該key值的話返回key所在節點的下標,同時返回一個指向該節點的指針;沒有找到返回 -1,同時返回該節點應該所在位置的父節點。初衷很簡單,是爲了給待會須要實現的Insert函數調用,達到代碼的複用性。若是咱們只是判斷該節點在不在B樹內,那對返回值咱們就只須要關注bool便可。要實現返回兩個參數,有兩種思路:第一就是經過函數傳參數的方式,傳遞引用達到目的,第二就是使用pair類型。
Pair是庫中定義好的一個雙變量結構體,這裏給出庫中pair的實現
template<class _Ty1,class _Ty2> struct pair {// store a pair of values typedef _Ty1 first_type; typedef _Ty2 second_type; }
下面是Find函數的實現代碼:
typedef pair<Node*, int> FindType; FindType Find(const K& key) { Node* parent = NULL; Node* cur = _root; while(cur) { size_t i =0; while(i < cur->_size) { if(key > cur->_key[i]) { i++; } else if(key < cur->_key[i]) { break; } else { return FindType(cur, i); } } parent = cur; cur = cur->_sub[i]; } return FindType(parent,-1); }
第二步:插入算法 Insert()與分裂
插入算法應該是比較複雜的了。
咱們先考慮這樣一個問題,當插入一個元素以後(樹不爲空樹),應該會有兩種狀況,一種是該節點中關鍵字的個數並無超過或等於M,這個時候徹底不須要調整,能夠直接結束。另外一種狀況,也就是咱們須要考慮的,當插入一個關鍵字以後,該節點的key滿了,這時候,就須要用到分裂算法。
咱們來考慮,在下圖中的B樹中插入56,會發生哪些事。
首先,咱們應該先找到56應該插入的位置。這裏Find()函數就能夠幫得上忙。若是Find查找到了該key,就不須要再插入,若是沒有找到,返回最終找到空節點的父節點,直接在該節點中插入便可。在上圖中,用Find()函數查找56,返回的指針應該是指向右下角的結點,接下來開始插入節點。
須要注意的是,這裏把56插入以後,還作了件其餘的事,57的左右孩子也跟着向右移動,由於它的左右孩子都是空結點,所以這裏並無直接畫出來。
接下來的任務就是開始分裂。
對B樹的分裂,其實是將關鍵字超出M-1的節點的中間關鍵字提取出來,同時將兩側分紅兩個子節點。注意,這裏只是把中間的關鍵字取出來,而後把中間的關鍵字再次插入到它的父節點中,同時將分裂產生的的新節點鏈接到父節點上。鏈接到父節點上的位置,與向父節點中插入新的關鍵字的位置有關,如圖:
調整以後若是發現,父節點的關鍵字個數又超出了範圍,如上圖,則再向上分裂增加,直到某一次插入以後,關鍵字的個數不超過M-1,則中止分裂並返回,或者某次分裂到根節點以後,對根節點特殊處理,以後直接結束程序。這就是分裂算法。
多注意一點的是,咱們第二次插入的過程當中,插入了key值,同時將分裂產生的節點也鏈接到了父節點上,所以,這裏對插入key的過程作了一次封裝,實現以下:
void InsertKey(Node* node, const K& key, Node* sub) { size_t index = node->_size-1; // 比key小的關鍵字連帶孩子節點同時向後移動 while (index >= 0) { if (node->_key[index] > key) { //向後移動 node->_key[index + 1] = node->_key[index]; node->_sub[index + 2] = node->_sub[index + 1]; } else // (node->_key[index] < key) { break; } --index; } // 將key插入到node結點當中 node->_key[index + 1] = key; // 將分裂產生的結點鏈接在node節點上 node->_sub[index + 2] = sub; if (sub != NULL) sub->_parent = node; // 對node的size調整 node->_size++; }
下面是插入節點實現代碼:
bool Insert(const K& key) { // 樹是空樹 if (_root == NULL) { _root = new Node; _root->_key[0] = key; _root->_parent = NULL; _root->_size = 1; return true; } // 在樹中Find該結點 FindNode ret = Find(key); if (ret.second != -1) // 樹中找到該節點 return false; Node* cur = ret.first; Node* parent = cur->_parent; Node* sub = NULL; int newkey = key; while (1) { //在 cur 節點裏面插入key、sub //若是cur沒滿,跳出循環 //cur->key滿了,向上分裂 InsertKey(cur, newkey, sub); if (cur->_size < M) return true; //開始分裂 size_t mid = cur->_size / 2; newkey = cur->_key[mid]; // 獲取下一次要插入的值 Node* tmp = new Node; size_t j = 0; size_t i = 0; size_t sz = cur->_size; for (i = mid + 1; i < sz; i++) { tmp->_key[j] = cur->_key[i]; tmp->_sub[j] = cur->_sub[i]; //注意子節點的父指針 if (tmp->_sub[j]) tmp->_sub[j]->_parent = tmp; j++; tmp->_size++; // 調整size cur->_size--; cur->_key[i] = K(); // 將cur分裂出去的部分恢復默認值 cur->_sub[i] = NULL; } tmp->_sub[j] = cur->_sub[i]; //注意子節點的父指針 if (tmp->_sub[j]) tmp->_sub[j]->_parent = tmp; cur->_sub[i] = NULL; // 清空原來的key[mid]結點 cur->_key[mid] = K(); cur->_size--; //根節點 if (parent == NULL) { _root = new Node; _root->_key[0] = newkey; _root->_size = 1; _root->_sub[0] = cur; _root->_sub[1] = tmp; cur->_parent = _root; tmp->_parent = _root; return true; } //非根節點 cur = parent; parent = parent->_parent; sub = tmp; } return true; }
要實現插入算法,就是要經過分裂實現,經過判斷結點關鍵字的個數,決定是否分裂,分裂就是以中間的關鍵字爲斷點,一分爲二,提出中間關鍵字繼續向上插入。兩個分節點鏈接到上一層結點。
第三步:中序遍歷算法
之因此要實現中序遍歷,是由於對於一棵平衡搜索樹而言,中序遍歷的結果是有序的,中序遍歷採用遞歸實現並不難,但要注意的一個問題是對每一個key進行訪問的同時,咱們不能再對兩個孩子進行遞歸訪問,由於這會對中間的孩子訪問兩次。以下圖:
對中間的結點訪問了兩次,所以在普通二叉搜索樹上除了要增長對每一個結點中key的訪問,也要禁止對左右子樹都遍歷,因而有以下實現代碼:
// 實現代碼 1 void _InOrder(Node* root) { if (root == NULL) return; size_t i = 0; for (i = 0; i < root->_size; i++) { _InOrder(root->_sub[i]); cout << root->_key[i] << " "; } _InOrder(root->_sub[i]); }
// 實現代碼 2 void _InOrder(Node* root) { if (root == NULL) return; for (size_t i = 0; i < root->_size; i++) { _InOrder(root->_sub[i]); cout << root->_key[i] << " "; //遍歷過程當中存在衝突,由於存在兩個指針指向一個結點的狀況 //解決方案:只打印前一半,到最後一個key的時候再打印後一半 if (i == root->_size-1) _InOrder(root->_sub[i + 1]); } }
關於測試用例,最直接的就是直接插入1到20,通過測試,1~20 依次插入,包含了全部狀況,若是中序遍歷能夠有序輸出,那麼代表B樹的實現基本已經能夠知足要求。
B樹及B樹的變形,都是減小爲了對磁盤的操做,上面看到當咱們插入多個節點,它會進行屢次的分裂,但當咱們把M放到很大,那麼它的高度就會成M的指數降低。
當 M=1024 的時候,三層能夠容納10億個結點,換句話說,10億結點咱們只須要查找三次,對於每一個節點中的key值,由於是有序的,採用二分查找不過10次,所以,在查找速度上是很是快的,也就減小了訪問磁盤的次數。
對B樹的應用,主要都體如今B樹的變形上的應用,這也是大多數數據庫設計的底層實現。