文章目录
- 一、什么是B树
- 1.为什么要存在B树?
- 2.B树的规则
- 二、B树的插入
- 三、B树的实现
- 时间复杂度
- 四、B+树
- 1.B+树的分类过程
- 五、B*树
- 六、B树系列的应用
- 1.MyISAM
- 2.InnoDB
一、什么是B树
相比于我们别的数据结构,我们的B树更加适合进行外查找
B树也可以进行内查找,但是有一点浪费空间
1.为什么要存在B树?
当我们的数据量很大,无法一次全部都放进内存的话,那就只能存在磁盘上。
那么我们应该如何将这些磁盘的文件进行管理起来呢?
磁盘当中的数据只能挨着存。
我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。
从上面的图中我们可以看出,我们从树的根开始读取的话,我们需要读取树的高度次磁盘IO
但是多次进行磁盘读取,就会非常缓慢。每次要读取新的数据,要去定位这个过程是非常缓慢地。
我们来看一下我们之前的数据结构:
AVL树/红黑树:logN
哈希表:O(1):极端场景下哈希冲突非常严重,效率下降很多。
这时我们就需要用到B树
如果是10亿个数据,我们的AVL树大概需要存30层。
我们要对这30层进行压缩,我们就要想办法我们对我们每一层进行压缩
在平衡搜索树的基础上寻找优化方法
1.压缩高度,二叉变多插
2.一个结点里面存多行的值,也就是一个结点里面有多个关键字以及映射的值
2.B树的规则
(B树的设计都是为了服务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。
(分支节点孩子的数量要比关键字的数量多一个)
假设现在我们的m是10
按照上面的规则,也就是说我们
最少需要4个关键字和5个孩子
最多需要9个关键字和0个孩子
然后我们这个节点中的关键字按照从小到大的顺序进行排列
(n,A0,K1,A1,K2,A2,… ,Kn,An)
K1<K2<K3<…<Kn
A0节点中的值<K1<A1节点中的值<K2<……
也就是说,如果比K1小,我们就在A0节点中继续寻找,如果比K2小比K1大,我们就在A1节点中进行查找。
也就是说如果采用了B树的设计,我们一次就可以读取出最多m个人的信息,读取更多人的信息,IO不会变慢吗?
IO慢的事查找定位到对应的位置的时间,如果是连续的存取的话,影响是不太大的。
在实际的情况下,M一般被设置成比较大的数字,比方说1024,也就是一个节点中最多存1023个关键字,1024个孩子
这时候,我们如果想要在单节点的m歌数据中进行查找的话,我们就可以使用二分进行查找。logN。
但是这样我们的空间的开销就比较大,所以它指定我们的一个节点中最少存m/2个孩子
(这跟B树的分裂有关)
所以我们B树的本质是一个多叉的搜索树
二、B树的插入
这里我们先假设M=3。
也就是最少存1个关键字,最多两个关键字,最少2个孩子,最多3个孩子
这里是我们的数据:{53, 139, 75, 49, 145, 36, 101}
首先我们将53,139和75插入
(我们这里多开一个空间,便于我们的插入。否则我们的第m个元素插入的时候,也就是我们刚刚好越界的时候,我们就不知道插入在哪里,我们可能还要分情况进行讨论,这样就非常麻烦。多开辟一个空间的话,我们就可以先将这第M个元素先插进去,然后再进行分裂操作,就省去了分类讨论)
关键字的数量等于M,那就是满了,满了就分裂,分裂出一个兄弟(兄弟里面最初始没有值),然后分一半的值给兄弟
满了的结点有M个关键字,(最多只能存M-1个),分裂M/2个给兄弟,还要提取中位数给父亲,没有父亲就创建新的根。(如果不提取值给父亲的话,这两个兄弟就裂开了)
再插入36的时候,我们左边的结点1就满了,我们又需要进行分裂
49是我们的中位数。
所以我们将49放入我们的父节点中
(我们的关键字要比我们孩子的数量少一个。现在我们有三个孩子和两个关键字)
最右边的子树满了,进行持续分裂
B树天然平衡
因为它是向右和向上生长的
新插入的结点一定是在叶子插入的。叶子没有孩子,所以不会影响孩子和关键字的关系(孩子比关键字多一个)
叶子结点满了,就分裂出一个兄弟,提取中位数,向父亲插入一个值和一个孩子。
根节点分裂才会增加一层。
假设M=1024,那么一个4层的M路的B树可以存多少个值呢?
如果这棵树是全满的情况下
第一层1023个关键字,1024个孩子
第二层10241023个关键字(上一层的每一个孩子也就是这一层的每一个结点都有1023个关键字),10241024个孩子
第三层102410241023个关键字(上一层的每一个孩子也就是这一层的每一个结点都有1023个关键字),102410241024个孩子
第三层1024102410241023个关键字(上一层的每一个孩子也就是这一层的每一个结点都有1023个关键字),1024102410241024个孩子
满树的情况下
第一层大概就是1000
第二层大概就是100w
第三层大概就是10亿
第四层大概就是1万亿
最差的情况:
第一层只有1个关键字,2个孩子
第二层有2512个关键字,大概1000个关键字,1000个孩子
第三层大概1000512个关键字,1000512个孩子
第四层大概50w512个关键字,约等于2.5亿个关键字
三、B树的实现
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是磁盘地址,是M路的搜索树,我们的M事不确定的
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);
}
}
// 往孩子去跳
//在往下一层跳之前先将当前的结点给parent
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* parent = ret.first;
K newKey = key;
Node* child = nullptr;
while (1)
{
InsertKey(parent, newKey, child);
// 满了就要分裂
// 没有满,插入就结束
if (parent->_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] = parent->_keys[i];
brother->_subs[j] = parent->_subs[i];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = brother;
}
++j;
// 拷走重置一下方便观察
parent->_keys[i] = K();
parent->_subs[i] = nullptr;
}
// 还有最后一个右孩子拷给
brother->_subs[j] = parent->_subs[i];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = brother;
}
parent->_subs[i] = nullptr;
brother->_n = j;
parent->_n -= (brother->_n + 1);
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] = brother;
_root->_n = 1;
parent->_parent = _root;
brother->_parent = _root;
break;
}
else
{
// 转换成往parent->parent 去插入parent->[mid] 和 brother
newKey = midKey;
child = brother;
parent = parent->_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;
};
void TestBtree()
{
int a[] = { 53, 139, 75, 49, 145, 36, 101 };
BTree<int, 3> t;
for (auto e : a)
{
t.Insert(e);
}
t.InOrder();
}
时间复杂度
第一层:M
第二层:MM
第三层:MMM
第四层:MMMM
N=M+M2 +M3 +M4 +……+Mh
h约等于log{M}{N}(M为底数,N为指数)
四、B+树
B+树是在B树的基础上进行了优化
1.分支节点的子树指针与关键字个数相同。(就相当于是取消掉了原先B树每个结点的最左边的那个孩子)
2.分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间
3.所有叶子节点增加一个链接指针链接在一起
4.所有关键字及其映射数据都在叶子节点出现
(分支节点跟叶子结点有重复的值,分支节点存的是叶子结点的索引)
(父亲中存的是孩子结点中的最小值做索引)
(分支节点可以只存key,叶子结点存key/value)
1.B+树的分类过程
假设这是一棵M==3的B+树,然后我们B+树要插入的数据是
{53,139,75,49,145,36,101,150,155};
插入49的时候进行第一次分裂
插入146和35
插入101的时候发生第二次分裂
插入150,插入155的时候发生连续的两次分裂
B+树的插入过程根B树是基本类似的,区别在于第一次插入的时候需要插入两层节点,一层做分支,一层做根,后面一样往叶子去插入,插入满了以后,分一半给兄弟,转换成往父亲插入一个key和一个孩子,孩子就是兄弟,key为兄弟结点的第一个最小值的key
总结:
1.简化孩子比关键字多一个的规则,变成相等。
2.所有值都在叶子上,方便便利查找所有值。
五、B*树
B树和的结点关键字和孩子数量->[2/3M,M]
B*树的分裂:
当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针。
所以,B*树分配新结点的概率比B+树要低,空间使用率更高;

