java b tree_深入学习B-Tree和B+Tree

温馨提示:看完本大约需要12.4min~

摘要:

B-Tree:即BTree,或者B树,可不要读作B减树让人笑话啦。它是多路搜索树(不是二叉的)。

B+Tree:B+Tree也是多路搜索树,是B-Tree的变体。

二叉查找树:二叉查找树也叫二叉排序树,所以,它是有顺序的,每一个节点,它的左子树每个节点值都要小于该节点值,它的右子树每个节点值都要大于该节点值。

平衡二叉树:AVL树,在符合二叉查找树的前提下,任意节点的两个子树深度查不大于1。

平衡多路查找树:大致了解以上概念,平衡多路查找树也就不难理解了。B-Tree是平衡多路查找树。

完全二叉树:若设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。

满二叉树:满二叉树即字面的意思,满二叉树是一颗特殊的完全二叉树。每一个非叶节点都有两个子节点,所有的叶节点都在同一层。

前言:本文主要将B-Tree、B+Tree,在学习它们我们可能需要了解一些常见的数据结构,如二叉树,二叉查找树,平衡二叉树等。

一、二叉查找树(binary search tree)

二叉树:任意节点最多有两个子节点的树,我们称之为二叉树。

二叉查找树:左子树的健小于跟节点的键值,右子树的键值大于跟节点的键值。

二叉查找树示意图:

a80f23e6c3b5dd0b54387e1582fc05ec.png

对二叉查找树的节点进行查找发现:深度为1的节点的查找次数为1,深度为2的查找次数为2,深度为n的节点的查找次数为n,因此其平均查找次数为(1+2+2+3+3+3) / 6 = 2.3次。

二叉查找树可以任意构造,同时也可以是一种链式结构,这种结构比较特殊,查找效率比较慢。所以想提高查找效率,由此便提出了平衡二叉树,也称作AVL树。

二、平衡二叉树(AVL Tree)

简单看一下平衡二叉树(AVL Tree)和非平衡二叉树的结构图:

60e7c56fe71402acc1cdc96247613bfa.png

如果我们对AVL树进行插入节点或删除节点操作,那么可能会导致AVL树失去平衡,这种失去平衡的二叉树已经不能再称之为AVL树。示例:在上图的AVL树中,如果我们将值为7的节点进行DELETE,那么,这颗AVL树就会失去平衡。

AVL树失去平衡后,要保持平衡可不是一件容易的事情,我们可以通过旋转将其恢复平衡。

AVL树的旋转类型有4中,LL、LR、RL、RR,稍后介绍具体该如何进行旋转。

其实,每个节点都有一份隐含的信息,那就是节点的平衡因子。

节点的平衡因子 = 左子树高度 - 右子树高度。

所以这里的另一个问题便得到了解决。什么时候进行旋转操作达到平衡?或者换一种问法,如何才知道我们的树失去了平衡?当树中存在节点的平衡因子不是-1、0、+1时,那就是失去了平衡,就需要进行调整,达到平衡。保证每个节点平衡,以局部平衡保持整体平衡。

8b50522daf038a310e68854115334db6.png

为方便理解在何时执行哪一种旋转,设x代表刚插入AVL树中的结点,设A为离x最近且平衡因子更改为2的绝对值的祖先。

LL旋转

当x位于A的左子树的左子树上时,执行LL旋转。

步骤:设left为A的左子树,要执行LL旋转,将A的左指针指向left的右子结点,left的右指针指向A,将原来指向A的指针指向left。

旋转过后,将A和left的平衡因子都改为0。所有其他结点的平衡因子没有发生变化。

0e8ca2e614f194b74c879cbd29ea5b20.png

LR旋转

当x位于A的左子树的右子树上时,执行LR旋转。

设left是A的左子结点,并设A的子孙结点grandchild为left的右子结点。

