【高阶数据结构】B树、B+树、B*树
B树、B+树、B*树
- 1. 常见的搜索结构
- 2. B树概念
- 3. B树的插入分析
- 4. B树的插入实现
- 4.1 B树的节点设计
- 4.2 B树的部分插入实现1
- 4.3 B树的查找
- 4.4 B树的部分插入实现2
- 4.5 插入key的过程
- 4.7 B树的插入完整代码
- 4.8 B树的简单验证
- 4.9 B树的删除
- 4.10 B树的性能分析
- 5. B+树
- 6. B*树
- 7. 总结
- 8. B树的应用
- 8.1 索引
- 8.2 MySQL索引简介
- 8.2.1 MyISAM
- 8.2.2 InnoDB
点赞👍👍收藏🌟🌟关注💖💖
你的支持是对我最大的鼓励,我们一起努力吧!😃😃
1. 常见的搜索结构
内查找
种类 | 数据格式 | 时间复杂度 |
---|---|---|
顺序查找 | 无要求 | O(N) |
二分查找 | 有序 | O(logN) |
二叉搜索树 | 无要求 | O(N) |
二叉平衡树(AVL树和红黑树) | 无要求 | O(logN) |
哈希 | 无要求 | O(1) |
外查找 B树系列
以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如何处理呢?那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,找数据时比较关键字,找到关键字也就找到这个数据在磁盘的地址,然后去这个地址去磁盘访问数据。
但是这样还有一些问题,如果关键字不是整型而是字符串,数据量大了在内存中这棵树存可能存不下。那怎么办?
节点可以不存关键字, 只存对应磁盘的地址。这个时候查找就要拿着地址去访问磁盘然后看关键字是否匹配。这个时候还是一样关键字比当前节点关键字大往右走,否则往左走。每一次比较节点都是一次IO。
但是这里的问题是,要走高度次磁盘IO,因为节点里面只有地址要进行关键字比较就要读一次磁盘。这个时候 AVL/红黑树 就不适合了,都是O(logN),虽然在内存中查找比较快,10亿个数字需要30次。但是在磁盘中如果是30次IO,那就很慢了。还有哈希表,虽然查找说是O(1),但是这个O(1)并不是一次而是常数次,更大的问题是极端场景下哈希冲突可能会非常严重,效率会下降很多。即使哈希表挂的是红黑树还是O(logN)。
使用平衡二叉树搜索树的缺陷:
平衡二叉树搜索树的高度是logN,这个查找次数在内存中是很快的。但是当数据都在磁盘中时,访问磁盘速度很慢,在数据量很大时,logN次的磁盘访问,是一个难以接受的结果。
使用哈希表的缺陷:
哈希表的效率很高是O(1),但是一些极端场景下某个位置冲突很多,导致访问次数剧增,也是难以接受的。
那有没有更好的数据结构能够替代上面的东西呢?
B树系列
平衡搜索树基础上找优化空间:
B树的思路是这样的,之前AVL/红黑树 存10亿个数据大概需要30层,能不能把高度压缩一下,从30层压缩到几层。如何压呢?很简单是不是让单层存更多是不是进行压缩了。如何让单层存更多呢?
- 压缩高度,二叉变多叉
注意到一个节点就一个地址,一个地址对应一行数据,那数据多了地址也很多。地址很多内存也是有限的,怎么办?
- 一个节点存有多个关键字及映射的值
2. B树概念
1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(后面有一个B的改进版本B+树,然后有些地方的B树写的的是B-树,注意不要误读成"B减树")。一棵m阶(m>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足一下性质:
- 根节点至少有两个孩子
- 每个分支节点都包含k-1个关键字和k个孩子,其中 ceil(m/2) ≤ k ≤ m ceil是向上取整函数(分支节点,孩子比关键字保持多一个的关系)
- 每个叶子节点都包含k-1个关键字,其中 ceil(m/2) ≤ k ≤ m
- 所有的叶子节点都在同一层
- 每个节点中的关键字从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域划分
- 每个结点的结构为:(n,A0,K1,A1,K2,A2,… ,Kn,An) 其中,Ki(1≤i≤n)为关键字,且Ki<Ki+1(1≤i≤n-1)。Ai(0≤i≤n)为指向子树根结点的指针。且Ai所指子树所有结点中的关键字均小于Ki+1。n为结点中关键字的个数,满足ceil(m/2)-1≤n≤m-1。
Ai是指向孩子的指针,Ki是关键字,从每个结点的结构上我们就可以看到孩子的数量比关键字多一个。
总结:
实际上M通常会设计的比较大,M = 1024,一个节点1023个关键字,1024个孩子。
为什么B树规则这么复杂,这都是它的插入有关。
3. B树的插入分析
为了简单起见,假设M = 3. 即三叉树,正常来说每个节点中最多存储两个关键字,最少一个关键字,两个关键字可以将区间分割成三个部分,因此节点应该有三个孩子。
用序列{53, 139, 75, 49, 145, 36, 101}构建B树的过程如下:
根节点至少有两个孩子,这里可以这样理解,最开始插入一个关键字,它有两个孩子,可以认为是空。所以根节点要单独拿出来。
注意到 M = 3,正常来说不应该是2个关键字和3个孩子吗,为什么这里是3个关键字和4个孩子?多放一个是有原因的。
插入139没有什么影响
在插75,如果不多开一个,这个地方待会实现会变得复杂。插入的值因为要保证是有序的,所以可能在最前面,可能在中间,可能在最后面。但是现在并不敢就直接插入,一插入就要越界了。关键字个数等于M,关键字最多只能有M-1个。
基于这里的原因,我们就多给一个。
多给一个空间的好处是,方便插入,直接插入满了在分裂,不用管插在哪要挪动那些数据,也不怕越界。浪费一个空间不算啥。
关键字的数量等于M,则满了,满了就分裂出一个兄弟,提取中位数M/2,将中位数右边值和孩子拷贝给兄弟。将中位数给父亲,如果没有父亲就创建新的根。
插入49,注意新增节点只能在叶子节点插入。要保证节点内关键字是有序的,内部可以用直接插入排序挪动关键字和孩子。
插入145
插入36,发现关键字个数等于M,申请一个兄弟,找到中位数,将中位数右边的关键字和孩子分裂拷贝兄弟,提取中位数插入到父亲。插入要移动关键字和它的右孩子。 插入之后别忘记最后要连接兄弟节点。
插入101,关键字等于M会分裂拷贝给兄弟,然后提取中位数给父亲,父亲插入之后,父亲的关键字也等于M了,也会分裂。分裂拷贝,找到中位数M/2,申请兄弟节点,将中位数右边的关键字以及左孩子拷贝给兄弟,最后还要在将最后一个孩子也要拷贝给兄弟。然后提取中位数给父亲,父亲插入之后,如果父亲不满就结束,如果满就持续分裂。
B树的插入,你会发现它是天然平衡,因为它是向右和向上生长。
新插入的节点,一定是在叶子节点。因为叶子节点没有孩子,不影响关键字和孩子的关系。分支节点孩子保持比关键字多一个的关系。叶子节点满了,分裂出一个兄弟,提取中位数,向父亲插入一个值和孩子。
根节点分裂,增加一层。 越到最后根节点越不容易分裂。
假设 M = 1024。一个节点最多放1023个关键字,1024个孩子。
4层M路B树,就可以放一万亿个关键字和孩子了。
当然这是满的情况,不满我们也可以看一下,然后对比。
插入过程总结:
- 如果树为空,直接插入新节点中,该节点为树的根节点
- 树非空,找待插入元素在树中的插入位置(注意:找到的插入节点位置一定在叶子节点中)
- 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)
- 按照插入排序的思想将该元素插入到找到的节点中
- 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足
- 如果插入后节点不满足B树的性质,需要对该节点进行分裂:
申请新节点
找到该节点的中间位置
将该节点中间位置右侧的元素以及其孩子搬移到新节点中
将中间位置元素以及新节点往该节点的双亲节点中插入,即继续4 - 如果向上已经分裂到根节点的位置,插入结束
4. B树的插入实现
4.1 B树的节点设计
目前B树节点还差一个东西,等下面用到了在补充。
template<class K,size_t M>
struct BTreeNode
{
//K _keys[M - 1];
//BTreeNode<K, M>* _subs[M];
// M叉树:即一个节点最多有M-1个关键字,M个孩子
// 为了方便插入以后再分裂,多给一个空间
K _keys[M];
BTreeNode<K, M>* _subs[M + 1];
size_t _n;//记录实际存储多少个关键字
BTreeNode()
{
for (size_t i = 0; i < M; ++i)
{
_keys[i] = K();
_subs[i] = nullptr;
}
_subs[M] = nullptr;
_n = 0;
}
};
4.2 B树的部分插入实现1
//数据是存在磁盘,K是磁盘地址
template<class K,size_t M>
class BTree
{
typedef BTreeNode<K, M> Node;
public:
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果该元素已经存在,则不插入
auto ret = Find(key);
}
private:
Node* _root = nullptr;
};
下面先实现B树的查找
4.3 B树的查找
比当前关键字小一定在它的左边,比它大就往右继续找,如果越界了,就往最后一个关键字的右孩子去找。走到叶子节点的nullptr说明没找到。
左孩子下标与关键字下标相等。
右孩子下标比关键字下标大1。
// 返回值: Node代表找到的节点,int为该元素在该节点中的位置
pair<Node*, int> Find(const K& key)
{
// 从根节点的位置开始查找
Node* cur = _root;
while (cur)// 节点存在
{
// 在一个节点查找
size_t i = 0;
while (i < cur->_n)
{
if (key < cur->_key[i])// 该元素可能在i的左边的孩子节点中
{
break;
}
else if (key > cur->_keys[i])// 继续向右查找,越界后往最后一个关键字的右孩子去找
{
++i;
}
else
{
return make_pair(cur, i);// 找到返回
}
}
//往孩子去跳
cur = cur->_subs[i];
}
//没找到
return make_pair(nullptr, -1);
}
找到在中间就会返回了,没找到正常返回nullptr和-1,但是因为插入,如果find没有找到,我们期望把叶子节点返回来,前面说了新插的一定在叶子节点。所以我们期望find在没找到的时候顺便把叶子节点给我返回来。
// 返回值: Node代表找到的节点,int为该元素在该节点中的位置
pair<Node*, int> Find(const K& key)
{
// 从根节点的位置开始查找
Node* cur = _root;
Node* parent = nullptr;
while (cur)// 节点存在
{
// 在一个节点查找
size_t i = 0;
while (i < cur->_n)
{
if (key < cur->_keys[i])// 该元素可能在i的左边的孩子节点中
{
break;
}
else if (key > cur->_keys[i])// 继续向右查找,越界后往最后一个关键字的右孩子去找
{
++i;
}
else
{
return make_pair(cur, i);// 找到返回
}
}
//往孩子去跳
parent = cur;
cur = cur->_subs[i];
}
//没找到,把叶子节点返回去
return make_pair(parent, -1);
}
4.4 B树的部分插入实现2
void InsertKey(Node* node, const K& key)
{
}
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果key已经存在,则不插入
auto ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,Find顺便带回了要插入的那个叶子节点
Node* cur = ret.first;
//插入
InsertKey(cur,key);
// 满了就要分裂
// 没有满,插入就结束
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; ++i)
{
// 分裂拷贝key和key的左孩子给兄弟 (分裂的是叶子节点没有孩子,分支节点和根节点都有孩子)
brother->_keys[j] = cur->_keys[i];
brother->_subs[j] = cur->_subs[i];
++j;
// 拷走重置一下方便观察
cur->_keys[i] = K();
cur->_subs[i] = nullptr;
}
//最后一个右孩子也要拷贝给兄弟
brother->_subs[j] = cur->_subs[i];
cur->_subs[i] = nullptr;
//更新节点关键字个数
brother->_n = j;
cur->_n -= (brother->_n + 1); // 1表示中位数要提取给父亲
//提取中位数给父亲
K nemid= cur->_keys[mid];
cur->_keys[mid] = K();
//分裂后向父亲插入
}
分裂后向父亲插入有两种情况:
- 本身是根没有父亲
- 叶子节点或者分支节点有父亲
一个有父亲,一个没父亲,这两种情况该如何区分呢?
很好解决,我们给节点增加一个父指针!
template<class K,size_t M>
struct BTreeNode
{
//K _keys[M - 1];
//BTreeNode<K, M>* _subs[M];
// M叉树:即一个节点最多有M-1个关键字,M个孩子
// 为了方便插入以后再分裂,多给一个空间
K _keys[M];
BTreeNode<K, M>* _subs[M + 1];
size_t _n;//记录实际存储多少个关键字
BTreeNode<K, M>* _parent;//父指针
BTreeNode()
{
for (size_t i = 0; i < M; ++i)
{
_keys[i] = K();
_subs[i] = nullptr;
}
_subs[M] = nullptr;
_parent = nullptr;
_n = 0;
}
};
因为节点增加了父指针,所以我们先把现有的插入代码修改一下
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果key已经存在,则不插入
auto ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,Find顺便带回了要插入的那个叶子节点
Node* cur = ret.first;
InsertKey(cur,key);
// 满了就要分裂
// 没有满,插入就结束
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; ++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 -= (brother->_n + 1); // 1表示中位数要提取给父亲
//提取中位数给父亲
K nemid= cur->_keys[mid];
cur->_keys[mid] = K();
}
}
如果本身是根没有父亲,很简单,创建一个根,然后插入一个关键字和两个孩子cur和brother。
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果key已经存在,则不插入
auto ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,Find顺便带回了要插入的那个叶子节点
Node* cur = ret.first;
InsertKey(cur,key);
// 满了就要分裂
// 没有满,插入就结束
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; ++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 -= (brother->_n + 1); // 1表示中位数要提取给父亲
//提取中位数给父亲
K nemid= cur->_keys[mid];
cur->_keys[mid] = K();
// 分裂的是根节点没有父亲
if (cur->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = nemid;
_root->_subs[0] = cur;
_root->_subs[1] = brother;
_root->_n = 1;
cur->_parent = _root;
brother->_parent = _root;
return true;
}
else // 分裂的是叶子节点或者是分支节点有父亲
{
// 转换成往cur->parent 去插入cur->[mid] 和 brother
}
}
}
如果分裂的是叶子节点或者是分支节点有父亲,转化为向父亲节点插入关键字和兄弟。 注意我们刚才写了一个InsertKey插入函数,参数只有key,没有孩子的参数,为了让分裂后插入也可以使用这个函数,因此我们可以增加一个孩子的参数。如果是向叶子节点插入关键字,我们可以认为它的孩子为nullptr。这样不管是向叶子节点插入,还是分裂后向父亲插入中位数和兄弟,InsetKey这个函数都可以用。并且插入应该是一个循环。
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果key已经存在,则不插入
auto ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,Find顺便带回了要插入的那个叶子节点
// 循环每次往cur插入 newkey和child
Node* cur = ret.first;
Node* child = nullptr;
K newkey = key;
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; ++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 -= (brother->_n + 1); // 1表示中位数要提取给父亲
//提取中位数给父亲
K newmid = cur->_keys[mid];
cur->_keys[mid] = K();
// 分裂的是根节点
if (cur->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = newmid;
_root->_subs[0] = cur;
_root->_subs[1] = brother;
_root->_n = 1;
cur->_parent = _root;
brother->_parent = _root;
return true;
}
else //分裂的是叶子节点或者分支节点
{
// 转换成往cur->parent 去插入cur->[mid] 和 brother
cur = cur->_parent;
newkey = newmid;
child = brother;
}
}
}
}
4.5 插入key的过程
按照直接插入排序的思想插入key,移动key也要移动它的右孩子,最后在把中位数和孩子插入。
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;
}
4.7 B树的插入完整代码
template<class K,size_t M>
struct BTreeNode
{
//K _keys[M - 1];
//BTreeNode<K, M>* _subs[M];
// M叉树:即一个节点最多有M-1个关键字,M个孩子
// 为了方便插入以后再分裂,多给一个空间
K _keys[M];
BTreeNode<K, M>* _subs[M + 1];
size_t _n;//记录实际存储多少个关键字
BTreeNode<K, M>* _parent;//父指针
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:
// 返回值: Node代表找到的节点,int为该元素在该节点中的位置
pair<Node*, int> Find(const K& key)
{
// 从根节点的位置开始查找
Node* cur = _root;
Node* parent = nullptr;
while (cur)// 节点存在
{
// 在一个节点查找
size_t i = 0;
while (i < cur->_n)
{
if (key < cur->_keys[i])// 该元素可能在i的左边的孩子节点中
{
break;
}
else if (key > cur->_keys[i])// 继续向右查找,越界后往最后一个关键字的右孩子去找
{
++i;
}
else
{
return make_pair(cur, 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;
}
bool Insert(const K& key)
{
// 如果树为空,直接插入
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
// 找插入位置,如果key已经存在,则不插入
auto ret = Find(key);
if (ret.second >= 0)
{
return false;
}
// 如果没有找到,Find顺便带回了要插入的那个叶子节点
// 循环每次往cur插入 newkey和child
Node* cur = ret.first;
Node* child = nullptr;
K newkey = key;
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; ++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 -= (brother->_n + 1); // 1表示中位数要提取给父亲
//提取中位数给父亲
K newmid = cur->_keys[mid];
cur->_keys[mid] = K();
// 分裂的是根节点
if (cur->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = newmid;
_root->_subs[0] = cur;
_root->_subs[1] = brother;
_root->_n = 1;
cur->_parent = _root;
brother->_parent = _root;
return true;
}
else //分裂的是叶子节点或者分支节点
{
// 转换成往cur->parent 去插入cur->[mid] 和 brother
cur = cur->_parent;
newkey = newmid;
child = brother;
}
}
}
}
private:
Node* _root = nullptr;
};
4.8 B树的简单验证
对B树进行中序遍历,如果能得到一个有序的序列,说明插入正确
void _InOrder(Node* root)
{
if (root == nullptr)
return;
// 左 根 左 根 ... 右
size_t i = 0;
for (; i < root->_n; ++i)
{
_InOrder(root->_subs[i]);//左子树
cout << root->_keys[i] << " ";//根
}
_InOrder(root->_subs[i]);//最后的那个右子树
}
4.9 B树的删除
B树的删除这里我们就不说了。如果对删除有兴趣请参考《算法导论》-- 伪代码和《数据结构-殷人昆》–C++实现代码。不过这里我们可以说一下大思路。
当被删除关键字k在分支节点,可以用k前序(当前关键字左侧指针所指子树中 “最右下” 元素)或者后继(当前关键字右侧指针所指子树中 “最左下” 元素)k’ 来代替 k,然后就转化成在叶子节点删除关键字了,因此下面我们讨论的是删除叶子节点关键字的情形。
当被在叶子节点删除分为下面4种情况:
- 若被删除的关键字所在叶子结点同时又是根节点,并且关键字个数大于等于 ceil (m/2),直接删除。
- 若被删除的关键字所在叶子结点不是根节点,并且关键字个数大于等于 ceil (m/2),直接删除。
- 若被删除的关键字所在叶子结点删除前关键字个数 n = ceil(m/2) - 1,且此时时与该节点相邻的右(或左)兄弟结点的关键字个数 n >= ceil(m/2),则需要调整该节点、右(或左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡。(兄弟够借)
- 若被删除关键字所在结点删除前的关键字个数 n = ceil(m/2) - 1,且此时与该节点相邻的左、右兄弟结点的关键字个数 n = ceil(m/2) - 1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。(兄弟不够借)
在合并的过程中,双亲结点的关键字个数会减1,若其双亲结点是根结点且关键字个数减少至0,则直接将根节点删除,合并后的新结点成为根;若双亲结点不是根结点,且关键字个数减少到ceil(m/2) -2,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止。
4.10 B树的性能分析
B树上搜索一个值时间复杂度最坏就是走高度次。
假设每层结点关键字都是满的设为M,孩子也是M。忽略掉孩子比关键字多1。
设有h层,一层一层下去这就是一个等比数列。
这里我们可以用错位相减法来算
不满的情况
对于一棵节点为N度为M的B树,查找和插入需要 l o g M − 1 N log_{M-1}N logM−1N~ l o g M / 2 N log_{M/2}N logM/2N次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要 l o g M − 1 N log{M-1}N logM−1N和 l o g M / 2 N log{M/2}N logM/2N之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则 l o g M / 2 N log_{M/2}N logM/2N <= 4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。
2-3查找树就是M = 3的B树。
5. B+树
B树总体来说还是非常依赖于搜索树,还区分左孩子右孩子。并且分支节点孩子的数量比关键字多一个,整体而言反而让结构变得复杂了不少。基于一系列原因大佬又对B树进行了优化。
B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又在B树的基础上做了以下几点改进优化:
- 分支节点的子树指针与关键字个数相同
- 分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间
- 所有叶子节点增加一个链接指针链接在一起
- 所有关键字及其映射数据都在叶子节点出现
2:孩子与关键字个数相等
3:B树以前下标和我相等是我的左孩子,现在在[k[i],k[i+1])之间。
2、3合在一起相当于取消了最左边的那个子树。
4:方便了去遍历,不需要从根开始了。
5:所有关键字及其映射数据都在叶子节点出现。这个时候不就有重复值了吗?这个时候另外的隐含意思是:
分支节点根叶子节点有重复的值,分支节点存的是叶子节点索引。
父亲中存的是孩子节点中的最小值做索引。
比如在这颗B+树搜索一个33,是如何搜索的呢?
先找到根,如果比5还小根本不存在,5已经是最小的了。
如果比5大,往下一个。比28大还往下一个。比65小说明在65的左边。去P2找。
比28大往下一个,比35小说明在55的左边。去P1找。最终找到33。如果是34,最终就是去33的右孩子去找,但33右孩子是空,所以找不到。
插入的过程也是类似的。这里简单了解一下 M == 3 B+树分裂过程,它这里的结构搞的简单一些。
刚开始插入要两层节点,一层做分支,一层做根。
插入到139。目前关键字是3个了。因为B+树分支节点孩子和关键字个数相同所以不用分裂。只有当 关键字个数 > M 才分裂
插入 49,关键字个数 > M 分裂
插入满了之后分裂一半给兄弟,转换成往父亲插入一个key和一个孩子,孩子就是兄弟,key就是兄弟的第一个最小值的key。
插入145,36
插入101,第二次分裂
再来两个数150、155,我们看看连续分裂的情况
B+树插入过程:
B+树的插入过程根B树基本是类型的,细节区别在于,第一次插入两层节点,一层做分支,一层做根。后面一样往叶子去插入,插入满了之后分裂一半给兄弟,转换成往父亲插入一个key和一个孩子,孩子就是兄弟,key就是兄弟的第一个最小值的key。
总结一下B+树:
- 简化B树孩子比关键字多一个的规则,变成相等
- 所有值都在叶子节点,方便遍历查找所有值
B+树的特性:
- 所有关键字都出现在叶子节点的链表中,且链表中的节点都是有序的。
- 不可能在分支节点中命中。
- 分支节点相当于是叶子节点的索引,叶子节点才是存储数据的数据层。
6. B*树
B*树是B+树的变形,
- 在B+树的分支节点增加指向兄弟节点的指针。
- B+树节点原来关键字个数最少是1/2M,B*树要求节点关键字最少是2/3M,最多是M。
B+树的分裂:
当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针。
B*树最大的改变就是让每个节点更满。因为B树和B+树都有一个缺陷,可能会浪费一半的空间。
B*树的结点关键字和孩子数量 -> [2/3M,M]。
B*树的分裂:
当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针。
所以,B*树分配新结点的概率比B+树要低,空间使用率更高;
7. 总结
通过以上介绍,大致将B树,B+树,B*树总结如下:
B树:有序数组+平衡多叉树;
B+树:有序数组链表+平衡多叉树;
B*树:一棵更丰满的,空间利用率更高的B+树
8. B树的应用
B树的应用有两个,一个是文件系统,一个是索引。
B树的应用有些人做了对比:在内存中 做内查找。B树系列和哈希和平衡搜索树对比。
单论树高度,搜索效率而言,B树确实不错。但是B树系列有一些隐形坏处:
- 空间利用率低,消耗高
- 插入删除数据时,分裂和合并节点,那么必然挪动数据
- 虽然高度更低,但是在内存中而言,跟哈希和平衡搜索树还是一个量级
结论:实质上B树系列在内存中体现不出优势。
8.1 索引
B树最常见的应用就是用来做索引。索引通俗的说就是为了方便用户快速找到所寻之物,比如:书籍目录可以让读者快速找到相关信息,hao123网页导航网站,为了让用户能够快速的找到有价值的分类网站,本质上就是互联网页面中的索引结构。
MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构,简单来说:索引就是数据结构。
当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将数据保存到数据库,因此数据库不仅仅是帮助用户管理数据,而且数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,该数据结构就是索引。
8.2 MySQL索引简介
mysql是目前非常流行的开源关系型数据库,不仅是免费的,可靠性高,速度也比较快,而且拥有灵活的插件式存储引擎,如下:
数据库有一个Cathes&Buffers,意思是数据存在磁盘中直接访问太慢, 所以建一个缓存(可以考虑使用LRU)。对于B+树而言可以把所有分支节点存储到Cache里,搜索必然要走分支。如果使用B树缓存就没有多大意义,因为太大了,分支节点既有数据也要数据所在磁盘的地址。B+树相比于B树而言,分支节点只存key,分支节点比较小。分支节点映射的磁盘数据块就可以尽量加载Cache。
倒数第二行是数据库的搜索引擎。
MySQL中索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的。
注意:索引是基于表的,而不是基于数据库的。
数据库创建好了,建表的时候其实就要建立一颗B树。
比如下面这张表的信息存到磁盘,那如何能快速查找信息呢?
这个时候B树就可以起作用了,不过最好还是用B+树是最好的。
数据库创建好了,首先要建表,建表要指定列字段和属性,通常要指定一个主键不然就用默认主键等等。建表的主键,就是B+树的key。B+树的value是存储一行数据的磁盘地址。
分支节点也是需要存磁盘中的,因为数据量大了,内存是存不下的。分支节点中应该是磁盘的地址。但是分支节点理论应该尽量被缓存到cache。
对表的操作有增删查改,比如我们改。可以有两种方式,一种是条件筛选使用主键,另一种是使用其他字段。思考一下这两种写法有何差别?
第一种可以使用B+树进行查找,效率高,时间复杂度O(logmn)
第二种只能遍历B+树所有叶子节点,暴力查找,效率低,时间复杂度O(n) (全表扫描)
如果经常向使用name去进行查找,怎么办?
其实可以用name创建一个索引。
对name创建索引之后,相当于使用B+树用name做key创建一棵树,当然value指向磁盘数据,那么在执行对应的sql语句,效率就高了
一般数据库要求主键值是不重复的唯一值字段:电话、身份证号码适合,名字、地址不适合。没有字段能保持唯一,可以考虑自增主键(其实就是它自己建立一个自增整数做主键)
如果主键冲突,插入数据就会报错。
一般数据库不要求索引唯一,像mysql建立索引可以考虑使用B+树或者哈希表,数据结构允许冗余。
B+树做主键索引相比于B树优势:
- B+树所有值都在叶子节点,遍历很方便,方便区间查找
- 对于没有建立索引的字段,全表扫描的遍历很方便
- 分支节点值存储key,一个分支节点空间占用更小,可以尽可能加载到内存
B树不用到叶子就能找到值,B+树一定要到叶子,这是B树一个优势,但是B+树高度足够低,所以差别不大。
8.2.1 MyISAM
MyISAM引擎是MySQL5.5.8版本之前默认的存储引擎,不支持事务,支持全文检索,使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址,其结构如下:
上图是以以Col1为主键,MyISAM的示意图,可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果想在Col2上建立一个辅助索引,则此索引的结构如下图所示:
叶节点的data域存放的是数据记录的地址,方便索引树和主键树映射同样的数据。
说明:B树节点数据都在磁盘文件中,访问节点都是IO行为,只是它会把热数据缓存到cache。
同样也是一棵B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。MyISAM的索引方式也叫做“非聚集索引”的。
8.2.2 InnoDB
InnoDB存储引擎支持事务,其设计目标主要面向在线事务处理的应用,从MySQL数据库5.5.8版本开始,InnoDB存储引擎是默认的存储引擎。InnoDB支持B+树索引、全文索引、哈希索引。但InnoDB使用B+Tree作为索引结构时,具体实现方式却与MyISAM截然不同。
第一个区别是InnoDB的数据文件本身就是索引文件。MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而InnoDB索引,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。
上图是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录,这种索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整型。
这样建立索引有一个不好的地方。建立索引的时候,索引树的叶子节点和主键树叶子节点中数据不一样,没办法直接映射
第二个区别是InnoDB的辅助索引data域存储相应记录主键的值而不是地址,所有辅助索引都引用主键作为data域
聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。
参考资料:
MySQL索引背后的数据结构及算法原理