1.什么是B-树
B树,是一棵适用于外查找(硬盘查找)的树。 我们常见的查找树有AVL树、红黑树,但是这些都是用于内查找(内存之中),这些树的查找效率为树的高度,即O(lgN)
如果在外查找中,也是用这种结构,每一层都需要访问一次硬盘,而访问硬盘的速度是比较慢的,为了提高外查找的效率,提出了一种平衡的多叉树
由于二叉搜索树,每多一层,就需要多访问一次硬盘,所以B树就是压缩了高度的二叉搜索树
2.B-树的性质
一棵M阶(M>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足以下性质:
- 根节点关键字的数量[1,M-1],孩子的数量[2,2M]
- 每个非根节点至少有(M/2-1)个关键字,至多有M-1个关键字,并且以升序排列(满了就会分裂)
- 每个非根,非叶子节点至少有M/2个孩子,至多有M个孩子(关键字+1)
- 一个节点中的关键字按照升序排列,孩子的数量比关键字的个数多1
- 所有叶子节点都在同一层(横/上 增长)
- key[i] 和 key[i+1] 之间的孩子节点的值介于key[i]、key[i+1] 之间(二叉树搜索树性质)
3.B-树插入过程
- 为了保证key的数量,比孩子的数量少一个,新插入的值都是插入到叶子节点之中
- 当某个节点满了,进行分裂,新创建一个兄弟节点,拷贝右半区间到兄弟节点,中位数提取到放到父亲处,如果没有父亲则创建新的根,分裂出来的两个节点分别做这个中位数的左右孩子
- 分裂规则中,新节点永远是横向和向上增长,所以叶子节点永远在同一层
假设M=3,需要插4,5,6,7,8,10,36,16,4
4.B-树的效率
-
对于一棵节点为N,度为M的B-树,查找和插入需要log(m-1)N~log(m/2)N 次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为(M/2-1) ~(M-1)之间,因此树的高度应该在要log(m-1)N 和log(m/2)N 之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。
等比为 q= m/2-1 ~m-1 Sn = a(1-q^h)/1-q = N => h = logq N => log(m-1) N ~ log(M/2)N
-
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则 <= 4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。
-
B树的核心是分裂,分裂保证树的平衡和节点空间利用至少接近50%
6.B-树是怎么做到减少与内存交互的次数的
- 我们的外存,比如硬盘,是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。
- 在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。
- 比如说一棵B树的阶为1001(即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点(数据的地址)持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。
- 这就好比我们普通人数钱都是一张一张的数,而银行职员数钱则是五张、十张,甚至几十张一数,速度当然是比常人快了不少。通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据(放在同一层)。由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。
- B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;
7.代码实现
template<class K,class V, size_t M>//K/V值,和阶数M
struct BTreeNode //定义节点
{
//孩子数量比关键字多1
//pait<K, V> _kvl[M - 1];//关键字
//BTreeNode<K, V, M> *_child[M]; //孩子
//多给一个空间,可以先插入,再分裂
pair<K, V> _kvl[M];//关键字
BTreeNode<K, V, M> *_child[M+1]; //孩子数组
BTreeNode<K, V, M> *_parent;//父指针
size_t _kvsize;//记录存储了多少个关键字
BTreeNode()
:_parent(nullptr)
, _kvsize (0)
{
for (int i = 0; i < M + 1; i++)
{
_child[i] = nullptr;//创建一个新节点,孩子节点全部置为空
}
}
};
template<class K, class V, size_t M>
class BTree
{
typedef BTreeNode<K, V, M> Node;
public:
//第i个key的左孩子是child[i]
//右孩子是child[i+1]
pair<Node*, int> Find(const K& key)
{
Node *parent = nullptr;
Node *cur = _root; //从根节点开始搜索
while (cur)
{
size_t i = 0;
//寻找当前节点当中的关键字
while (i < cur->_kvsize) //如果M比较大,换成二分查找
{
if (cur->_kvl[i].first < key)//key大于当前位置,往右边找
i++;
else if (cur->_kvl[i].first>key)//key小于当前位置,往左孩子找
break;
else
return make_pair(cur, i);//找到了,返回即可
}
//来到这里,要么当前节点的所有关键字都小于key,要么当前节点中有大于key的关键字
//即需要继续往孩子节点中进行搜索
parent = cur;//往下一层走之前,记录父亲
//假设key小于所有的关键字,那么去右孩子中进行寻找,此时i++已经来到下一位置 -> child[i]
//假设key大于某个关键字,那么需要去该关键字的左孩子寻找 ,此时直接退出循环,因此正是i位置-> child[i]
cur = cur->_child[i];
}
//没有找到
return make_pair(parent, -1);//返父亲指针
}
//在cur节点之中,插入kv,和孩子节点
void InsertKv(Node* cur,const pair<K, V> &kv,Node* brother)
{
//从后往前寻找
size_t i = cur->_kvsize;
for (i; i > 0; i--)
{
if (kv.first > cur->_kvl[i-1].first)//插入的关键字,大于前面的关键字,说明是插在此位置
{
break;
}
//插入关键字小于前面的位置,则将前面的位置往后挪,同时孩子节点也需要挪动
cur->_kvl[i] = cur->_kvl[i - 1];
cur->_child[i+1] = cur->_child[i];//孩子的下标比关键字大1
}
//填入关键字,和孩子节点
cur->_kvl[i] = kv;
cur->_child[i+1] = brother;
cur->_kvsize++;//关键字数量自增
}
//插入一个关键字
//先找到对应的位置,然后插入
//如果当前key值已经存在则不能插入
//key不存在,插入到叶子节点之中,因此在插入的时候,需要找到叶子节点
bool Insert(const pair<K, V> &kv)
{
if (_root == nullptr)//根节点为空
{
_root = new Node;
_root->_kvl[0] = kv;//填入当前关键字
_root->_kvsize = 1;//关键字++
return true;
}
//根不为空
//根不为空时,先查找,如果找到了,则不被允许插入
pair<Node*, int> ret = Find(kv.first);
if (ret.second >= 0)//表示当前已经存在,不可插入
{
return false;
}
//没有找到,可以进行插入
//此时的ret.first表示可以插入的节点
Node* cur = ret.first;//获取插入的位置
//在cur中,插入值kv,此时不需要填入兄弟节点
InsertKv(cur, kv,nullptr);
//有可能需要进行分裂处理
while (1)
{
if (cur->_kvsize < M)//当前节点没有满,直接返回成功即可
{
return true;
}
else//当前节点满了
{
//分裂出一个兄弟节点
Node *brother = new Node;
//[0,mid-1]位置的关键字留给原来的节点
//[mid+1,M-1]位置的关键字拷贝给兄弟节点
int mid = cur->_kvsize / 2;
//拷贝一半的数据给兄弟节点
size_t sub = 0;
for (int i = mid + 1; i < cur->_kvsize; i++)
{
brother->_kvl[sub++] = cur->_kvl[i];//拷贝关键字
brother->_kvsize++;//关键字总数++
}
//在向上分裂的过程之中,如果有孩子节点,则需要拷贝孩子节点
//原来的保留[0,mid]的孩子
//兄弟节点拷贝[mid+1,size]的孩子
sub = 0;
for (int i = mid + 1; i <= cur->_kvsize; i++)
{
if (cur->_child[i] == nullptr)//没有孩子了
break;
brother->_child[sub++] = cur->_child[i];
cur->_child[i]->_parent = brother;//更新父节点
cur->_child[i] = nullptr;
}
//原来节点留下的关键字数量=原来的-兄弟中的-交付给根节点的
cur->_kvsize = cur->_kvsize - brother->_kvsize - 1;
//将mid处位置的值,放入父节点之中
//1.如果没有父节点,则需要创建根节点
//2.如果有父节点,往父节点中插入mid位置的关键字
if (cur->_parent == nullptr)//没有父节点
{
//创建父节点,并且将关键字填入
_root = new Node;
_root->_kvl[0] = cur->_kvl[mid];
_root->_kvsize++;
//填入孩子节点
_root->_child[0] = cur;
_root->_child[1] = brother;
//填入父节点指针
cur->_parent = _root;
brother->_parent = _root;
return true;
}
else//有父亲节点,再次调用插入即可
{
pair<K, V> kv = cur->_kvl[mid];//获取需要插入的值
cur = cur->_parent;//cur变为父节点
InsertKv(cur, kv,brother);//往父节点中插入kv和孩子节点(当前的brother节点)
brother->_parent = cur;//填入父指针
}
}
}
}
//中序遍历,输出的数是升序的
void _Inorder(Node *root)
{
if (root == nullptr)
return;
size_t i = 0;
while (i < root->_kvsize)
{
_Inorder(root->_child[i]);//先输出左边
cout << root->_kvl[i].first << " ";//再输出当前位置
i++;
}
_Inorder(root->_child[i]);//输出右边
}
void Inorder()
{
_Inorder(_root);
}
private:
Node *_root = nullptr;
};