一、树查找
在之间介绍数据结构的文章中我们介绍过二叉树查找,如果忘记的大家可以查看下这篇文章数据结构-树的那些事(四),这里对二叉树就不做介绍,我们来说一下二叉排序树;
二叉排序树(Binary Search Tree):又被称为二叉查找树或者二叉搜索树,当然首先是二叉树,另外特点如下:
1.若它的左子树不为空,则左子树的结点小于它根节点的值;
2.若它的右子树不为空,则右子树的结点大于它根节点的值;
3.它的左、右子树也分别为二叉排序树;
明白特点接下来我们来说一下查找的性能,二叉排序树查找性能取决于二叉树的形状,但是二叉树排序的形状是不确定,最坏的情况查找性能为O(n),最好情况查找性能为O(logn),如下图
接下来我们谈一下二叉排序树的增加和删除操作,查询就没必要说明,就写下代码实现下就好;
增加操作:
1.若当前的二叉树排序树为空,则插入元素的根节点;
2.若插入的值小于根节点,则插入到左子树当中;
3.若插入的值大于根节点,则插入到右子树当中;
删除操作:
1.若删除的是子节点,则直接删除该结点;
2.若删除的节点仅有左或者右子树的结点,则让该结点的子树与父亲节点相连,删除该结点即可;
3.若删除的节点左子树和右子树都有子结点,如下面两幅图,这里面强调一下第二幅图,删除47以后用37或者48都可以满足二叉排序树,这里我们为什么选择47而要放弃37,是因为二叉排序树按照中序遍历以后形成的是一个有序序列(由小到大排序),选择37以后不满足这个特征;
上面我们谈到二叉排序树性能问题,接下来我们来说一下平衡二叉树看如何处理二叉树性能问题,所有代码在介绍完成以后提供实现;
平衡二叉树(AVL树):首先平衡二叉树是一颗二叉树排序树,他的特点是每一个节点左子树和右子树的高度差至多等于1,这样就能避免照成单枝树,影响查询效率,来个看图识AVL树,下图中图二满足二叉排顺序树的特征,58节点的左子树大于59,图三58的左子树的高度为2,右子树的高度为0,高度差大于1,所以不满足平衡二叉树,接下来我们谈谈平衡二叉树插入和删除照成失衡以后的操作(平衡二叉树的旋转)。
失衡以后操作:
失衡以后可能造成4种情况:LL(左左)、RR(右右)、LR(左右)、RL(右左),接下来我们用图说话,看一下这4种情况;
对这4种情况进行统一整理就是:插入或删除一个节点后,根节点的左子树的右子树还有非空子节点,导致"根的左子树的高度"比"根的右子树的高度"大2,导致AVL树失去了平衡。就用第一种说一下当插入D的时候导致,导致5左子树的高度为3,5的右子树为1,差值大于1,这个时候我们抓住3,使5进行右旋,3的右孩子变成5的左孩子这个时候平衡达成。
这里简单讲一下对应结点的抽象,不同于二叉树的情况就是这里要增加一个高度的属性,好用来判断左子树和右子树的差值;
B树:首先要明白B树要处理什么问题,我们上面提到过的AVL树、二叉排序树以及我们没有提到过的红黑树,这些都是在内存中内部查找树,而B树是前面平衡树算法的扩展,它支持保存在磁盘或者网络上的符号表进行外部查找,这些文件可能比我们以前考虑的输入要大的多(难以存入内存)。既然内容保存在磁盘中,那么自然会因为树的深度过大而造成磁盘I/O读写过于频繁(磁盘读写速率是有限制的),进而导致查询效率低下。那么降低树的深度自然很重要了。因此,我们引入了B树,多路查找树。
明白了B树是干什么用的,我们来个他下个定义,B树是一种平衡的多路查找树,结点最大的孩子数目为B树的阶,一个m阶的B树特征如下:
1.每个根节点至少有2个孩子节点;
2.每个非根的分支节点都包含k-1个元素和k个孩子,其中 m/2 <= k <= m;
3.每一个叶子节点都包含k-1个元素,其中 m/2 <= k <= m;
4.所有的叶子节点都位于同一层次;
5.每个节点中的元素从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域分划(这个在下面解释下);
接下来我们以3阶树为例子来说说上面这些特征是如何体现的,然后在讨论下B树的增加和删除节点情况,
上图是一颗3阶树,首先最大的孩子数目为3,所以是3阶树,我们来看下12,这是2结点,包含1个根节点和2个孩子结点,满足第2,3条,左子树11小于12,右子树13、15大于12,接下来我们看2,6结点,3结点包含2、6两个元素,和3个孩子结点,左子树1小于2、6,右子树8大于2、6,3、5位于左、右子树中间,满足以上条件;
增加操作有3种情况:
1.对于空树,插入一个2结点即可;
2.插入到一个2结点的叶子上。这种情况考虑将其升级为3结点即可,这里进行解释一下,当插入3的时候,通过遍历会发现3比4小,只能插入到4的左子树,4有个子结点,这个时候只能将其升级为3结点,3比1大,插入到1的后继,这个时候形成右图样子。
3.插入3结点的情况相对比较麻烦,3结点是3阶树最大的容量,当向3结点插入的时候就要考虑拆分的情况,我们来看一下这几种情况。
第一种情况:向2结点满元素的子节点插入时候,左图满足这种情况,当向4的右孩子结点6,7插入5的时候,6、7已经是3结点的,不能在增加,这个时候发现4结点是2结点,就需要将其升级为3结点,于是讲6、7拆分,6与4组成3结点,5成为中间孩子,7成为右孩子,如右图。
第二种情况:当向3结点的满元素的子节点插入时候,左图满足这种情况,当向12、14的左孩子9、10插入11的时候,发现9、10已经满足3结点,不能在增加,但是父亲节点也是3结点,这个时候在向上查找,发现12、14的父亲结点8,还可以进行插入,这个时候讲8升级为3结点,将8与12合并,最终生成右图样子。
再来看一种比较特殊的情况:当向4、6左孩子结点1、3插入元素的时候,发现都是3结点无法在进行拆分,这个是将1、3结点、4、6结点、8、12结点都进行拆分,形成右图样子;
删除操作:
1.删除位于3结点的叶子结点,删除改元素即可,不会影响到别的结构,如下图:
2.删除元素位于一个2结点上,这里的情况比较复杂,我们分情况介绍:
第一种情况:该结点的双亲都是2结点,且拥有一个3结点的孩子,如下图,删除结点1,这种情况只需要左旋,6成为父亲节点,4成为6的左孩子,7成为6的右孩子。
第二种情况:该节点的双亲是2结点,右孩子也是2结点,如下图,删除4,左旋导致没有右孩子,不满足B树定义的第一条,所以这个时候需要对树的结构进行调整,首先将7进行左旋,将8进行左旋,9也进行左旋,形成图三所示的样子。
第三种情况:该结点双亲是一个3结点,如下图所示,删除10结点,12、14不能成为3结点,于是将此结点拆分,并将12、13合并成左孩子。
第四种情况:满二叉树的情况,删除任何一个叶子都会使整棵树不能满足第一条的定义,如下图所示,当删除8的时候,需要考虑将整棵树的层数减少,这个时候将6、7合并为9的左孩子,这个时候不满足第4条定义,需要将9、14合并为,最终形成右图所示。
3.删除的元素元素如果不是叶子结点,则考虑按照中序遍历后得到的该元素的前驱或者后继来进行替换该结点。这里我们就不上图来说明了。
接下来我们做一些思考,由二叉树查找、二叉树排序树、平衡二叉树、B树我们能得出一个结论,树的高度越小查找的速度越快,当元素的数目一定的时候,怎么样才能使树的高度降低,当然是扩展树的阶数才能降低树的高度,之前很火的什么4Y个URL中查询某个URL等之类的题,我想看到这里的时候大家会有一些想法了吧,等等以后我会对类问题进行一次总结,这次先不谈了。
再来做一个思考,对于n个关键字的m阶树,最坏需要需要几次查找?
1.因为根至少有两个孩子,因此第2层至少有两个结点。
2.除去跟结点和叶子外,每个分支的结点至少都有m/2个孩子;
3.第三层结点的个数2*m/2个节点;
4.第四层结点的个数2*(m/2)^2个结点;
5.第K+1层结点的个数2*(m/2)^k-1个结点,这里K+1层就是叶子结点;
6.B树有n个关键字,当你找到叶子节点,就等于查找不成功的结点为n+1,因此n+1>=2*(m/2)^k-1,即k<=log(m/2)((n+1)/2)+1;
7.结论根结点到关键字结点涉及的结点个数不操过k<=log(m/2)((n+1)/2)+1;
总结如下,一颗n个关键字结点的m阶树最大高度为k<=log(m/2)((n+1)/2)+1,B树的每个非根的分支节点m个孩子,其中 m/2 <= m<= m,随着m的增加,B树的高度下降,查找性能提升;
B+树:
接下来我们继续探讨一个问题,看下图,当我们对B树进行中序遍历的时候,假设每一个结点都在硬盘不同的页面上,这个时候必然会经过页2---页1--页3--页4--页1---页4---页1---页5,这样会导致每次那一个元素都要对结点的元素进行遍历,我们有没有什么办法让元素只被访问一次,带着这个问题我们来看下B+树。
接下来我们还是老套路看图说话,下图是一颗B+树,其有如下特点:出现在分支结点中的元素会被当作分支结点位置的中序后继结点再次列出,另外每个叶子结点都会保存一个执行后一个叶子结点的指针。
一颗m阶树的B+树和m阶的B树差异如下:
1.n颗子树的结点包含n个关键字;
2.所有的叶子结点包含全部关键字的信息,以及指向这些关键字记录的指针,叶子结点本身按照从小到大的顺序进行排列;
3.所有分支结点都可以看成索引,结点中含有子树的最大(或最小)关键字。
如果上面的B+树看的还不是很直观,那么我们再来看一颗更直观一点的;这里我们就不谈什么页分裂问题,这个问题待索引的时候再来探讨,我们来说下B+树和B树在查找方面的比较,首先2者与二叉树比较,提升了磁盘I/O效率,但是B树没有解决遍历元素时效率低的问题,如同上面提出的那个问题,这里我们可以通过B+树来解决,B+树只需要通过叶子节点遍历就可以实现对整棵树的遍历。B+更大的优势在于范围查找,这一点是B树无法比较的,进行范围查找是只需要在叶子结点上遍历即可。B+树的插入和删除都与B树类似,但是插入和删除都是在叶子结点上进行。
上面介绍树的几种不同的数据结构,主要是为了引出B+树,明白这种结构的好处才能为我们后面数据库索引打下基础,另外这里没有介绍红黑树,这个等HashMap源码解读的时候再来看,Java代码实现主要提供平衡二叉树的实现,B树的实现还是给大家提供一个参考https://github.com/int32bit/algorithms/blob/master/B-Tree/src/BTree.java;
二、Java代码实现
public class AVLNode<T extends Comparable<T>> { public T key;//关键字 public int height;//高度 public AVLNode<T> lchild;//左孩子 public AVLNode<T> rchilid;//右孩子 public AVLNode(T key){ this(key,null,null); } public AVLNode(T key,AVLNode<T> lchild,AVLNode<T> rchilid){ this.key=key; this.lchild=lchild; this.rchilid=rchilid; this.height=0; } } public class AVLTree<T extends Comparable<T>> { private AVLNode<T> root; public AVLNode<T> getRoot() { return root; } public AVLTree(){ this.root=null; } public AVLTree(T key){ this.root=new AVLNode<T>(key); } //获取树的高度 private int height(AVLNode<T> node){ if (node!=null){ return node.height; } return 0; } public int height(){ return height(root); } //比较两个值的大小 private int max(int a,int b){ return a>b?a:b; } //递归前序遍历 private void preOrder(AVLNode<T> node){ if (node!=null){ System.out.print(node.key+" "); preOrder(node.lchild); preOrder(node.rchilid); } } public void preOrder(){ preOrder(root); } //中序遍历 private void midOrder(AVLNode<T> node){ if (node!=null){ midOrder(node.lchild); System.out.print(node.key+" "); midOrder(node.rchilid); } } public void midOrder(){ midOrder(root); } //后序遍历 private void postOrder(AVLNode<T> node){ if (node!=null){ postOrder(node.lchild); postOrder(node.rchilid); System.out.print(node.key+" "); } } public void postOrder(){ postOrder(root); } //递归查找key元素 private AVLNode<T> search(AVLNode<T> node,T key){ if (node==null) return node; int compare=key.compareTo(node.key); if (compare<0) return search(node.lchild,key); else if (compare>0) return search(node.rchilid,key); else return node; } public AVLNode<T> search(T key){ return search(root,key); } //LL旋转 private AVLNode<T> llRotation(AVLNode<T> node){ AVLNode<T> newNode; newNode=node.lchild; node.lchild=newNode.rchilid; newNode.rchilid=node; node.height=max(height(node.lchild),height(node.rchilid))+1; newNode.height=max(height(node.lchild),node.height)+1; return newNode; } //RR旋转 private AVLNode<T> rrRotation(AVLNode<T> node){ AVLNode<T> newNode; //根结点右旋 newNode=node.rchilid; node.rchilid=newNode.lchild; newNode.lchild=node; node.height=max(height(node.lchild),height(node.rchilid))+1; newNode.height=max(height(newNode.rchilid),node.height)+1; return newNode; } //LR旋转 private AVLNode<T> lrRotation(AVLNode<T> node){ node.lchild=rrRotation(node.lchild); return llRotation(node); } //RL旋转 private AVLNode<T> rlRotation(AVLNode<T> node){ node.rchilid=llRotation(node.rchilid); return rrRotation(node); } //插入结点 private AVLNode<T> insert(AVLNode<T> node,T key){ if (node==null){ node=new AVLNode<T>(key); }else { int cmp=key.compareTo(node.key);//比较节点的值 if (cmp<0){//小于插入左子树 node.lchild=insert(node.lchild,key); //判断是否失衡 //向左侧插入结点只能照成ll或者lr情况 if (height(node.lchild)-height(node.rchilid)==2){ //插入的结点和当前结点的左子树比较 //大于说明是LR情况否者LL if (key.compareTo(node.lchild.key)>0) node=lrRotation(node); else node=llRotation(node); } }else if (cmp>0){ //同上下面不做介绍 node.rchilid=insert(node.rchilid,key); if (height(node.rchilid)-height(node.lchild)==2){ if (key.compareTo(node.rchilid.key)>0) node=rrRotation(node); else node=rlRotation(node); } }else { System.out.println("添加失败:不允许添加相同的节点!"); } } node.height=max(height(node.lchild),height(node.rchilid))+1; return node; } public void insert(T key){ root=insert(root,key); } //查找最大值 private AVLNode<T> findMax(AVLNode<T> node){ if (node==null) return null; while (node.rchilid!=null){ node=node.rchilid; } return node; } public T finMax(){ AVLNode<T> p=findMax(root); if (p!=null){ return p.key; } return null; } //查找最小值 private AVLNode<T> finMin(AVLNode<T> node){ if (node==null) return null; while (node.lchild!=null){ node=node.lchild; } return node; } public T finMin(){ AVLNode<T> p=finMin(root); if (p!=null){ return p.key; } return null; } //删除结点 private AVLNode<T> remove(AVLNode<T> node,AVLNode<T> del){ if (node==null||del==null) return null; //删除的结点和当前结点比较 int cmp=del.key.compareTo(node.key); if (cmp<0){ //递归向左查找结点 node.lchild=remove(node.lchild,del); //在左子树中删除后该节点失衡,若失衡,则可以肯定的是该节点的右子树比左子树高 if (height(node.rchilid)-height(node.lchild)==2){ AVLNode<T> rTree=node.rchilid;//右子树失衡2种情况 右右和右左 if (height(rTree.lchild)>height(rTree.rchilid)) node=rlRotation(node); else node=rrRotation(node); } }else if (cmp>0){ node.rchilid=remove(node.rchilid,del);//同上相反左边失衡 if (height(node.lchild)-height(node.rchilid)==2){ AVLNode<T> lTree=node.lchild; if (height(lTree.rchilid)>height(lTree.lchild)) node=lrRotation(node); else node=llRotation(node); } }else { //找到了要删除的节点,该节点左右子树都不为空 if ((node.lchild!=null)&&(node.rchilid!=null)){ //判断左右孩子的高度 if (height(node.lchild)>height(node.rchilid)){ //如果左子树高度大于右子树 //则找到左子树最大的结点替换当前结点 //这样操作会避免失衡 AVLNode<T> maxNode=findMax(node.lchild); node.key=maxNode.key; node.lchild=remove(node.lchild,maxNode); }else { //同上 AVLNode<T> minNode=finMin(node.rchilid); node.key=minNode.key; node.rchilid=remove(node.rchilid,minNode); } }else {//单一结点则删除 node=(node.lchild!=null)?node.lchild:node.rchilid; } } return node; } public void remove(T key){ AVLNode<T> removeNode=search(key); if (removeNode!=null) root=remove(root,removeNode); } } public class AVLTest { private static int arr[]= {1,2,3,4,5,6,7,8,9,10,12,11,13,14,15 }; public static void main(String[] args) { AVLTree<Integer> tree = new AVLTree<Integer>(); for (int i=0;i<arr.length;i++){ System.out.printf("%d ", arr[i]); tree.insert(arr[i]); } System.out.printf("\n前序遍历: "); tree.preOrder(); System.out.printf("\n中序遍历: "); tree.midOrder(); System.out.printf("\n后序遍历: "); tree.postOrder(); System.out.printf("\n"); System.out.printf("高度: %d\n", tree.height()); System.out.printf("最小值: %d\n", tree.finMin()); System.out.printf("最大值: %d\n", tree.finMax()); tree.remove(7); System.out.printf("\n高度: %d", tree.height()); System.out.printf("\n中序遍历: "); tree.midOrder(); } }
三、数据库索引
已经探讨B+树的好处,接下来我们来看一下B+插入和删除操作;
插入操作:
B+树插入必须保证插入后的叶子结点记录依然排序,另外还需要考虑插入到B+树的三种情况,接下来我们来谈一下这3种情况:
1.当Leaf Page和Index Page都未满的时候,直接将记录插入到叶子节点即可(Leaf Page和Index Page分别指的是叶子结点和父亲结点);
当插入28这个值的时候,Leaf Page和Index Page都未满,直接插入到叶子节点
2.当Leaf Page满,Index Page未满时候,首先拆分Leaf Page,将中间结点放入到Inde Page中,然后小于中间节点的放左边,大于或者等于中间结点的放右边;
再次插入70这个值的时候,Leaf Page已经满,但是Index Page还没满,在根节点插入叶子节点中间结点,然后在进行页分裂;
3.当Leaf Page和Index Page都满的时候,首先拆分Leaf Page,小于中间结点的记录放左边,大于或者等于中间结点的记录放右边,接下来拆分Index Page,相当于提升整颗树的高度,小于中间结点的放入到左边,大于或者等于中间结点的放入到右边,最后讲中间节点放入到上一层Index Page;
接下来插入95,这个时候Leaf Page和Index Page都满值,没办法插入,这个时候就需要进行2次拆分,首先拆分叶子结点,最后在拆分根结点。
以上图2和3都没有添加双向链表,主要是为让大家看明白分裂的情况。
删除操作:
B+树使用填充因子来控制树的删除删除变化,50%是填充因子可设置的最小的值,小于这个值这个时候就需要做合并操作,删除操作同时也必须保证删除后的结点依然排序。
1.Leaf Page和Index Page都大于填充因子,如果删除的是叶子节点直接删除就好,如果删除的是Index Page的元素,使用该结点的右结点代替;
如上图删除70这个节点,直接删除就可以了
2.Leaf Page小于填充因子,Index Page大于填充因子,这个时候合并叶子节点和他的兄弟节点,更新Index Page;
接下来删除25,在这个时候满足第一种情况,但是这个值又在Index Page中,删除25以后,还需要将右兄弟替换到Page Index中。
3.Leaf Page和Index Page都小于填充因子,这个时候需要叶子节点做合并,当删除掉Index Page结点以后Index Page也需要做合并。
接下来我们删除60,这个时候删除60以后Leaf Page不满足填充因子,进行合并,同时删除Index Page的值也需要做合并。
B+树索引的本质就是B+树在数据库中的实现,B+树在数据库中的高度一般为2-4层,这个怎么实现的我们先不要去管,但是我们要知道这样做就会导致我们查询速度很快,高度为2-4层意味着我们查找一个数据所需要做的IO操作为2-4次,磁盘每秒可以做100次的IO操作,这就意味着我们能在0.02-0.04秒查找出数据。是不是很快。数据库中分为聚集索引和非聚集索引,这两者的差别在于叶子节点是否存在一整行数据。
聚集索引:
这里思考一个问题,我们在建表的时候会建立一个主键,这是为什么?其实这个主键就是我们的聚集索引,要是不建立主键,那么我们存储的数据就会在无序的存在磁盘中,当然查询会慢,但是建立主键以后,数据库中的数据会按照B+树的特点进行构建,整个表变成一个索引,叶子节点存放的是表中的整行数据,这就是我们所说的聚集索引,非叶子节点的索引存放的是键值以及数据页的偏移量。聚集索引的好处在于,对于主键查找的和排序非常快,因为叶子节点存放的就是我们的数据,另外每个叶子节点之间的连接都是双向链表,不需要再一次进行查找。
非聚集索引:
非聚集索引的叶子节点并不包含所有行的数据,叶子节点除了包含键值以外,每个叶子节点的索引包含一个书签,用来保存相对应的行数据的聚集索引的键。所以当通过非聚集索引查询数据的时候,首先会遍历非聚集索引并通过叶子节点的指针获得指向聚集索引的键,然后通过聚集索引查找与之匹配的叶子节点。每次给表中增加一个非聚集索引,表中的字段就会被复制出来一份,生成索引,所以给表添加索引就会增加表空间,减慢插入时候的速度。
联合索引:
联合索引就是在表上的多个列建立索引,联合索引本质上还是一棵B+树,不同的是联合索引的键值数量不是1,而是大于等于2。为什么需要联合索引的存在?
1.假设(a,b)列建立索引,对于查询条件而言,当查询的条件为a和b,则可以使用,条件单独为a的时候也可以使用,但是为b的情况不可以使用;
2.对于(a,b)列建立的索引,第二个列是排序的,这样当我们按照b条件排序的时候就不需要进行排序操作;
明白了索引的原理我想大家以后在优化查询的时候也有清晰的思路了吧,以上是B-Tree索引介绍,另外Hash索引我们以后再聊。
四、结束语
主要参考《大话数据结构》和《MySQL技术内幕:InnoDB存储引擎》