步骤:要执行LR旋转,将left的右子结点指向grandchild的左子结点,grandchild的左子结点指向left,A的左子结点指向grandchild的右子结点,再将grandchild的右子结点指向A,最后将原来指向A的指针指向grandchild。

执行LR旋转之后,调整结点的平衡因子取决于旋转前grandchild结点的原平衡因子值。

如果grandchild结点的原始平衡因子为+1,就将A的平衡因子设为-1,将left的平衡因子设为0。

如果grandchild结点的原始平衡因子为0,就将A和left的平衡因子都设置为0。

如果grandchild结点的原始平衡因子为-1,就将A的平衡因子设置为0,将left的平衡因子设置为+1。

在所有的情况下,grandchild的新平衡因子都是0。所有其他结点的平衡因子都没有改变。

f866cf6ab4f63c9943fa22f5b75d4c8b.png

RL旋转

当x位于A的右子树的左子树上时,执行RL旋转。

RL旋转与LR旋转是对称的关系。

步骤:设A的右子结点为right,right的左子结点为grandchild。要执行RL旋转,将right结点的左子结点指向grandchild的右子结点,将grandchild的右子结点指向right,将A的右子结点指向grandchild的左子结点,将grandchild的左子结点指向A,最后将原来指向A的指针指向grandchild。

执行RL旋转以后,调整结点的平衡因子取决于旋转前grandchild结点的原平衡因子。这里也有三种情况需要考虑:

如果grandchild的原始平衡因子值为+1,将A的平衡因子更新为0,right的更新为-1;

如果grandchild的原始平衡因子值为 0,将A和right的平衡因子都更新为0;

如果grandchild的原始平衡因子值为-1,将A的平衡因子更新为+1,right的更新为0;

在所有情况中,都将grandchild的新平衡因子设置为0。所有其他结点的平衡因子不发生改变。

fd40275dc5278ffc051a9b0d302480b0.png

RR旋转

当x位于A的左子树的右子树上时,执行RR旋转。

RR旋转与LL旋转是对称的关系。

设A的右子结点为Right。要执行RR旋转,将A的右指针指向right的左子结点,right的左指针指向A,原来指向A的指针修改为指向right。

完成旋转以后,将A和left的平衡因子都修改为0。所有其他结点的平衡因子都没有改变。

f1348cb354b3459bd063d2f642e2695a.png

TODO深度优先遍历、广度优先遍历

TODO代码层面的具体实现

TODO为什么1.8的HashMap选择红黑树,而不是二叉搜索树?

下一篇对红黑树数据结构的深入理解,届时应该会解释这个问题。

三、平衡多路查找树(B-Tree)

在计算机科学中,B树(英语:B-tree)是一种自平衡的树,能够保持数据有序。这种数据结构能够让查找数据、顺序访问、插入数据及删除的动作,都在对数时间内完成。B树,概括来说是一个一般化的二叉查找树(binary search tree)一个节点可以拥有最少2个子节点。与自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。B树减少定位记录时所经历的中间过程,从而加快存取速度。B树这种数据结构可以用来描述外部存储。这种数据结构常被应用在数据库和文件系统的实现上。

——————摘自维基百科

所以,我们口中的B树,也就是所谓的BTree,或者B-Tree,不存在口中的B减树。B-Tree是一棵树多路平衡树。

扩展:如果你还听过“2-3 B树”,简称为2-3树,它也是一棵B-Tree,该B-Tree内部的节点只能有2个或者3个子节点。

B-Tree的定义:

一个 m 阶的B树是一个有以下属性的树:

每一个节点最多有 m 个子节点

每一个非叶子节点(除根节点)最少有 ⌈m/2⌉ 个子节点

如果根节点不是叶子节点,那么它至少有两个子节点

有 k 个子节点的非叶子节点拥有 k − 1 个键

所有的叶子节点都在同一层

