在数据结构中,有很多高效的数据结构,比如红黑树、AVL树、hash表这种,它们的搜索速度都很快,但是都有一个问题,面对外存储时,它们的效率依旧不够高。
**外存储:**即从磁盘上进行存取操作,对应的就是在内存上进行的内存储。
为什么不够高呢?首先是 红黑树 和 AVL树,它们能够做到 Log(N) 级别的搜索,但是对于操作系统来说, Log(N) 次的磁盘IO 依旧是很慢的。
而hash呢?虽然有 O(1) 级别的搜索效率,但是极端场景下,它的哈希冲突一旦很多,也需要多次磁盘IO。
那么如何提高数据访问速度呢?
- 提高IO速度,即使用更高速的磁盘
- 减少检索的次数,即降低树的高度---B树
B树概念
一颗 M 阶的B树是平衡的 M 路平衡搜索树,可以是空树。
它有以下性质:
- 根节点至少有两个孩子
- 每个分支节点都有 k-1 个 key 值和 k 个孩子 (ceil(m/2) <= k <=m)
- 每个叶子节点都有 k-1 个关键字(ceil(m/2) <= k <=m)
- 叶子节点都在同一层
- 每个节点关键字从小到大排序,节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分
- 每个节点结构为 n,A0,K1,A1,K2....AN,KN。其中 n 是节点关键字个数, K 是关键字,A是子节点的指针,n 满足 (ceil(m/2) <= n <=m)
也许这个性质看着一头雾水,不过和 红黑树不同,B树的性质是服务于结构的,红黑树是结构服务于性质的,因此接下来我们讲解一下B树的插入,此时大家就会有更深的印象了。
B树的插入
我们先设一个 B树 为 3 阶,用 {53, 139, 75, 49, 145, 36, 101} 构建一个 B 树。
插入 53 和 139 后,发现 75 应该在中间,因此会将 139 往后挪,然后找到75应该在的位置后,再插入 75.
此时节点已经满了,因此需要进行分裂操作。
分裂操作:将节点分为三部分,左边、中位数、右边。
右边的关键字都给兄弟节点,自己这个节点保留左边,生成一个父亲节点,保留中位数
分裂后编程这样子。
通过分裂后的图发现一件事,这个 B树 严格符合性质5:
每个节点关键字从小到大排序,节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分
可以看到,childs[0] 下标指的节点,其最大值一定不超过 75,childs[1] 下标指向的节点,其最小值一定不低于 75.
继续插入。
发现插入到 36 时又需要分裂。
分裂后变成这样。
从图中发现,childs[0] 的孩子节点的值一定都小于49,而 childs[1] 的孩子节点的值则一定都在 49---75 之间。
继续插入 101 后,就会发现会一直分裂,然后根节点也需要分裂。
而无论是哪里,都是严格符合B树的性质。
这就是一个B树的插入过程。
那么如何实现呢?
B树插入代码实现
cpp
#include<utility>
using namespace std;
#pragma once
template<class K,size_t M>
struct BTreeNode
{
K _keys[M];
BTreeNode<K, M>* _subs[M + 1];
BTreeNode<K, M>* _parent;
size_t n;
BTreeNode()
{
for (int i = 0; i < M; i++)
{
_keys[i] = K();
_subs[i] = nullptr;
}
_subs[M] = nullptr;
n = 0;
_parent = nullptr;
}
};
template<class K,size_t M>
class BTree {
public:
typedef BTreeNode<K, M> Node;
pair<Node*, int> Find(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
//遍历cur这个节点的所有key值,看是不是放在这个节点内部
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);
}
}
parent = cur;
cur = cur->_subs[i];
}
//如果 key 值节点不存在,那么必定这里返回的是一个叶子节点
return make_pair(parent, -1);
}
void _InsertKey(Node* node,const K& key,Node* child)
{
size_t end = node->n - 1;
while (end)
{
//将大的数向后挪动,并且挪动孩子
if (key < node->_keys[end])
{
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;
//有可能没有子节点,因为可能没有满,只用将key插入到node中即可
//但是如果有分裂情况,child 可能就不为空了
if (child)
{
child->_parent = node;
}
node->n++;
}
bool Insert(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;
}
//此时就是不存在
//不存在就找到了需要插入的叶子节点
Node* parent = ret.first;
K Newkey = key;
Node* child = nullptr;
while (1)
{
_InsertKey(parent, key, child);
//插入之后,看被插入的叶子节点满没满,满了就分裂
if (parent->n < M)
{
return true;
}
else {
//满了进行分裂操作
size_t mid = M / 2;
Node* bro = new Node;
size_t j = 0;
size_t i = mid + 1;
for (; i < M; i++)
{
//parent 中间往后的key和子节点都给兄弟
bro->_keys[j] = parent->_keys[i];
bro->_subs[j] = parent->_subs[i];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = bro;
}
j++;
parent->_keys[i] = K();
parent->_subs[i] = nullptr;
}
//由于孩子比key多一个,因此这里需要额外设置一下
bro->_subs[j] = parent->_subs[j];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = bro;
}
parent->_subs[i] = nullptr;
bro->n = j;
parent->n -= (bro->n + 1);
//然后将中间key值给父亲节点
K midKey = parent->_keys[mid];
parent->_keys[mid] = K();
//如果已经是根节点,就说明这里需要生成一个新的父节点
if (parent->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = midKey;
_root->_subs[0] = parent;
_root->_subs[1] = bro;
_root->n = 1;
parent->_parent = _root;
bro->_parent = _root;
break;
}
//否则就需要往上继续遍历
else {
Newkey = midKey;
parent = parent->_parent;
child = bro;
}
}
}
return true;
}
private:
Node* _root = nullptr;
};
首先是查找的实现,其实 B 树也是二叉搜索树的基础上演变而来的,因此查找也是类似的方式,不过由于B树的性质**,每次插入时都是需要找到一个叶子节点才能插入**,因此这里如果没找到key值的节点位置,就直接返回当前这个叶子节点,这个叶子节点也是 key 值可以插入的位置。
为什么每次插入都需要找叶子节点呢?
这是由于B树的性质5导致的:节点第 k-1 个元素正好是第 k 个孩子包含的元素的值的域划分
如果插入的时候不找叶子节点,而是直接插入,就会破坏性质。
而实际上的插入函数是这个,需要找到插入的叶子节点,并且插入数据,按照平衡树的性质,来从后往前将数据往后挪,直到 key 值大于 _keys[end],此时 end + 1 就是 key 值存在的位置。
而child是有可能为空的,假设 B 树只有一个根节点,而且根节点没满,那么这个 child 一定是空,那么什么时候不为空呢?那就是节点需要分裂,child 是该节点分裂出来的兄弟节点。
而如果插入的时候, root 没有数,就需要自行创建一个 Node,并且存储key值后返回。
而如果root有,并且 key 值存在,就不用插入,直接返回false。
而如果key值不存在,就需要将数据插入 。
由于之前已经找到了key值存在的叶子节点,因此可以直接往这个叶子节点中插入key值。
插入后节点的值未满,就可以直接返回,满了就需要进行分裂操作。
分裂则需要将 key 值和 该节点的所有子节点分为两部分:左边和右边
而key 值的中间值,则需要跟父亲节点,右边的都给兄弟节点保存,如果子节点存在,还需要修改子节点的父亲指针指向。
有一个细节是由于 子节点 比 key 值多一个,因此最后出循环后,j = M 的情况下,需要将最后一个子节点也设置给兄弟节点。
然后初始化兄弟节点的key值个数。
而如果分裂操作进行完后,就需要将中间值给父亲节点。
如果已经是根节点,就新生成一个节点。
不过如果分裂的节点有父亲节点,则需要向上进行遍历,将兄弟节点、中间值给它,不过这个父亲节点有概率会满,因此需要向上遍历来进行下一步分裂操作。
B树的性能分析
B树的平均搜索效率应该在 Log(M-1)N ~ log(M/2)N 之间,因为每个节点的key值数量都在 M/2到 M-1 之间。
这是一个非常快的效率。
假设一颗B树每个节点有 1023 个数据。
那么第一层有1023,第二次有 1024*1023个数据(节点比数据多一个),第三层就有 1024*1024*1023,第四层就有 1024*1024*1024*1023,仅仅几层就能到一个天文数字,用在磁盘搜索正好,可以很快就定位到数据所在的位置,然后就可以利用二分查找,以常数级别的次数找到数据。
B+树和B*树
B+树
B+树在B树的基础上修改了几项规则,主要和B树没什么区别,唯一区别是B+树只有叶子节点存储数据,其他节点存的只是一个范围。
- 分支节点的叶子结点和关键字个数相同
- 分支节点的子树指针 p[i] 所指向的节点内的关键字大小在分支节点 k[i] k[i+1] 之间
- 叶子节点之间通过指针链接在一起
- 关键字及其映射数据只有叶子节点有
我们可以之间看看 B+ 树的结构。
(图片来自网络)
从结构上可以看到,B+树所有节点的子节点个数和关键字个数相同。
其次,叶子节点存储的值都在父节点的关键字之间。
换句话说:分支节点存储的都是目录, 类似于告诉你这个分支节点的这个子节点的范围是多少,如果你查询的数据在这个范围内,就去这个子节点找,反之,就去后面。
B+树的分裂
B+树的分裂很简单,如果叶子节点满了,就分一半给兄弟节点,然后将兄弟节点的最小值给父亲节点,让父亲节点新增一个范围。
如果父亲节点满了,则让父亲节点也分一半给兄弟节点。
B*树
B*树是在B+树的基础上增加了一个分支节点之间的指针。
B*树的分裂
B*树的分裂则看兄弟节点满没满,自己满了就将数据放入没满的兄弟节点,同时修改父亲节点的关键字(范围)。
如果兄弟满了,则新增兄弟节点,并且自己和兄弟节点各分1/3给这个新增节点,然后让父亲节点新增指针。
B树的应用
数据库是一种常用的存储数据的手段,因此需要经常和磁盘存储打交道,因此B树在数据库中应用很广泛。
数据库在建表的时候,可以设置某个数据为索引,如主键索引,唯一键索引。
当这个表有索引时,那么数据库就会在底层以索引为关键字,来创建一个 B+树。
这样当出现海量数据时,数据库可以快速的通过索引查询数据,并且获取到数据。
MyIsam
数据库中有很多存储引擎,MyIsam就是其中之一,它采用 B+树来作为索引结构。
不过它的叶子节点并不直接存储数据,而是存储索引对应数据的地址 , 也就是所谓的非聚簇索引。
这种索引方式会导致主键索引和辅助索引没有区别 ,因为非聚簇索引无论是按主键索引还是辅助索引,都是找数据的地址,因此没有区别。
从下图可以看到,二者都是之间通过索引找到地址,因此主键索引和辅助索引没有区别。
唯一的区别是主键唯一,辅助索引不唯一。
InnoDB
InnoDB 也是一个引擎,其内部也是通过 B+树作为查询结构的。
它是MySQL 的默认存储引擎,不过它和 MyIsam 不同的是,它是聚簇索引,即索引对应的节点存储的是数据,而非地址。
这就导致了它通过主键索引和辅助索引查询效率不同,主键索引查询效率高于辅助索引查询
因为辅助索引查询的数据其实是这个辅助索引对应的主键索引(为了减少内存),然后找到这个主键后再去通过主键索引查询数据,因此通过辅助索引有两次查询,就导致数据下降。
而且InnoDB是聚簇索引,必须通过主键建立一个B+树,因此InnoDB引擎必须有一个主键,如果没有,它自己会有一个默认主键,该主键按数据插入时间顺序排序。
聚簇索引导致主键索引查询效率高,但是其辅助索引的查询效率低。
总结
本文讲述了 B树的概念,以及插入和实现,也了解了B+树和B*树的概念,并且学习了B树的应用场景,MyIsam 和 InnoDB 两个存储引擎的区别也有所涉及,希望对大家有所帮助。