六、B树系列的应用
在内存中做内查找的话和哈希、平衡搜索树对比:
单纯论树的高度,搜索效率而言,B树确实不错。
但是B树系列存在一些隐形的坏处:
1.空间利用率低,消耗高
2.插入和删除数据、分裂和合并节点,那么必然挪动数据。
3.虽然高度更低,但是在内存中而言,跟哈希和平衡搜索树还是在一个量级的
结论:实质上B树系列在内存中体现不出优势。
内存中搜索3次和30次差别不大,
磁盘中搜索3次和30次差别巨大
B树系列的应用:数据库中的引擎MyISAM或者InnoDB
比方说表格中:
一行就是一个人的成绩信息
一列就是所有人的一项的信息
数据库建表的时候就要选择一列作为主键
CREATE TABLE StuInfo
(
stuID int,
age int,
name varchar(255)
...
);
当我们将上面的sql语句传给我们的MySQL数据库的时候,他会为我们建立一棵B+树来索引磁盘数据。
建表的主键就是B+树的key,B+树的value是存储一行数据的地址(一般主键要求是唯一的,不允许冗余的mysql:电话,身份证号码比较适合作为主键,名字、地址不适合作为主键)
如果没有字段能保持唯一怎么办?
设置一个自增主键(其实他自己建立一个自增整数作为主键)
一般数据库不要求索引唯一,像mysql建立索引可以考虑使用B+树还是Hash表,数据结构允许冗余。
我们可以将stuID设置成我们的主键key,其余的字段就作为value
分支节点也是要存到磁盘中的
因为数据量大了,内存中存不下的时候,
分支节点中应该存磁盘地址。
但是分支节点理论应该尽量缓存在cache当中。
以下两种sql语句的写法,第一种的查找方式更快
1.UPDATE StuInfo SET age=35 WHERE stuID=15;
2.UPDATE StuInfo SET age=35 WHERE name=‘BOb’
因为stuID是我们的主键,按照主键进行查找的话,就可以用我们的B+树的规则进行查找log_{M}N
但是如果查找条件是name的话,就需要将数据一条一条遍历,这样就非常缓慢。O(N)(全表扫描,我们尽量需要去避免)
如果经常想使用name进行查找怎么办?
可以用name字段创建一个索引。
MySQL创建索引的时候,可以指定是用B树创建索引还是用哈希表创建索引
CREATE INDEX nameIndex on stuInfo(name);
对name创建索引以后,相当于使用B+树用name做key穿件一棵树,
当然value只想磁盘数据,那么在执行sql语句,效率性就高了。
B+树做主键索引相比B树的优势
1.B+树所有的值都在叶子,遍历很方便,方便qujianchazhao。
2.对于没有建立索引的字段,全表扫描的遍历很方便
3.分支节点只存储key,一个分支节点空间占用更小,可以尽可能加载到缓存。
B树相比于B+树的优势
B树不用到叶子就能找到值,B+树一定要到叶子
但是B+树的高度足够低,所以差别不大
1.MyISAM
MyISAM引擎是MySQL5.5.8版本之前默认的存储引擎,不支持事物,支持全文检索,使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址,其结构如下:
方便索引树和主键树映射同样的数据。
上图是以以Col1为主键,MyISAM的示意图,可以看出MyISAM的索引文件仅仅保存数据记录的 地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果想在Col2上建立一个辅助索引,则此索引 的结构如下图所示:
同样也是一棵B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按 照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为 地址,读取相应数据记录。MyISAM的索引方式也叫做“非聚集索引”的。
(索引文件中存的是数据文件的地址)
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域。
(先用name,name对应主键id,再用主键id再去搜索一次,也就是说他用索引查找需要查找两次)
说明:B树节点数据都在磁盘文件中。访问节点都是IO行为,只是他们会热数据缓存到Cache中