目录
种类 | 数据格式 | 时间复杂度 |
---|---|---|
顺序查找 | 无要求 | |
二分查找 | 有序 | |
二叉搜索树 | 无要求 | 到 |
二叉平衡树(AVL树和红黑树) | 无要求 | |
哈希 | 无要求 |
以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景(内查找)
存在问题
若数据量很大,假设有100G数据,无法一次放进内存中,那就只能放在磁盘上了。若放在磁盘上,有时需要搜索某些数据,那么该如何处理呢?
可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中。要访问数据时,取这个地址去磁盘访问数据
但是这么做依然存在问题
使用二叉平衡树的缺陷
平衡二叉树搜索树的高度是,这个查找次数在内存中是很快的。但是当数据都在磁盘中时,访问磁盘速度很慢,在数据量很大时,次的磁盘访问,是一个难以接受的结果
注意:一旦读/写磁头正确定位,并且盘片已经旋转到所要页面的开头位置,对磁盘的读写就完全电子化了(除了磁盘的旋转外),磁盘能够快速地读写大量的数据。所以IO次数是影响效率的重要因素
使用哈希表的缺陷
哈希表的效率很高是,但是一些极端场景下某个位置冲突很多,导致访问次数剧增,也是难以接受的
该如何加速对数据的访问呢?
1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(有些地方的B树写的的是B-树,注意不要误读成"B减树")
一棵m阶(m>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足以下性质:
为了方便讲解,这里B-树的阶数取小一点,即3
即三阶B-树(三叉平衡树),那每个结点最多存储两个关键字,两个关键字可以将区间分割成三个部分,因此节点应该有三个孩子(子树)
为了后续实现简单,关键字和孩子都多给一个空间,结点的结构如下:
下面使用序列{53,139,75,49,145,36,101}分析一下插入的过程
插入53
此时满足B-树性质,无需改变
插入139
关键字采用升序排序。此时满足性质
插入75
为什么要多开一个空间?可以在插入之后关键字顺序已经调整好的情况下去分裂,实现会方便很多
分裂结点
上面的规则中为什么要求除根结点外的所有非叶子结点都包含 个关键字,,即k的最小值是 ,即结点最少包含 个关键字
若m是奇数:9,那 是5,5-1是4。9个分裂之后正好两边每个结点都是4个关键字,中间的一个提取给父亲
若是偶数:10,那 是5,5-1是4。10个分裂的话,一边4个(最少的),一边5个,还有一个中间值提取给父亲,所以最少就是 个关键字
插入49,145
插入36
此时36插入的这个结点又满了,需要进行分裂
新增一个兄弟结点之后,相当于父亲结点就多了一个孩子,所以也需要增加一个关键字(关键值始终比孩子少一个),就把中间值提给父亲结点。49上提插入到父亲,它比75小,所以75往后移(它的孩子也跟着往后移),然后49插入到前面
插入101
插入后结点的关键字数量大于m-1,进行分裂
向上插入后,发现父结点也满了,所以还需继续分裂
B-树每一次插入之后都是天然的完全平衡,不像红黑树AVL树那样,插入之后不满足平衡条件了,再去调整。并且B-树的平衡是绝对平衡。每一棵树的左右子树高度之差都是0
为什么他能保持天然的完全平衡呢?
通过上面的插入过程可以发现B-树是向右和向上生成的,只会产生新的兄弟和父亲
M叉树:即一个节点最多有M个孩子,M-1个数据域
为实现简单期间,数据域与孩子与多增加一个(原因参见上文对插入过程的分析)
- template<class K, size_t M>
- struct BTreeNode
- {
- BTreeNode():_parent(nullptr), _number(0) {
- for (int i = 0; i < M; ++i) {
- _keys[i] = K();
- _childs[i] = nullptr;
- }
- _childs[M] = nullptr;
- }
-
- //为了方便插入后再进行分裂操作,多给一个空间
- K _keys[M];
- BTreeNode
* _childs[M + 1]; - BTreeNode
* _parent; - size_t _number; //记录此时实际存储了多少个关键字
- };
在插入之前需先找到,待插入关键字在树中的插入位置
- //Node*指向找到的结点,int为该元素在该结点中的位置
- pair
int > Find(const K& key) - {
- Node* parent = nullptr;
- Node* current = _root;
- int index = 0;
- while (current)//若结点存在
- {
- //在结点的值域中查找
- index = 0;
- while (index < current->_number)
- {
- if (key == current->_keys[index])
- return make_pair(current, index);
- else if (key < current->_keys[index])
- break; //该元素可能在i的左孩子结点中
- else ++index;
- }
- //在current中没有找到,到current结点的第index个孩子中查找
- parent = current;
- current = current->_childs[index];
- }
- return make_pair(parent, -1);
- }
按照插入排序的思想插入key
- void InsertKey(Node* current, const K& key, Node* child)
- {
- int end = current->_number - 1;
- while (end >= 0)
- {
- if (key < current->_keys[end])
- {
- //挪动key和其右孩子
- current->_keys[end + 1] = current->_keys[end];
- current->_childs[end + 2] = current->_childs[end + 1];
- --end;
- }
- else break;
- }
- current->_keys[end + 1] = key;
- current->_childs[end + 2] = child;
- if(child)//若key的child不为空,则链接child的父指针
- child->_parent = current;
- ++current->_number;
- }
- bool Insert(const K& key)
- {
- if (nullptr == _root)
- {
- _root = new Node;
- _root->_keys[0] = key;
- ++_root->_number;
- return true;
- }
-
- //找插入位置,若该元素已经存在,则不插入
- pair
int> ret = Find(key); - if (-1 != ret.second) return false;
-
- Node* current = ret.first;//待插入的叶子结点
- K newKey = key;
- Node* child = nullptr;
-
- //可能多次分裂,一直向上插入
- while (true)
- {
- InsertKey(current, newKey, child);
- //若没有满则操作结束
- if (current->_number < M) return true;
-
- //若满了则进行分裂
- Node* brother = new Node;
- size_t mid = (M >> 1);
- size_t i = mid + 1, j = 0;
- for (; i <= M - 1; ++i)
- {
- brother->_keys[j] = current->_keys[i];
- brother->_childs[j++] = current->_childs[i];
- if (current->_childs[i])
- current->_childs[i]->_parent = brother;
- //拷贝完成后进行重置,便于观察
- current->_keys[i] = K();
- current->_childs[i] = nullptr;
- }
- //孩子比关键字多搬移一个
- brother->_childs[j] = current->_childs[i];
- if (current->_childs[i])
- current->_childs[i]->_parent = brother;
- current->_childs[i] = nullptr;
-
- brother->_number = j;
- current->_number -= (brother->_number + 1);
-
- K midKey = current->_keys[mid];
- current->_keys[mid] = K();
-
- //若分裂的节点为根节点,重新申请一个新的根节点
- //将中间位置数据以及分裂出的新节点插入到新的根节点中,插入结束
- if (current == _root)
- {
- _root = new Node;
- _root->_keys[0] = midKey;
- _root->_childs[0] = current;
- _root->_childs[1] = brother;
- _root->_number = 1;
- current->_parent = _root;
- brother->_parent = _root;
- break;
- }
- //若分裂的节点不是根节点,将中间位置数据以及新分裂出的节点继续向current的双亲中进行插入
- else
- {
- newKey = midKey;
- child = brother;
- current = current->_parent;
- }
- }
- return true;
- }
使用监视窗口查看
对B树进行中序遍历,若能得到一个有序的序列,说明插入正确
- void _InOrder(Node* root)
- {
- if (nullptr == root) return;
- for (int i = 0; i < root->_number; ++i) {
- _InOrder(root->_childs[i]);
- cout << root->_keys[i] << " ";
- }
- _InOrder(root->_childs[root->_number]);
- }
含n个关键字的m阶B树,最小高度、最大高度是多少?
最小高度
n个关键字的m阶B树,关键字个数和B-树的阶数已经确定的话,若要让高度最小,就要让每个结点存的关键字最满
那对于m阶的B树来说,每个结点最多m-1个关键字,m个孩子。第一层肯定只有一个根结点(最满即为m-1个关键字,m个孩子),那第二层最多就有m个结点,每个结点最多m-1关键字,那第三层就是m*m个孩子,以此类推…...
假设高度为h,关键字的总个数就等于:
即 ,解得最小高度
最大高度
要让树变得尽可能高的话,那就要让每个结点得关键字数量尽可能少(分支尽可能少)
第一层只有一个根结点(关键字最少是1,孩子是2),根结点最少两个孩子,所以第二层2个结点。又因为除了根结点之外的结点最少有 个孩子,所以第三层就最少有 个结点,第四层就是 ,以此类推…...第h层就是 个结点
所以得出:,解得最大高度
B-树的效率是很高的,对于N = 62*1000000000个节点,若度M为1024。查找最坏就是高度次,,。即在620亿个元素中,若这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数
B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又在B树的基础上做了一些改进优化。
一棵m阶的B+树需满足下列条件:
B+树特性:
B+树与B树对比
B*树是B+树的变形,在B+树的非根和非叶子结点再增加指向兄弟结点的指针
B+树的分裂:
当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以不需要指向兄弟的指针
B*树的分裂:
当一个结点满时,若它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);若兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针
所以B*树分配新结点的概率比B+树要低,空间使用率更高
(内查找时)B树系列与哈希、平衡搜索树对比
单论树的高度以及查找效率,B树系列确实不错,但是B树系列也有一些缺点:
结论:实质上B树系列在内存中体现不出优势,更适合进行外查找