1、B树的引入
通过之前的学习,我们了解了一些常见的搜索结构,比如二分查找、二叉搜索树、AVL树、红黑树、哈希表等等,但是以上结构适合用于数据量相对不是很大,能够一次性放到内存中进行查找的场景。如果数据量很大,无法一次性放到内存中,那就只能放到磁盘上了。
如果放到磁盘上,需要搜索某些数据,该如何处理呢?我们可以考虑将存放关键字及其映射的数据地址放到一个内存中的搜索树节点中,那么要访问数据时,先取出这个地址,再去磁盘中访问数据,也就是要查找高度次的磁盘IO,那么使用哪种搜索结构合适呢?
如果使用平衡二叉树(AVL树、红黑树)的话,高度是logN,这个查找次数在内存中是很快的,但是当数据都在磁盘中时,访问磁盘的速度会很慢。使用哈希表是否可以呢?答案也是不行,虽然哈希表的效率为O(1),但是一些极端场景下某个位置的冲突会很多,导致访问次数剧增。
所以这里就需要一种新的结构,既然二叉平衡树效率较低,那么就在此基础上进行优化:1、降低树的高度,二叉变多叉,让每一层存更多的节点。2、一个节点里面存放多个关键字及映射的值。
2、B树的概念
1970年,R.Bayer提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(有的地方也写作B-树)。一棵m阶(m > 2)的B树,满足以下性质:
1、根结点至少有两个孩子
2、每个分支节点都包含k-1个关键字和k个孩子
3、每个叶子结点都包含k-1个关键字
4、所有叶子结点都在同一层
5、每个节点中的关键字从小到大排列
6、每个节点的结构为:(n,A0,K1,A1,K2,A2,...,Kn,An)其中,Ki为关键字,Ai为指向子树根结点的指针,n为节点中关键字的个数。
3、B树的实现
cpp
#pragma once
template<class K, size_t M>
struct BTreeNode
{
//K _keys[M - 1];
//BTreeNode<K, M>* _subs[M];
// 为了方便插入之后再分裂,多给一个空间
K _keys[M];
BTreeNode<K, M>* _subs[M + 1];
BTreeNode<K, M>* _parent;
size_t _n; // 记录实际存储的关键字
BTreeNode()
{
for (size_t i = 0;i < M;i++)
{
_keys[i] = K();
_subs[i] = nullptr;
}
_subs[M] = nullptr;
_parent = nullptr;
_n = 0;
}
};
// 数据是存在磁盘,K是磁盘地址
template<class K, size_t M>
class BTree
{
typedef BTreeNode<K, M> Node;
public:
pair<Node*, int> Find(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
size_t i = 0;
// 在一个节点中查找
while (i < cur->_n)
{
if (key < cur->_keys[i])
{
break;
}
else if (key > cur->_keys[i])
{
++i;
}
else
{
return make_pair(cur, i);
}
}
// 往孩子去跳
// _keys[i]的左孩子,左孩子和它下标相等
parent = cur;
cur = cur->_subs[i];
}
return make_pair(parent, -1);
}
void InsertKey(Node* node, const K& key, Node* child)
{
int end = node->_n - 1;
while (end >= 0)
{
if (key < node->_keys[end])
{
// 挪动key和它的右孩子
node->_keys[end + 1] = node->_keys[end];
node->_subs[end + 2] = node->_subs[end + 1];
--end;
}
else
{
break;
}
}
node->_keys[end + 1] = key;
node->_subs[end + 2] = child;
if (child)
{
child->_parent = node;
}
node->_n++;
}
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
++_root->_n;
return true;
}
// key已经存在,就不插入
pair<Node*, int> ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,find顺便带回了要插入的那个叶子结点
// 循环每次往cur插入newKey和child
Node* cur = ret.first;
K newKey = key;
Node* child = nullptr;
while (1)
{
InsertKey(cur, newKey, child);
// 满了就要分裂
// 没有满,插入就结束
if (cur->_n < M)
{
return true;
}
else
{
size_t mid = M / 2;
// 分裂一半[mid+1, M-1]给兄弟
Node* brother = new Node;
size_t j = 0;
size_t i = mid + 1;
for (;i <= M - 1;i++)
{
// 分裂拷贝key和key的左孩子
brother->_keys[j] = cur->_keys[i];
brother->_subs[j] = cur->_subs[i];
if (cur->_subs[i])
{
cur->_subs[i]->_parent = brother;
}
j++;
// 拷走重置一下,方便观察
cur->_keys[i] = K();
cur->_subs[i] = nullptr;
}
// 还有最后一个右孩子
brother->_subs[j] = cur->_subs[i];
if (cur->_subs[i])
{
cur->_subs[i]->_parent = brother;
}
cur->_subs[i] = nullptr;
brother->_n = j;
cur->_n -= (j + 1);
K midKey = cur->_keys[mid];
cur->_keys[mid] = K();
// 说明刚刚分裂的是根结点
if (cur->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = midKey;
_root->_subs[0] = cur;
_root->_subs[1] = brother;
_root->_n = 1;
cur->_parent = _root;
brother->_parent = _root;
break;
}
else
{
// 转换成往cur->_parent去插入cur->_keys[mid]和brother
newKey = midKey;
child = brother;
cur = cur->_parent;
}
}
}
return true;
}
void _InOrder(Node* cur)
{
if (cur == nullptr)
return;
size_t i = 0;
for (;i < cur->_n;++i)
{
_InOrder(cur->_subs[i]); // 左子树
cout << cur->_keys[i] << " "; // 根
}
_InOrder(cur->_subs[i]); // 最后的那个右子树
}
void InOrder()
{
_InOrder(_root);
}
private:
Node* _root = nullptr;
};
4、B+树和B*树
4.1 B+树
B+树是B树的变形,是在B树基础上优化的多路平衡搜索树。B+树的规则和B树基本类似,但是又在B树的基础上进行了以下几点改进优化:
1、分支节点的子树指针与关键字个数相同
2、分支结点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])之间
3、所有叶子结点增加一个链接指针链接在一起
4、所有关键字及其映射数据都在叶子结点出现

B+树的插入过程和B树基本类似,区别在于B+树第一次插入两层节点,一层做分支,一层做根。后面一样往叶子中去插入,插入满了以后分类一般半给兄弟,转换成往父亲中插入一个key和一个孩子,孩子就是兄弟,key是兄弟的第一个最小值的key。
4.2 B*树
B*树是B+树的变形,在B+树的非根和非叶子结点再增加指向兄弟节点的指针。

当一个节点满了,如果它的下一个兄弟节点未满,那么将一部分数据转移到兄弟结点中。如果兄弟节点也满了,那么在原结点与兄弟节点之间增加新节点,并各复制1/3的数据到新节点。所以,B*树的空间使用率更高。