【数据结构】从零开始认识B树 --- 高效外查找的数据结构 - 详解

在这里插入图片描述

挫折会来也会过去,
热泪会流下也会收起,
通过没有什么能够让我气馁的,
因为,我有着长长的一生。
--- 席慕蓉 《写给幸福》---

B树的概念

先前我们学习过的数据结构有红黑树,二叉搜索树,平衡搜索树,哈希表… 对于搜索问题,这几个数据结构各有优缺点

种类数据格式时间复杂度特点
顺序查找无要求O(N)优点是对数据格式无任何要求、搭建最简单;缺点是数据量越大效率越低,无法利用资料特征优化。
二分查找有序O(l o g 2 N log_2 Nlog2N)依赖有序材料,插入 / 删除后维持有序成本高。就是优点是时间复杂度低,仅需比较操作;缺点
二叉搜索树无要求O(N)优点是兼顾搜索与动态插入 / 删除;缺点是极端情况下退化为链表,效率骤降,稳定性差
二叉平衡树(AVL树和红黑树)无要求O(l o g 2 N log_2 Nlog2N)优点是处理了普通二叉搜索树的失衡问题,搜索、插入、删除均稳定在 O (log₂N);缺点是维护平衡的旋转运行艰难,实现成本高
哈希无要求O(1)优点是搜索效率理论上达到常数级,动态操作也高效;缺点是存在哈希冲突,需额外处理(如链地址法),无序存储无法支持范围查询。

同时,上面的数据结构处理的数据量不会很大,因为他们都必须在内存中构建相应的结构,然后在内存进行搜索。如果出现了100G,内存中无法正常储存时,那么想要应用以上的数据结构就不成立了!那如果我们想要搜索这些数据应该如何处理呢?
那么我们可以考虑将存放关键字及其映射的数据的地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取该地址去磁盘访问数据。
在这里插入图片描述
假如我们将平衡搜索树的节点换为数据储存的地址,那么会得到上图的树。上面的树层数只有3层,如果有100层,我们可以模拟一下搜索的过程:

  1. 原本可以直接从内存中读取出来的数据,现在得去磁盘进行一次IO
  2. 如果我们当前节点是位于叶子节点,此时就需要进行100次的磁盘IO,对应时间复杂度是O(l o g 2 N log_2 Nlog2N)
  3. 内存 IO 和磁盘 IO 的读写速度差距极大,通常在10 万倍到 100 万倍的量级,那么许可想象到这一次的搜索会花费大量的时间在磁盘IO上。

如果使用哈希表,是不是可能进行稳定O(1)的磁盘IO呢?必然是不可能的,哈希表中出现大量哈希冲突时,对于开散列版本的哈希表也是需要进行大量IO的

不能满足大量素材时的磁盘读取!所以对此就产生了一个独特的树:B树。专门用来解决大材料的磁盘搜索。就是显然,上述的数据结构都

