B树
- 一、概念性问题
- 1、前置知识:常见搜索结构
- 2、常规使用数据结构缺陷问题
- 3、B树概念
- 4、存放数量分析
- 二、代码实现逻辑
- 1、结点定义和基本框架
- 2、Find查找函数
- (1)思想
- (2)代码实现
- 3、InsertKey插入关键字函数--InsertKey(Node* node, const K& key, Node* child)
- (1)解析
- i、例子一:刚开始插入第一个数情况
- ii、例子二:叶子结点插入值但此时插入的叶子结点未满的情况
- iii、例子三:叶子结点插入并且此时插入的叶子结点满了的情况
- iv、例子四:非叶子节点插入情况
- (2)代码实现
- 4、Insert插入函数--bool Insert(const K& key)
- (1)实现思想
- i、第一种情况:根节点为空,插入这个结点
- ii、第二种情况:插入当前M刚好满的情况其父亲为空
- ii、第三种情况:插入当前M满了以后其父亲不为空
- (2)代码实现
- 5、中序遍历
- (1)原理讲解
- (2)代码实现
- (3)展示成果
- 6、删除(只有思路没有代码)
- (1)当前删除值是叶子结点且值的多少是>M/2的
- (2)当前删除值是叶子结点且值的多少是<M/2的且父亲结点数量大于M/2或兄弟结点数量大于M/2
- (3)当前删除值是叶子结点且值的多少是<M/2的且父亲结点和兄弟结点数量小于M/2
- (4)当前删除值是非叶子节点(数量够就借,不够就合并)
心中有B树,面试自然神~~
一、概念性问题
1、前置知识:常见搜索结构
种类 | 数据格式 时间复杂度 |
---|---|
顺序查找 | 无要求 O(N) |
二分查找 | 有序 O( l o g 2 N log_2 N log2N) |
二叉搜索树 | 无要求 O(N) |
二叉平衡树(AVL树和红黑树) | 无要求 O( l o g 2 N log_2 N log2N) |
哈希 | 无要求 O(1) |
以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘上,有需要搜索某些数据,那么如果处理呢?那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。
2、常规使用数据结构缺陷问题
使用平衡二叉树搜索树的缺陷:
平衡二叉树搜索树的高度是logN,这个查找次数在内存中是很快的。但是当数据都在磁盘中时,访问磁盘速度很慢,在数据量很大时,logN次的磁盘访问,是一个难以接受的结果。
使用哈希表的缺陷:
哈希表的效率很高是O(1),但是一些极端场景下某个位置冲突很多,导致访问次数剧增,也是难以接受的。那如何加速对数据的访问呢?1. 提高IO的速度(SSD相比传统机械硬盘快了不少,但是还是没有得到本质性的提升)2. 降低树的高度—多叉树平衡树
3、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。
4、存放数量分析
这个是最满的情况:
最空和最满对比分析:
二、代码实现逻辑
1、结点定义和基本框架
// B树的节点
template<class K, size_t M>
class BTreeNode
{
public:
// 为了方便插入以后再分裂,我们多给一个空间
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:
// ...
private:
Node* _root;
};
2、Find查找函数
(1)思想
我们用下面的逻辑树来进行模拟一下查找过程:
总共有三种情况:
第一种情况是:刚好找到的结点正好是需要返回的值,那么就返回make_pair(cur, i) // 结点和下标。
第二种情况是:比当前点的值小,那么就往它的孩子节点走,先更新parent到孩子节点,再将当前节点变成其左孩子下标即可。
第三种情况是:比当前点的值大,关键字往后走即可。i++。
(2)代码实现
// 寻找一个结点
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 = cur;
cur = cur->_subs[i];
}
return make_pair(parent, -1); // 直接返回不相关的值-1即可
}
3、InsertKey插入关键字函数–InsertKey(Node* node, const K& key, Node* child)
(1)解析
先在当前的结点进行移动,关键字比当前节点小的话就往后移动直到遇到比关键字大的那个值,插入进去,此时再改变父亲和孩子的关系,父亲的第二个值都往后移动了,那么其右孩子需要继续往后移动。
其实说白了这个插入关键字的结点的函数只是插入函数的分支,上面逻辑函数包含了分裂的相关知识,我们下面用几个插入例子来进行模拟一下我们插入逻辑:
i、例子一:刚开始插入第一个数情况
这个是放到insert函数中的。
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n++;
return true;
}
ii、例子二:叶子结点插入值但此时插入的叶子结点未满的情况
iii、例子三:叶子结点插入并且此时插入的叶子结点满了的情况
iv、例子四:非叶子节点插入情况
例子三和例子四可以合并再一起。
(2)代码实现
// 结点 关键字 孩子
void InsertKey(Node* node, const K& key, Node* child)
{
int end = node->_n - 1; // 用end设置为最后一个元素
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++;
}
4、Insert插入函数–bool Insert(const K& key)
(1)实现思想
i、第一种情况:根节点为空,插入这个结点
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n++;
return true;
}
ii、第二种情况:插入当前M刚好满的情况其父亲为空
ii、第三种情况:插入当前M满了以后其父亲不为空
(2)代码实现
// 插入函数
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;
}
// 循环每次往cur插入 newkey和child
Node* parent = ret.first;
K newkey = key;
Node* child = nullptr;
while (1)
{
// 先插入key值
InsertKey(parent, newkey, child);
// 满了就要分裂
// 没有满,插入就结束
if (parent->_n < M)
{
return true;
}
else
{
size_t mid = M / 2; // 分裂一半给兄弟
// 分裂一半 [mid + 1, M - 1]给兄弟
Node* brother = new Node; // 先new一个brother
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
{
newkey = midKey;
child = brother;
parent = parent->_parent;
}
}
}
return true;
}
5、中序遍历
(1)原理讲解
(2)代码实现
// 中序遍历子函数
void _OrderPrint(Node* root)
{
if (root == nullptr)
return;
size_t i = 0;
for (; i < root->_n; i++)
{
// 往左边走
//root = root->_subs[i];
_OrderPrint(root->_subs[i]);
std::cout << root->_keys[i] << " ";
}
// 往最后一个右子树走
_OrderPrint(root->_subs[i]);
}
// 中序遍历
void OrderPrint()
{
return _OrderPrint(_root);
}
(3)展示成果
打印的完全没毛病!
6、删除(只有思路没有代码)
记住口诀:父亲往他的父亲借是肯定能给的,而往小儿子借不一定给(小儿子数量小于M/2)!
(1)当前删除值是叶子结点且值的多少是>M/2的
(2)当前删除值是叶子结点且值的多少是<M/2的且父亲结点数量大于M/2或兄弟结点数量大于M/2
(3)当前删除值是叶子结点且值的多少是<M/2的且父亲结点和兄弟结点数量小于M/2
(4)当前删除值是非叶子节点(数量够就借,不够就合并)
图的实现这里不放了,有点多…