b-tree与b+tree的背景:
最近了解了一些关于b-tree和b+tree的方面的知识,改变了我对这两个数据结构的理解。平时我们运行程序的时候一般来说,变量的访问都是访问内存的,那么以前就在想既然有了rb-tree和avl-tree,为什么还要设计b-tree甚至是b+tree呢?如果都是可以在内存里面访问,那么在查找效果上rb-tree、avl-tree与b-tree、b+tree的效率其实也差不多。这里的问题就在于b-tree、b+tree的特性与它的使用背景。相比之下b-tree、b+tree对于rb-tree、avl-tree来说树的高度可以通过分裂因子来控制,所以可以使得高度没有那么高。那么高度小了有什么好处呢?高度小,那么查找的树的结点的次数就变少了。使用b-tree和b+tree的背景就是和查找结点相关的,对于关系型数据库例如mysql它的底层设计是b+tree。b+tree的每个结点都有对应的磁盘块地址,当我们访问一个结点的时候其实就是先用磁盘块的地址找到这块结点的数据,然后把结点的数据放入内存给我们的程序访问。这样子就知道为什么关系型数据库为什么要用b+tree来组织数据了吧。因为树高减少了,需要访问的结点的次数减少了,所以访问磁盘的次数也就减少了。内存的访问速度相对于磁盘的访问速度来说几乎可以忽略不计,所以一个结点中的查找元素范围的效率肯定比查找结点的效率高很多,那么结点的访问次数就是最主要效率问题了。那么还有hash,hash的效率也很高,为什么不用这个作为关系型数据库的数据组织结构了。这是因为hash无法支持范围查找。
通常mysql数据库的每个结点对应于一个16k的数据页,操作系统的磁盘读写的磁盘块大小与文件系统设置的块大小有关,一般是4k,这个是操作系统读取磁盘的最小单位。
M阶b-tree的定义:
1、每个结点至多拥有M棵子树。
2、根结点至少拥有两棵子树。
3、除了根结点以外,其余每个分支结点至少拥有M/2棵子树。
4、所有叶子结点都在同一层上。
5、有k棵子树的分支结点则存在k-1个关键字,关键字按照递增顺序进行排序。
6、关键字的数量满足ceil(M/2)-1 <= n <= M-1。
7、在一个结点中,当1 < i <= M时,k_i(第i个关键字)的值大于p_i(指向第i棵子树的指针)所拥有的所有关键字。
b+tree相对于b-tree的区别:
1、所有数据都存储在叶子结点,非叶子结点只存储索引。
2、所有的叶子结点都用前后指针链接起来。
下面就只讨论b-tree了。
b-tree的结点结构:
b-tree的结点有一个k数组、一个p数组、一个表示这个结点有多少个关键字的字段n和一个表示是否叶子结点的字段is_leaf。
上图是一个4阶的b-tree通常阶数不是这样子定义的而是用2t来表示,t是一个大于0的整数。
4阶其实就是t=2时的阶数。用2t来表示阶数主要是可以表示阶数是个偶数,分裂结点的时候比较方便。k数组时用于存储key关键字,而数组p就是用于存储子树的指针。
由上面的结构可以看出每个子树就是一个有序范围,而子树与子树之间存在一个用于分隔范围的分界线。
b-tree的结点分裂:
上图时一个t=3的6阶b-tree。由于找到要插入的结点有可能已经满了(关键字个数为2t-1),所以这个结点需要分裂。分裂过程就是把要分裂的结点的第t个关键字上提到父结点,原来的结点分裂成左右两个结点,左结点存储第1个关键字到第t-1个关键字,右结点存储第t+1个关键字到第2t-1个关键字。如果要分裂的结点不是一个叶子结点,那么还要把要分裂的结点的第t棵子树放到左结点的最右边,第t+1棵子树放到右结点的最左边。由于分裂后,第t个关键字上提到父结点,那么如果上提之前父结点的关键字个数已经时2t-1个了那么这个再处理父结点的分裂就很麻烦了。解决方案就是查找插入位置结点的时候,只要经过的结点的关键字个数已经时2t-1个了,那么就开始分裂。分裂函数的参数分别是:要分裂的结点的父结点的指针和要分裂的结点在父结点的第几个位置。例如上图插入L的过程,在查找的时候先经过根结点,这个时候根结点已经是2t-1个关键字了,所以开始分裂。注意根结点的分裂与普通的结点分裂不一样,是先创建一个空结点,把空结点的第1个孩子指针指向根结点,然后把根结点指针指向空结点,然后传入现在的根结点指针与参数1(因为在第1个孩子)来进行分裂。
b-tree关键字的插入:
向b-tree插入结点,先判断b-tree的根结点的关键字个数是否为2t-1,如果否,那么向根结点插入一个关键字(这个时候根结点不满2t-1),如果是,那么按照上述方式分裂根结点,然后向新的根结点插入关键字(这个时候新的根结点不满2t-1)。然后就是讨论向一个不满2t-1的结点插入关键字的过程了。首先判断这个结点是否为叶子结点,如果是那么直接插入关键字到对应位置,否则查找新关键字应该插入的子树的根结点(当前结点子结点)。如果子树的根结点的关键字个数不足2t-1,那么向子树插入新关键字,否则,按照上述方式分裂,然后如果新关键字比新提上来的关键字k_i(第i个关键字)小那么向当前节点的第i棵子树插入新关键字,否则向第i+1棵子树插入新关键字。向某个结点插入新关键字这个操作的 前提是这个结点的关键字个数不足2t-1。
b-tree的结点合并:
当判断P1关键字C的左孩子结点的关键字个数等于t-1而且右孩子结点的关键字个数也等于t-1的时候,就合并了。把关键字C加到结点P2的末尾,然后把结点P3的关键字和子结点加到结点P2的对应位置,然后就可以释放P3了。
b-tree的关键字删除:
b-tree的关键字删除是从根结点开始查找要删除的关键字,如果关键字不等于当前结点(第一次是根结点)上的关键字,那么在对比当前结点的关键字的时候就可以确认要删除的关键字位于哪棵子树上,那么就可以递归在这棵子树上删除关键字。
在查找的过程中遇到两种情况:
1、要删除的关键字就在当前结点上。
2、要删除的关键字不在当前结点上。
对于情况1,如果当前结点是叶子结点,那么就可以直接删除关键字,否则,需要判断要删除的关键字的左右子结点的关键字数量,情况如下:
a、如果左子结点的关键字数量大于t-1,那么查找以这个左子结点为根结点的b-tree的最大关键字(这个关键字必定在叶子结点上),然后对左子树递归调用删除这个最大关键字操作。然后把原来要删除的关键字替换为这个最大关键字。
b、如果右子结点的关键字数量大于t-1,那么查找以这个右子结点为根结点的b-tree的最小关键字(这个关键字必定在叶子结点上),然后对右子树递归调用删除这个最小关键字操作。然后把原来要删除的关键字替换为这个最小关键字。
c、如果左右子结点的关键字数量都等于t-1,那么就对当前结点的要删除的关键字的左右子树合并。合并完之后,要删除的关键字原来这个关键字的左子结点上(这个时候右结点已经不存在了),然后对这个左子结点递归调用删除要删除的关键字。
对于情况2,那么就先查找要删除的关键字在哪棵子树上,然后判断这个子树的根结点point_s_r的关键字数量,如果关键字数量等于t-1,那么就要判断这个根结点的左右兄弟结点的关键字数量了,处理完下面的情况之后,对point_s_r递归调用删除要删除的关键字,否则,也是对point_s_r递归调用删除要删除的关键字。
a、如果左兄弟结点point_l_b的关键字数量大于t-1,那么把point_l_b的最右边关键字替换到在当前结点上的处于这个point_l_b与point_s_r之间的关键字k,然后把关键字k移动到point_s_r的最左边,同时把point_l_b的最右边子树移动到point_s_r的最左边。
b、如果右兄弟结点point_r_b的关键字数量大于t-1,那么把point_r_b的最左边关键字替换到在当前结点上的处于这个point_r_b与point_s_r之间的关键字k,然后把关键字k移动到point_s_r的最右边,同时把point_r_b的最左边子树移动到point_s_r的最右边。
c、如果左右兄弟结点的关键字数量都是等于t-1,那么可以对当前结点的某个关键字k(右兄弟节点与point_s_r之间的关键字)的左右子树合并。合并完之后,关键字k已经下移到point_s_r。