每一个内部节点的键将节点的子树分开。例如,如果一个内部节点有3个子节点(子树),那么它就必须有两个键: a1 和 a2 。左边子树的所有值都必须小于 a1 ,中间子树的所有值都必须在 a1 和a2 之间,右边子树的所有值都必须大于 a2 。

问:MySQL INNODB引擎中索引数据结构为什么是B-/B+Tree,而不是二叉搜索树?为什么也不是红黑树?

答:B-Tree相对于二叉搜索树会减少搜索节点数,也就是缩减了节点个数,从而减少I/O操作,使得I/O操作和内存操作达到极致,所以,B-Tree相对二叉搜索树来说,性能更好。也不是红黑树的原因和二叉搜索树类似,总之是为了减少I/O操作,减少I/O时间,提高整体性能。

f220a1fc6dc6b05e1eb1fa437d980a0b.png

四、B+Tree

B+Tree相对于B-Tree有几点不同:

非叶子节点只存储键值信息。

所有叶子节点之间都有一个链指针。

数据记录都存放在叶子节点中。

将上一节中的B-Tree优化,由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:

be89ed04e1c91f0832c7aaedf1a70bf9.png

通常在B+Tree上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。

B+Tree是B-Tree众多变异体中的一种,同时也是B-Tree的基础之上的一种优化。使其更适合实现外存储索引结构,InnoDB存储引擎就是用B+Tree实现其索引结构。

至于优化的点在哪里,请听我慢慢道来。

首先,我们需要了解到MySQL INNODB引擎中有“页”的概念。页是磁盘管理的最小单位。InnoDB存储引擎中默认每个页的大小为16KB,我们可以通过

mysql> show variables like 'innodb_page_size';

来进行查看页大小。我们从上面的B-Tree图来看,一个节点分为三部分:指向子节点的指针、键、data。如果data足够大,会让一个页中的节点数变少,当节点数变少,平均I/O操作就会增多,整体性能下降,所以这是B+Tree的优化点,去掉非叶节点中的Data,让非叶节点保存的数据变少,从而提升节点中的键增多,便减少了I/O操作,所以提高整体性能。

当然,只是能说是提高整体性能,为什么这么说?

大家想想看,假设A同学用B-Tree,B同学用B+Tree,如果查询同一个东西,假设这个数据就在root节点,那么A同学是不是一下子就查到了?而B同学却不能,所以,这种情况A同学效率更高。但是对于整体,B同学效率更高。

刚刚说到InnoDB存储引擎中每个页的大小默认为16KB,现在我们来算算在InnoDB下,B+Tree中一个节点能有多少个键。

一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值(因为是估值,为方便计算,这里的K取值为〖10〗^3)。也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿 条记录。

实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree的高度一般都在2~4层。MySQL的InnoDB存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作。

参考文献:

https://www.cnblogs.com/vianzhang/p/7922426.html

https://www.cnblogs.com/idreamo/p/8308336.html

https://zh.wikipedia.org/wiki/B%E6%A0%91

https://zh.wikipedia.org/wiki/B%2B%E6%A0%91