1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树(后面有一个B的改进版本B+树,然后有些地方的B树写的的是B-树,注意不要误读成"B减树")。一棵m阶( m > 2 )的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足一下性质:

  1. 根节点至少有两个孩子
  2. 每个分支节点都包含 k - 1 个关键字和 k 个孩子,其中 ceil(m / 2) ≤ k ≤ m ,ceil是向上取整函数
  3. 每个叶子节点都包含 k - 1个关键字,其中 ceil(m / 2) ≤ k ≤ m
  4. 所有的叶子节点都在同一层
  5. 每个节点中的关键字从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域划分
  6. 每个结点的结构为:(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

通过这些性质乍一看很艰难,但其实很好理解,下面大家利用3阶的B树构建过程能够快速理解B树的结构

B树的插入分析

在这里插入图片描述

插入的步骤:

  1. 找到应该插入到的节点
  2. 将数据放入到该节点中关键字中
  3. 判断当前关键字是否满足m阶B树的要求,假如不满足需要进行分裂。
  4. 分裂的过程是将一半的内容分给新的brother节点,再将中间的关键字给于父节点(为了保证分裂可以均分子节点)。
  5. 对父节点进行同样的检查,父节点是根节点并且得分裂时需要特殊处理,构建一个新的根节点:
#pragma once
#include<iostream>
  #include<vector>
    #include<assert.h>
      using namespace std;
      namespace BTree {
      template<class K , size_t M>
        struct TreeNode {
        std::vector<K> keys;//储存的关键值
          std::vector<TreeNode<K , M>*> subs;//储存的子节点
            TreeNode<K, M>* parent;//父节点
              size_t size;//有效数据个数
              TreeNode() {
              keys.resize(M, K());//最多M个关键字
              subs.resize(M + 1, nullptr);//M+1个子节点
              size = 0;
              parent = nullptr;//初始父节点为空指针
              }
              };
              template<class K , size_t M>
                class BTree {
                public:
                typedef TreeNode<K, M> node;
                  //构造函数
                  BTree() : _root ( nullptr){}
                  //查找目标函数
                  std::pair<node*, int> find(const K& key) {
                    //从根节点开始寻找
                    node* cur = _root;
                    node* parent = nullptr;
                    while (cur != nullptr) {
                    //现在当前节点的keys关键字中寻找
                    size_t i = 0;
                    for (; i < cur->size; i++) {
                      if (key > cur->keys[i]) {
                      continue;
                      }
                      else if (key < cur->keys[i]) {
                        //说明不在当前节点中 需要向下寻找子节点
                        break;
                        }
                        else {
                        //找到了 - 返回当前节点与下标
                        return std::make_pair(cur, i);
                        }
                        }
                        //走入最后一个节点
                        parent = cur;
                        cur = cur->subs[i];
                        }
                        //没有找到 返回最后走入的节点(应该插入的节点)
                        return std::make_pair(parent, -1);
                        }
                        //节点插入数据
                        void InsertKey(node* cur, const K& key , node* child) {
                        //向cur中插入key
                        //找到key对应的位置
                        int end = cur->size - 1;
                        while (end >= 0) {
                        if (key < cur->keys[end]) {
                          //向后挪动
                          cur->keys[end + 1] = cur->keys[end];
                          cur->subs[end + 2] = cur->subs[end + 1];
                          end -= 1;
                          }
                          //找到合适位置
                          else {
                          break;
                          }
                          }
                          cur->keys[end + 1] = key;
                          cur->subs[end + 2] = child;
                          if (child)
                          {
                          child->parent = cur;
                          }
                          cur->size++;
                          }
                          //树中插入数据
                          bool Insert(const K& key) {
                          //如果是第一次插入 创建根节点
                          if (_root == nullptr) {
                          _root = new node();
                          _root->keys[0] = key;
                          _root->subs[0] = nullptr;
                          _root->size = 1;
                          _root->parent = nullptr;
                          return true;
                          }
                          //不是第一次插入数据 
                          //先判断是否已经存在
                          std::pair<node*, int> p = find(key);
                            if (p.second != -1) {
                            //说明已经插入过了
                            return false;
                            }
                            //没有插入过 那么find会返回应该插入到的叶子结点
                            node* cur = p.first;
                            K newKey = key;
                            node* child = nullptr;
                            //开始进行插入
                            while (true) {
                            InsertKey(cur, newKey, child);
                            //判断是否需要分裂
                            if (cur->size < M) {
                            //没有超出返回 成功插入
                            return true;
                            }
                            //该节点的数据满了 需要进行分裂
                            //1. 将一半的数据+子节点分给brother节点
                            //2. 将中间节点+brother给父节点
                            //3. 对父节点继续进行处理
                            node* brother = new node();
                            size_t mid = M / 2;
                            //迁移数据
                            //分裂一半[mid+1, M-1]给兄弟
                            size_t i = 0;
                            size_t j = mid + 1;
                            for (; j < M; j++ , i++) {
                            brother->keys[i] = cur->keys[j];
                            brother->subs[i] = cur->subs[j];
                            //子节点的父节点转移
                            if (cur->subs[j] != nullptr) {
                            cur->subs[j]->parent = brother;
                            }
                            //清空cur的数据
                            cur->keys[j] = K();
                            cur->subs[j] = nullptr;
                            }
                            //转移最后一个子节点
                            brother->subs[i] = cur->subs[j];
                            //子节点的父节点转移
                            if (cur->subs[j] != nullptr) {
                            cur->subs[j]->parent = brother;
                            }
                            //更新cur的数据
                            cur->subs[j] = nullptr;
                            brother->size = i;
                            cur->size -= (brother->size + 1);//更新数据量
                            //brother处理完成 向上处理
                            // 将中间节点给父节点
                            K midKey = cur->keys[mid];
                            cur->keys[mid] = K();//迁移原数据
                            //判断父节点是否存在
                            if (cur->parent == nullptr) {
                            //说明是根节点 需要新建一个新的根节点
                            _root = new node();
                            _root->keys[0] = midKey;
                            _root->subs[0] = cur;
                            cur->parent = _root;
                            _root->subs[1] = brother;
                            brother->parent = _root;
                            _root->size = 1;
                            _root->parent = nullptr;
                            return true;//完成插入
                            }
                            //不是根节点 就要继续向上处理
                            newKey = midKey;
                            child = brother;
                            cur = cur->parent;
                            }
                            }
                            private:
                            TreeNode<K, M>* _root;//根节点
                              };
                              }

对于一棵节点为N度为M的B-树,查找和插入需要l o g M − 1 N log{M-1}NlogM1N~l o g M / 2 N log{M/2}NlogM/2N次比较,这个很好证明:对于度为M的B-树,每一个节点的子节点个数为M/2 ~(M-1)之间,因此树的高度应该在要l o g M − 1 N log{M-1}NlogM1Nl o g M / 2 N log{M/2}NlogM/2N之间,在定位到该节点后,再采用二分查找的方式可以很快的定位到该元素。

B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024,则l o g M / 2 N log_{M/2}NlogM/2N<=4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找能够快速定位到该元素,大大减少了读取磁盘的次数。

B树的删除分析

B树的删除是一个很繁琐的过程:
核心原则是保证删除后每个节点(除根节点外)的关键字数量不低于 ⌈M/2⌉ - 1(下限),否则需要通过 “借兄弟节点” 或 “合并节点” 来维持平衡。以下是具体步骤
参考视频:B树删除

在这里插入图片描述

B+树与B*树

B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又在B树的基础上做了以下几点改进优化:

  1. 分支节点的子树指针与关键字个数相同
  2. 分支节点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间
  3. 所有叶子节点增加一个链接指针链接在一起
  4. 所有关键字及其映射数据都在叶子节点出现

在这里插入图片描述
互相相连的,更加便于遍历查找。就是与B树的最大区别就是非根节点的关键字与节点数是一样的,并且只有叶子结点储存数据。同时叶子节点

B+树的特性:

  1. 有序的。就是所有关键字都出现在叶子节点的链表中,且链表中的节点都
  2. 不可能在分支节点中命中。
  3. 存储数据的数据层。就是分支节点相当于是叶子节点的索引,叶子节点才

B树是B+树的变形,在B+树的非根和非叶子节点再增加指向兄弟节点的指针:
在这里插入图片描述
通过以上介绍,大致将B树,B+树,B
树总结如下:

  • B树:有序数组+平衡多叉树;
  • B+树:有序数组链表+平衡多叉树;
  • B*树:一棵更丰满的,空间利用率更高的B+树。

B树的应用

数据库索引是B树最核心的应用,之前在mysql文章提到过索引是依赖B树建立的。
通过B-树最常见的应用就是用来做索引。索引通俗的说就是为了方便用户快速找到所寻之物,比如:书籍目录能够让读者快速找到相关信息,hao123网页导航网站,为了让用户能够快速的找到有价值的分类网站,本质上就是互联网页面中的索引结构。

MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构,简单来说:索引就是数据结构。

当数据量很大时,为了能够方便管理数据,提高数据查询的效率,一般都会选择将资料保存到数据库,因此数据库不仅仅是支援用户管理材料,而且数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用数据,这样就可以在这些数据结构上实现高级查找算法,该数据结构就是索引。

mysql中主要的储存引擎有MyISAM 和 InnoDB 这两者的索引结构是不同的:

资料记录的地址,其结构如下:就是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的索引方式也叫做“非聚集索引”的

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域
在这里插入图片描述
聚集索引这种实现方式使得按主键的搜索十分高效,不过辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。

posted @ 2025-12-16 13:31  yangykaifa  阅读(4)  评论(0)    收藏  举报