前言
基本的搜索结构
种类 | 数据格式 | 时间复杂度 |
---|---|---|
顺序查找 | 无要求 | O(N) |
二分查找 | 有序 | O(log2 N) |
二叉搜索树 | 无要求 | O(N) |
二叉平衡树(AVL树 和 红黑树) | 无要求 | O(log2 N) |
哈希表 | 无要求 | O(1) |
位图 | 无要求 | O(1) |
布隆过滤器 | 无要求 | O(K),K为哈希函数的数量 |
对于顺序查找
,他的时间复杂度比较高,每次都整体遍历一遍,显然不太好;
而二分查找
虽然可以将时间复杂度变为log2N
,但是他的使用有一个前提就是必须有序(升序,降序都可);
对于二叉树类型的查找,先是二叉搜索树
,他的设计虽然是基于查找的,但是由于没有旋转的操作,就很容易造成所插入的数据只有一个单边的情况(顺序插入);在这之后有了基于旋转的AVL树
,以及优化后减少旋转次数的红黑树
为了可以快速,准确的直接找到数据,又出现了哈希表
,他可以将数据查找的时间复杂度近似于O(1),虽然很厉害,但是还是存在哈希冲突的问题,极端情况下可能还是需要查找N次;
在哈希表的设计之后,为了可以用更少的内存去存储大数据,使用一个位(1 bit = 8 byte)来表示一个数据,就有了哈希位图
布隆过滤器
,就是使用多个哈希表,对于同一个数据,对多个哈希表计算的位置进行插入,所以说时间复杂度就是哈希表的个数,但是他对于数据,所判断的结果只能是可能存在,或者一定不存在
B 树的由来
对于上面的数据结构,当我们的数据都在磁盘中的时候,因为数据的访问速度CPU -> 内存 -> 磁盘IO
。如果我们的数据在磁盘中存储,当使用红黑树这种数据结构的时候
1000 数据,最多需要读取磁盘 10 次
100w 数据,最多需要读取磁盘 20 次
10亿 数据,最多需要读取磁盘 30 次
这样看似很快,但是因为数据是存储在磁盘中,所以读取数据的速度相对来说还是很慢的,他的时间主要花费在了磁盘中的跳转中,因为地址都是随机,不连续的。
这就需要一种新的数据结构来表示,那些大佬们就设计出了B树,从此心中有了一个B数
B- 树
1970年,R.Bayer和E.mccreight 提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树
或者B-树
(不是B减树)。一棵M阶(M>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足一下性质:
- 根节点至少有两个孩子
- 每个非根节点至少有M/2(上取整)个孩子,至多有M个孩子
- 每个非根节点至少有M/2-1(上取整)个关键字,至多有M-1个关键字,并且以升序排列
- key[i]和key[i+1]之间的孩子节点的值介于key[i]、key[i+1]之间
- 所有的叶子节点都在同一层
他的大概思路就是,还是采用的树形数据结构,还是那个熟悉的搜索树的思路(比自己大的在右子树,比自己小的在左子树)。与之不同的是,对于树的每一个结点,以前的规定是只能存储一个数据,但是现在就让他可以存储多个数据,这样不就减少了频繁访问内存的次数。
然后对于他的孩子节点,比如说我们定义一个节点可以存储 M 个数据,那么就会产生 M+1 个范围,也就是 M + 1 个子节点的位置
对于B-树节点的描述,我们可以这样来
template<class T,int M = 3>
struct BTreeNode {
T _keys[M]; //存放元素
BTreeNode<T,M>* _pSub[M+1]; //存放孩子节点,比数据多一个
BTreeNode<T,M>* _pParent; //存放父亲节点,分裂后需要向上调整
size_t _size; //当前有效元素个数
BTreeNode()
:_pParent(nullptr),_size(0) {
//初始化每个孩子
for(size_t i = 0; i < M; i++) {
_pSub[i] = nullptr;
_keys[i] = T();
}
_pSub[M] = nullptr;
}
~BTreeNode() {
_pParent = nullptr;
for(size_t i = 0; i <= _size; i++) {
_pSub[i] = nullptr;
}
_size = 0;
}
};
B树的实现
先对整个B树进行一个描述,对于他的节点类型,我们使用typedef
进行一个简单的描述,bNode
template<class T,int M = 3>
class BTree {
public:
typedef BTreeNode<T,M> bNode;
BTree()
:_root(nullptr)
{}
~BTree() {
std::cout<< " clear " << std::endl;
clear(_root);
std::cout<<std::endl;
}
// B数的查找元素,返回值为元素所在的节点,以及元素在节点中的位置
pair<bNode*,int> Find(const T& key);
// 中序遍历
void Show();
// 插入元素
bool Insert(const T& key);
private:
// 中序遍历
void _InorderBTree(bNode* root);
// 插入排序,插入一个key
void _InsertKey(bNode* cur,const T& key,bNode* sub);
// 清理函数
void clear(bNode* root);
private:
bNode* _root;
};
因为他的本质还是一个二叉树的形式,那么比较简单的一部分就是他的打印了,按照他的设计来看,那么他的中序遍历的结果就是一个有序的集合
遍历
对于遍历,我们采用中序遍历的方式
public:
// 中序遍历
void Show() {
_InorderBTree(_root);
}
private:
// 中序遍历
void _InorderBTree(bNode* root) {
if(root == nullptr) return ;
for(size_t i = 0; i < root->_size; i++) {
_InorderBTree(root->_pSub[i]);
std::cout<< root->_keys[i] << " ";
}
_InorderBTree(root->_pSub[root->_size]);
}
查找
对于查找而言,维护两个节点的指针,父亲节点和对应的孩子节点。规定返回值是一个pair的数组,其中一个值是查找数据所在的节点,另一个就是查找数据在这个节点中的下标。
我们规定如果下标为-1,那么就说明这次查找没有找到,也就是这个数据不存在
// B数的查找元素,返回值为元素所在的节点,以及元素在节点中的位置
pair<bNode*,int> Find(const T& key) {
bNode* cur = _root;
bNode* parent = nullptr;
int pos = 0;
while(cur) {
pos = 0;
while(pos < cur->_size) {
if(key == cur->_keys[pos]) {
return make_pair(cur,pos);
} else if (key < cur->_keys[pos]) {
break; //B数节点按升序排列
}
//没找到顺序后延
pos++;
}
parent = cur;
cur = cur->_pSub[pos];
}
//没有找到
return make_pair(parent,-1);
}
插入
先用一组数据来模拟一下插入的过程
53, 139, 75, 49, 145, 36, 101 构建一个 M = 3 的B树
插入53,139后
插入75 (调整)
- 插入的过程
- 向上调整
插入49,145
插入36
- 没有调整前
- 调整后
插入101
- 没有进行调整前
- 第一次调整后(根节点不满足条件了)
- 第二次调整
总结
- 如果树为空,直接插入新节点中,该节点为树的根节点
- 树非空,找待插入元素在树中的插入位置 (找到的插入节点位置一定在叶子节点中)
- 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入,find函数的第二个返回值为-1)
- 按照插入排序的思想将该元素插入到找到的节点中
- 检测该节点是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足
- 如果插入后节点不满足B树的性质,需要对该节点进行分裂:
先申请新节点,再找到该节点的中间位置,再将该节点中间位置右侧的元素以及其孩子搬移到新节点中,最后将中间位置元素以及新节点往该节点的双亲节点中插入,即继续4的循环 - 如果向上已经分裂到根节点的位置,插入结束
// 插入元素
bool Insert(const T& key) {
if (_root == nullptr) {
_root = new bNode();
_root->_keys[0] = key;
_root->_size++;
return true;
}
// 先找到插入位置
pair<bNode*,int> ret = Find(key);
if (-1 != ret.second) {
// 插入的该元素已经存在,不需要插入
return false;
}
T tKey = key;
bNode* tNode = nullptr; //孩子节点
bNode* cur = ret.first; //插入位置节点
while (true) {
_InsertKey(cur,tKey,tNode);
if (cur->_size < M) {
return true; //插入成功
}
// 当前节点已经满了,需要向上分裂
tNode = new bNode();
int mid = M>>1; //向上分裂一半
for (size_t i = mid+1; i < cur->_size; i++) {
tNode->_keys[tNode->_size] = cur->_keys[i];
tNode->_pSub[tNode->_size++] = cur->_pSub[i];
if(cur->_pSub[i] != nullptr) {
cur->_pSub[i]->_pParent = tNode;
}
}
// 孩子节点比关键字多一个
tNode->_pSub[tNode->_size] = cur->_pSub[cur->_size];
if (cur->_pSub[cur->_size] != nullptr) {
cur->_pSub[cur->_size]->_pParent = tNode;
}
// 更新 cur 节点剩余节点的个数
cur->_size -= tNode->_size + 1;
//向上分裂
if (cur == _root) {
_root = new bNode();
_root->_keys[0] = cur->_keys[mid];
_root->_pSub[0] = cur;
_root->_pSub[1] = tNode;
_root->_size = 1;
cur->_pParent = tNode->_pParent = _root;
return true;
} else {
// 继续循环进行插入
tKey = cur->_keys[mid];
cur = cur->_pParent;
}
}
return true;
}
总结,结果验证
#include "head.hpp"
#include <time.h>
void test() {
srand((int)time(0));
dcl::BTree<int> bt;
for (size_t i = 0; i < 100; i++) {
bt.Insert(rand() % 1000);
}
bt.Show();
std::cout<<std::endl;
}
int main() {
test();
return 0;
}
采用模拟大数据的形式,以随机数来对结果进行验证,主要是插入和打印,以及结果的释放,是否有内存泄漏