http://blog.codinglabs.org/articles/theory-of-mysql-index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
BPlusTree_Java实现 package bplustree; import java.util.*; import com.xuedi.IO.*; import com.xuedi.maths.*; ////// DisposeRoot ///////中的key参数有些问题 public class BTree { //用于记录每个节点中的键值数量 public int keyAmount; //树的根节点 public Node root; public BTree(int keyAmount) { this.keyAmount = keyAmount; this.root = new Node(keyAmount); } //在B树中插入叶节点///////////////////////////////////////////////////////////// public void insert(long key,Object pointer) { //找到应该插入的节点 Node theNode = search(key,root); //在叶节点中找到空闲空间,有的话就把键放在那里 if( !isFull(theNode) ) { putKeyToNode(key,pointer,theNode); }else{ //如果在适当的叶节点没有空间,就把该叶节点分裂成两个,并正确分配键值 Node newNode = separateLeaf(key,pointer,theNode); //如果分裂的是根节点,就新建一个新的根节点将新建的节点作为他的字节点 if( isRoot(theNode) ) { DisposeRoot(theNode,newNode,newNode.keys[0]); }else{ //将新建立的节点的指针插入到上层节点 insertToInnerNode(theNode.parent,newNode,newNode.keys[0]); } } } //lowerNode是下级节点分离后新建立的那个节点/////////////////////////////////////// //upperNode是lowerNode的上层节点 private void insertToInnerNode(Node upperNode,Node lowerNode,long key) { //上层节点有空位就直接插入 if( !isFull(upperNode) ) { putKeyToNode(key,lowerNode,upperNode); //重置父节点指针 pointerRedirect(upperNode); return; }else{ //如果分裂的是根节点,就新建一个新的根节点将新建的节点作为他的子节点 Node newNode; if( isRoot(upperNode) ) { newNode = separateInnerNode(key,lowerNode,upperNode); Node newRoot = new Node(this.keyAmount); newRoot.pointer[0] = upperNode; newRoot.pointer[1] = newNode; upperNode.parent = newRoot; newNode.parent = newRoot; newRoot.keyAmount = 1; newRoot.keys[0] = key; root = newRoot; //重置父节点指针 pointerRedirect(upperNode); return; }else{ //上层非根节点没有空位进行分裂和插入操作 newNode = separateInnerNode(key,lowerNode,upperNode); //重置父节点指针 pointerRedirect(upperNode); //记录要向上插入的键值在源节点中的位置(该键值在separateInnerNode()被保留在srcNode中) int keyToUpperNodePosition = upperNode.keyAmount; //向上递归插入 insertToInnerNode(upperNode.parent,newNode,upperNode.keys[keyToUpperNodePosition]); //重置父节点指针 pointerRedirect(newNode); } } } //将对应的内部节点进行分裂并正确分配键值,返回新建的节点 private Node separateInnerNode(long key,Object pointer,Node srcNode) { Node newNode = new Node(this.keyAmount); //因为我在Node中预制了一个位置用于插入,而下面的函数(putKeyToLeaf())不进行越界检查 //所以可以将键-指针对先插入到元节点,然后再分别放到两个节点中 putKeyToNode(key,pointer,srcNode); //先前节点后来因该有(n+1)/2取上界个键-值针对 int ptrSaveAmount = (int)com.xuedi.maths.NumericalBound.getBound(0,(double)(this.keyAmount+1)/2); int keySaveAmount = (int)com.xuedi.maths.NumericalBound.getBound(0,(double)(this.keyAmount)/2); int keyMoveAmount = (int)com.xuedi.maths.NumericalBound.getBound(1,(double)(this.keyAmount)/2); //(n+1)/2取上界个指针和n/2取上界个键留在源节点中 //剩下的n+1)/2取下界个指n/2取下界个键留在源节点中 for (int k = ptrSaveAmount; k < srcNode.keyAmount; k++) { newNode.add(srcNode.keys[k], srcNode.pointer[k]); } newNode.pointer[newNode.keyAmount] = srcNode.pointer[srcNode.pointer.length-1]; srcNode.keyAmount = keySaveAmount; return newNode; } //将对应的叶节点进行分裂并正确分配键值,返回新建的节点/////////////////////////////// private Node separateLeaf(long key,Object pointer,Node srcNode) { Node newNode = new Node(this.keyAmount); //兄弟间的指针传递 newNode.pointer[this.keyAmount] = srcNode.pointer[this.keyAmount]; //因为我在Node中预制了一个位置用于插入,而下面的函数(putKeyToLeaf())不进行越界检查 //所以可以将键-指针对先插入到元节点,然后再分别放到两个节点中 putKeyToNode(key,pointer,srcNode); //先前节点后来因该有(n+1)/2取上界个键-值针对 int oldNodeSize = (int)com.xuedi.maths.NumericalBound.getBound(0,(double)(this.keyAmount+1)/2); for(int k = oldNodeSize; k <= this.keyAmount; k++) { newNode.add(srcNode.keys[k],srcNode.pointer[k]); } srcNode.keyAmount = oldNodeSize; //更改指针--让新节点成为就节点的右边的兄弟 srcNode.pointer[this.keyAmount] = newNode; return newNode; } //把键值放到叶节点中--这个函数不进行越界检查//////////////////////////////////////// private void putKeyToNode(long key,Object pointer,Node theNode) { int position = getInsertPosition(key,theNode); //进行搬迁动作--------叶节点的搬迁 if( isLeaf(theNode) ) { if(theNode.keyAmount <= position) { theNode.add(key,pointer); return; } else{ for (int j = theNode.keyAmount - 1; j >= position; j--) { theNode.keys[j + 1] = theNode.keys[j]; theNode.pointer[j + 1] = theNode.pointer[j]; } theNode.keys[position] = key; theNode.pointer[position] = pointer; } }else{ //内部节点的搬迁----有一定的插入策略: //指针的插入比数据的插入多出一位 for (int j = theNode.keyAmount - 1; j >= position; j--) { theNode.keys[j + 1] = theNode.keys[j]; theNode.pointer[j + 2] = theNode.pointer[j+1]; } theNode.keys[position] = key; theNode.pointer[position+1] = pointer; } //键值数量加1 theNode.keyAmount++; } //获得正确的插入位置 private int getInsertPosition(long key,Node node) { //将数据插入到相应的位置 int position = 0; for (int i = 0; i < node.keyAmount; i++) { if (node.keys[i] > key) break; position++; } return position; } //有用的辅助函数//////////////////////////////////////////////////////////////// //判断某个结点是否已经装满了 private boolean isFull(Node node) { if(node.keyAmount >= this.keyAmount) return true; else return false; } //判断某个节点是否是叶子结点 private boolean isLeaf(Node node) { //int i = 0; if(node.keyAmount == 0) return true; //如果向下的指针是Node型,则肯定不是叶子节点 if(node.pointer[0] instanceof Node) return false; return true; } private boolean isRoot(Node node) { if( node.equals(this.root) ) return true; return false; } //给内部节点中的自己点重新定向自己的父亲 private void pointerRedirect(Node node) { for(int i = 0; i <= node.keyAmount; i++) { ((Node)node.pointer[i]).parent = node; } } //新建一个新的根节点将新建的节点作为他的字节点 private void DisposeRoot(Node child1,Node child2,long key) { Node newRoot = new Node(this.keyAmount); newRoot.pointer[0] = child1; newRoot.pointer[1] = child2; newRoot.keyAmount = 1; newRoot.keys[0] = key; root = newRoot; //如果两个孩子是叶节点就让他们两个相连接 if( isLeaf(child1) ) { //兄弟间的指针传递 child2.pointer[this.keyAmount] = child1.pointer[this.keyAmount]; child1.pointer[this.keyAmount] = child2; } pointerRedirect(root); return; } /////////////////////////////////////////////////////////////////////////////// //用于寻找键值key所在的或key应该插入的节点 //key为键值,curNode为当前节点--一般从root节点开始 public Node search(long key,Node curNode) { if (isLeaf(curNode)) return curNode; for (int i = 0; i < this.keyAmount; i++) { if (key < curNode.keys[i]) //判断是否是第一个值 return search(key, (Node) curNode.pointer[i]); else if (key >= curNode.keys[i]) { if (i == curNode.keyAmount - 1) //如果后面没有值 { //如果key比最后一个键值大,则给出最后一个指针进行递归查询 return search(key,(Node) curNode.pointer[curNode.keyAmount]); } else { if (key < curNode.keys[i + 1]) return search(key, (Node) curNode.pointer[i + 1]); } } } //永远也不会到达这里 return null; } }

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值