本文首發於我的公眾號 Linux雲計算網絡(id: cloud_dev) ,專注於干貨分享,號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,歡迎大家關注,二維碼文末可以掃。
一、高級數據結構
本章以后到第21章(並查集)隸屬於高級數據結構的內容。前面還留了兩章:貪心算法和攤還分析,打算后面再來補充。之前的章節討論的支持動態數據集上的操作,如查找、插入、刪除等都是基於簡單的線性表、鏈表和樹等結構,本章以后的部分在原來更高的層次上來討論這些操作,更高的層次意味着更復雜的結構,但更低的時間復雜度(包括攤還時間)。
- B樹是為磁盤存儲還專門設計的平衡查找樹。因為磁盤操作的速度要遠遠慢於內存,所以度量B樹的性能,不僅要考慮動態集合操作消耗了多少計算時間,還要考慮這些操作執行了多少次磁盤存儲。因此,B樹被設計成盡量減少磁盤訪問的次數。知道了這一點,就會明白B樹的變形B+樹了,B+樹通過將數據存儲在葉子節點從而增大了一個節點所包含的信息,進而更加減少了磁盤的訪問次數。
- 可合並堆:支持make-heap, insert, minimum, extract-min, union這5種操作。在堆排序章節討論過二叉堆,除了union操作,二叉堆的性能都很好。該部分討論的二項堆和斐波那契堆對union操作能夠獲得很好的性能,此外,對於其他操作,也能獲得較好的改進。
- 該部分提出一種數據結構:van Emde Boas樹,當關鍵字在有限范圍內的整數時,進一步改進了動態集合操作的性能,可以在O(lglgu)時間內完成。
- 不相交集合(並查集):通過一棵簡單的有根樹來表示每個集合,就可以得到驚人的快速操作:一個由m個操作構成的序列的運行時間為O(n&(n)),而對於宇宙中的原子數總和n,&(n)也<=4,所以可以認為實際時間是O(n)。
二、B樹
從歷史演進上來看,B樹是在2-3樹的基礎上演變而來,2-3樹是一種類型的平衡查找樹,AVL樹的平衡條件是“保證任意節點的左右子樹的高度差不超過1”,而紅黑樹則是“通過對節點着不同的顏色來約束平衡”,2-3樹則是“通過約束內部節點的度來達到平衡”:分為普通兩個度的節點和三個度的節點,故名為2-3樹,如下圖所示:
更深一步,從實現原理上看,紅黑樹是2-3樹的一種簡單實現,原因在於2-3樹在編碼實現上比較復雜,且失去二叉樹的特性,不易被人接受和理解。如果稍加對2-3樹做一點轉換,就可以變為二叉樹,做法是:用兩種連線來區分度為3和度為2的節點,比如用紅色的線來連接度為3的節點,黑色的線連接普通的節點,以這種方法,即可將2-3樹轉化為紅黑樹。如下:
2-3樹是將內部節點賦予2-3度來達到平衡,那更一般地,自然想到為內部節點賦予更大大小的度,進而減小了樹的高度,應對更多不同的場景。從這個層面上看,B樹是在前人的基礎上應運而生的一種樹結構。
從應用場景來看,在一些大規模的數據存儲中,如數據庫,分布式系統等,實現索引查詢這樣一個實際背景下,數據的訪問經常需要進行磁盤的讀寫操作,這個時候的瓶頸主要就在於磁盤的I/O上。如果采用普通的二叉查找樹結構,由於樹的深度過大,會造成磁盤I/O的讀寫過於頻繁,進而導致訪問效率低下(一般樹的一個節點對應一個磁盤的頁面,讀取一次磁盤頁相當於訪問無數次內存)。那么,如何減少樹的深度,一個基本的、很自然的想法就是:采用多叉樹結構。節點的分支因子越大(可以理解成節點的孩子節點數),樹的高度也就越低,從而查詢的效率也就越高。從這個意義上來看,就有了B樹的結構。
前面提到過,在大多數系統中,B樹算法的運行時間主要由它所執行的disk-read和disk-write操作的次數所決定的,其余時間在內存中計算,速度不在一個量級。因此,應該有效地使用這兩種操作,即讓它們讀取更多的信息以更少的次數。由於這個原因,在B樹中,一個節點的大小通常相當於一個完整的磁盤頁。因此,一個B樹節點可以擁有的孩子數就由磁盤頁的大小決定。理論上說,孩子數越多越好,因為這樣樹的高度會減少,查詢效率會增加,但要保證一個節點的總大小不能大於磁盤中的一個頁的大小,否則在一個節點內操作時還要來回訪問內存,反而拖慢效率。
三、B樹的定義及動態集合操作
一棵B樹具有以下的性質:
1)每個節點x有三個屬性:
a、x.n—>關鍵字個數
b、關鍵字遞增排序
c、x.leaf—>節點是否屬於葉子節點
2)每個節點有x.n+1個孩子節點
3)每個節點關鍵字 > 其左孩子節點 < 其右孩子節點
4)每個葉子節點具有相同的深度,即樹的高度h。
5)每個節點用最小度數 t 來表示其關鍵字個數的上下界,或者孩子節點(分支因子)的個數的上下界。一般,每個非根節點中所包含的關鍵字個數 j 滿足:
t-1 <= j <= 2*t - 1
根節點至少包括一個關鍵字,若非葉子節點,則至少兩個分子,即 t>= 2。
與紅黑樹相比,雖然兩者的高度都以 O(lgn)的速度增長,但對於 B 樹來說底要大很多倍。對大多數的樹的操作來說,要查找的結點數在 B 樹中要比紅黑樹中少大約 lgt 的因子。因為在樹中查找任意一個結點通常需要一次磁盤存取,所以磁盤存取的次數大大的減少了。
以下代碼表示B樹中的一個節點:
1 /// B樹中的一個結點 2 struct BTreeNode 3 { 4 vector<int> Keys; 5 vector<BTreeNode *> Childs; 6 BTreeNode *Parent;///< 父結點。當該結點是樹的根結點時,Parent結點為nullptr 7 bool IsLeaf; ///< 是否為葉子結點 8 9 BTreeNode() : Parent( nullptr ), IsLeaf( true ) {} 10 11 size_t KeysSize() 12 { 13 return Keys.size(); 14 } 15 };
關於B樹的動態集合操作,就不一一述說了,《算法導論》書已經講得非常清楚了,而且圖文並茂,照着認真看,絕對是沒問題的。下面是實現的代碼:

#ifndef _B_TREE_H_ #define _B_TREE_H_ #include <iostream> #include <algorithm> #include <vector> #include <string> #include <sstream> #include <cassert> using namespace std; class BTree { public: /// B樹中的一個結點 struct BTreeNode { vector<int> Keys; vector<BTreeNode *> Childs; BTreeNode *Parent; ///< 父結點。當該結點是樹的根結點時,Parent結點為nullptr bool IsLeaf; ///< 是否為葉子結點 BTreeNode() : Parent( nullptr ), IsLeaf( true ) {} size_t KeysSize() { return Keys.size(); } }; /// 構造一棵最小度為t的B樹(t>=2) BTree( int t ) : _root( nullptr ), _t( t ) { assert( t >= 2 ); } ~BTree() { _ReleaseNode( _root ); } /// @brief B樹的查找操作 /// /// 在B-樹中查找給定關鍵字的方法類似於二叉排序樹上的查找。 /// 不同的是在每個結點上確定向下查找的路徑不一定是二路而是keynum+1路的。\n /// 實現起來還是相當容易的! pair<BTreeNode *, size_t> Search( int key ) { return _SearchInNode( _root, key ); } /// @brief 插入一個值的操作 /// /// 這里沒有使用《算法導論》里介紹的一趟的方法,而是自己想象出來的二趟的方法 /// 效率肯定不如書上介紹的一趟優美,但是能解決問題。\n /// 因為插入操作肯定是在葉子結點上進行的,首先順着書向下走直到要進行插入操作的葉子結點將新值插入到該葉子結點中去. /// 如果因為這個插入操作而使用該結點的值的個數>2*t-1的上界,就需要遞歸向上進行分裂操作。 /// 如果分裂到了根結點,還要處理樹長高的情況。\n bool Insert( int new_key ) { if ( _root == nullptr ) //空樹 { _root = new BTreeNode(); _root->IsLeaf = true; _root->Keys.push_back( new_key ); return true; } if ( Search( new_key ).first == nullptr ) //是否已經存在該結點 { BTreeNode *node = _root; while ( !node->IsLeaf ) { int index = 0; while ( index < node->Keys.size() && new_key >= node->Keys[index] ) { ++index; } node = node->Childs[index]; } //插入到Keys里去 node->Keys.insert( find_if( node->Keys.begin(), node->Keys.end(), bind2nd( greater<int>(), new_key ) ), new_key ); //再遞歸向上處理結點太大的情況 while ( node->KeysSize() > 2 * _t - 1 ) { //=====開始分裂====== int prove_node_key = node->Keys[node->KeysSize() / 2 - 1]; // 要提升的結點的key //后半部分成為一個新節點 BTreeNode *new_node = new BTreeNode(); new_node->IsLeaf = node->IsLeaf; new_node->Keys.insert( new_node->Keys.begin(), node->Keys.begin() + node->KeysSize() / 2, node->Keys.end() ); new_node->Childs.insert( new_node->Childs.begin(), node->Childs.begin() + node->Childs.size() / 2, node->Childs.end() ); assert( new_node->Childs.empty() || new_node->Childs.size() == new_node->Keys.size() + 1 ); for_each( new_node->Childs.begin(), new_node->Childs.end(), [&]( BTreeNode * c ) { c->Parent = new_node; } ); //把后半部分從原來的節點中刪除 node->Keys.erase( node->Keys.begin() + node->KeysSize() / 2 - 1, node->Keys.end() ); node->Childs.erase( node->Childs.begin() + node->Childs.size() / 2, node->Childs.end() ); assert( node->Childs.empty() || node->Childs.size() == node->Keys.size() + 1 ); BTreeNode *parent_node = node->Parent; if ( parent_node == nullptr ) //分裂到了根結點,樹要長高了,需要NEW一個結點出來 { parent_node = new BTreeNode(); parent_node->IsLeaf = false; parent_node->Childs.push_back( node ); _root = parent_node; } node->Parent = new_node->Parent = parent_node; auto insert_pos = find_if( parent_node->Keys.begin(), parent_node->Keys.end(), bind2nd( greater<int>(), prove_node_key ) ) - parent_node->Keys.begin(); parent_node->Keys.insert( parent_node->Keys.begin() + insert_pos, prove_node_key ); parent_node->Childs.insert( parent_node->Childs.begin() + insert_pos + 1, new_node ); node = parent_node; } return true; } return false; } /// @brief 刪除一個結點的操作 bool Delete( int key_to_del ) { auto found_node = Search( key_to_del ); if ( found_node.first == nullptr ) //找不到值為key_to_del的結點 { return false; } if ( !found_node.first->IsLeaf ) //當要刪除的結點不是葉子結點時用它的前驅來替換,再刪除它的前驅 { //前驅 BTreeNode *previous_node = found_node.first->Childs[found_node.second]; while ( !previous_node->IsLeaf ) { previous_node = previous_node->Childs[previous_node->Childs.size() - 1]; } //替換 found_node.first->Keys[found_node.second] = previous_node->Keys[previous_node->Keys.size() - 1]; found_node.first = previous_node; found_node.second = previous_node->Keys.size() - 1; } //到這里,found_node一定是葉子結點 assert( found_node.first->IsLeaf ); _DeleteLeafNode( found_node.first, found_node.second ); return true; } private: void _ReleaseNode( BTreeNode *node ) { for_each( node->Childs.begin(), node->Childs.end(), [&]( BTreeNode * c ) { _ReleaseNode( c ); } ); delete node; } /// @brief 刪除B樹中的一個葉子結點 /// /// @param node 要刪除的葉子結點! /// @param index 要刪除的葉子結點上的第幾個值 /// @note 必須保證傳入的node結點為葉子結點 void _DeleteLeafNode( BTreeNode *node, size_t index ) { assert( node && node->IsLeaf ); if ( node == _root ) { //要刪除的值在根結點上,並且此時根結點也是葉子結點,因為本方法被調用時要保證node參數是葉子結點 _root->Keys.erase( _root->Keys.begin() + index ); if ( _root->Keys.empty() ) { //成為了一棵空B樹 delete _root; _root = nullptr; } return; } //以下是非根結點的情況 if ( node->Keys.size() > _t - 1 ) { //要刪除的結點中Key的數目>t-1,因此再-1也不會打破B樹的性質 node->Keys.erase( node->Keys.begin() + index ); } else //會打破平衡 { //是否借到了一個頂點 bool borrowed = false; //試着從左兄弟借一個結點 BTreeNode *left_brother = _GetLeftBrother( node ); if ( left_brother && left_brother->Keys.size() > _t - 1 ) { int index_in_parent = _GetIndexInParent( left_brother ); BTreeNode *parent = node->Parent; node->Keys.insert( node->Keys.begin(), parent->Keys[index_in_parent] ); parent->Keys[index_in_parent] = left_brother->Keys[left_brother->KeysSize() - 1]; left_brother->Keys.erase( left_brother->Keys.end() - 1 ); ++index; borrowed = true; } else { //當左兄弟借不到時,試着從右兄弟借一個結點 BTreeNode *right_brother = _GetRightBrother( node ); if ( right_brother && right_brother->Keys.size() > _t - 1 ) { int index_in_parent = _GetIndexInParent( node ); BTreeNode *parent = node->Parent; node->Keys.push_back( parent->Keys[index_in_parent] ); parent->Keys[index_in_parent] = right_brother->Keys[0]; right_brother->Keys.erase( right_brother->Keys.begin() ); borrowed = true; } } if ( borrowed ) { //因為借到了結點,所以可以直接刪除結點 _DeleteLeafNode( node, index ); } else { //左右都借不到時先刪除再合並 node->Keys.erase( node->Keys.begin() + index ); _UnionNodes( node ); } } } /// @brief node找一個相鄰的結點進行合並 /// /// 優先選取左兄弟結點,再次就選擇右兄弟結點 void _UnionNodes( BTreeNode * node ) { if ( node ) { if ( node == _root ) //node是頭結點 { if ( _root->Keys.empty() ) { //頭結點向下移動一級,此時樹的高度-1 _root = _root->Childs[0]; _root->Parent = nullptr; delete node; return; } } else { if ( node->KeysSize() < _t - 1 ) { BTreeNode *left_brother = _GetLeftBrother( node ); if ( left_brother == nullptr ) { left_brother = _GetRightBrother( node ); swap( node, left_brother ); } //與左兄弟進行合並 int index_in_parent = _GetIndexInParent( left_brother ); node->Keys.insert( node->Keys.begin(), node->Parent->Keys[index_in_parent] ); node->Parent->Keys.erase( node->Parent->Keys.begin() + index_in_parent ); node->Parent->Childs.erase( node->Parent->Childs.begin() + index_in_parent + 1 ); left_brother->Keys.insert( left_brother->Keys.end(), node->Keys.begin(), node->Keys.end() ); left_brother->Childs.insert( left_brother->Childs.begin(), node->Childs.begin(), node->Childs.end() ); for_each( left_brother->Childs.begin(), left_brother->Childs.end(), [&]( BTreeNode * c ) { c->Parent = left_brother; } ); delete node; _UnionNodes( left_brother->Parent ); } } } } pair<BTreeNode *, size_t> _SearchInNode( BTreeNode *node, int key ) { if ( !node ) { //未找到,樹為空的情況 return make_pair( static_cast<BTreeNode *>( nullptr ), 0 ); } else { int index = 0; while ( index < node->Keys.size() && key >= node->Keys[index] ) { if ( key == node->Keys[index] ) { return make_pair( node, index ); } else { ++index; } } if ( node->IsLeaf ) { //已經找到根了,不能再向下了未找到 return make_pair( static_cast<BTreeNode *>( nullptr ), 0 ); } else { return _SearchInNode( node->Childs[index], key ); } } } void _GetDotLanguageViaNodeAndEdge( stringstream &ss, BTreeNode *node ) { if ( node && !node->Keys.empty() ) { int index = 0; ss << " node" << node->Keys[0] << "[label = \""; while ( index < node->Keys.size() ) { ss << "<f" << 2 * index << ">|"; ss << "<f" << 2 * index + 1 << ">" << node->Keys[index] << "|"; ++index; } ss << "<f" << 2 * index << ">\"];" << endl;; if ( !node->IsLeaf ) { for( int i = 0; i < node->Childs.size(); ++i ) { BTreeNode *c = node->Childs[i]; ss << " \"node" << node->Keys[0] << "\":f" << 2 * i << " -> \"node" << c->Keys[0] << "\":f" << ( 2 * c->Keys.size() + 1 ) / 2 << ";" << endl; } } for_each( node->Childs.begin(), node->Childs.end(), [&]( BTreeNode * c ) { _GetDotLanguageViaNodeAndEdge( ss, c ); } ); } } /// 得到一個結點的左兄弟結點,如果不存在左兄弟結點則返回nullptr BTreeNode * _GetLeftBrother( BTreeNode *node ) { if ( node && node->Parent ) { BTreeNode *parent = node->Parent; for ( int i = 1; i < parent->Childs.size(); ++i ) { if ( parent->Childs[i] == node ) { return parent->Childs[i - 1]; } } } return nullptr; } /// 得到一個結點的右兄弟結點,如果不存在右兄弟結點則返回nullptr BTreeNode * _GetRightBrother( BTreeNode *node ) { if ( node && node->Parent ) { BTreeNode *parent = node->Parent; for ( int i = 0; i < static_cast<int>( parent->Childs.size() ) - 1; ++i ) { if ( parent->Childs[i] == node ) { return parent->Childs[i + 1]; } } } return nullptr; } /// 得到一個結點在其父結點中屬於第幾個子結點 /// @return 返回-1時表示錯誤 int _GetIndexInParent( BTreeNode *node ) { assert( node && node->Parent ); for ( int i = 0; i < node->Parent->Childs.size(); ++i ) { if ( node->Parent->Childs[i] == node ) { return i; } } return -1; } BTreeNode *_root; ///< B樹的根結點指針 int _t; ///< B樹的 最小度數。即所有的結點的Keys的個數應該t-1 <= n <= 2t-1,除了根結點可以最少為1個Key }; #endif//_B_TREE_H_
四、B樹的引申——B+樹、B*樹
B+樹是對B樹的一種變形樹,它與B樹的差異在於:
- 有k個子結點的結點必然有k個關鍵碼;
- 非葉結點僅具有索引作用,跟記錄有關的信息均存放在葉結點中。
- 樹的所有葉結點構成一個有序鏈表,可以按照關鍵碼排序的次序遍歷全部記錄。
B樹和B+樹各有優缺點:
- B+樹的磁盤讀寫代價更低:B+樹的內部結點並沒有指向關鍵字具體信息的指針。因此其內部結點相對B 樹更小。如果把所有同一內部結點的關鍵字存放在同一磁盤頁中,那么一頁所能容納的關鍵字數量也越多。一次性讀入內存中的需要查找的關鍵字也就越多。相對來說IO讀寫次數也就降低了。
- 訪問緩存命中率高:其一,B+樹在內部節點上不含數據項,因此關鍵字存放的更加緊密,具有更好的空間局部性。因此訪問葉子節點上關聯的數據項也具有更好的緩存命中率;其二,B+樹的葉子結點都是相鏈的,因此對整棵樹的遍歷只需要一次線性遍歷葉子結點即可。而B樹則需要進行每一層的遞歸遍歷。相鄰的元素可能在內存中不相鄰,所以緩存命中性沒有B+樹好。
- B+樹的查詢效率更加穩定:由於非葉子節點只是充當葉子結點中數據項的索引。所以任何關鍵字的查找必須走一條從根結點到葉子結點的路。所有關鍵字查詢的路徑長度相同,導致每一個數據的查詢效率相當。
當然,B樹也不是因此就沒有優點,由於B樹的每一個節點都包含key和value,因此經常訪問的元素可能離根節點更近,因此訪問也更迅速。
由於B+樹較好的訪問性能,一般,B+樹比B 樹更適合實際應用中操作系統的文件索引和數據庫索引!
B*樹則是在B+樹的基礎上,又新增了一項規定:內部節點新增指向兄弟節點的指針。另外,B*樹定義了非葉子結點關鍵字個數至少為(2/3)*t,即塊的最低使用率為2/3(代替B+樹的1/2);B*樹在分裂節點時,由於可以向空閑較多的兄弟節點進行轉移,因此其空間利用率更高。
我的公眾號 「Linux雲計算網絡」(id: cloud_dev),號內有 10T 書籍和視頻資源,后台回復 「1024」 即可領取,分享的內容包括但不限於 Linux、網絡、雲計算虛擬化、容器Docker、OpenStack、Kubernetes、工具、SDN、OVS、DPDK、Go、Python、C/C++編程技術等內容,歡迎大家關注。