http://hxraid.iteye.com/blog/608982 在计算机许多应用领域中,查找操作都是十分重要的研究技术。查找效率的好坏直接影响应用软件的性能。比如说: (1) 全文检索技术中对文本建立索引之后,对索引的查找效率将决定搜索引擎的质量。 (2) mysql数据库的索引就是B+树结构,查找效率极高。 (3) Windows OS的文件系统结构也是采用B+树进行存储的。
在《查找算法》系列文章中,我将主要介绍动态查找树结构。其他静态查找结构在下面简单的引出:
静态查找:数据集合稳定,不需要添加,删除元素的查找操作。 动态查找:数据集合在查找的过程中需要添加或删除元素。
静态查找结构概论
当把看似杂乱无章的数据组织成具有一定结构和一定规则的集合体时,查找的效率就会大不一样了。
【顺序查找】 大家都知道,最简单的查找方法就是顺序查找 (一个接一个得查下去)。这种线性结构的查找效率是最低的,时间复杂度在O(N)数量级,最坏的情况莫过于所有数据都找遍了,还是没找到。
难道真的每一个数据都必须找一次吗?
【折半查找】 很显然的一个例子就是利用折半查找(二分查找) 法对有序的线性数据进行查找。每一次都找1/2的部分,查找次数当然就大大减少了。时间复杂度在O(log2 N)数量级上。
折半查找实际就是一颗二叉树遍历,其中最中间的数据就是二叉树的根。但是问题又来了,如果这个根数据一年才查找一次,而这棵树的叶子数据1秒钟需要查找1W次(考虑数据的查找概率)。这种折半查找的效率又不行了?
【静态最优查找树/次优查找树】 考虑到上面折半查找在概率问题下的效率不行,我们就想能不能把折半查找二叉树中概率最大的数据放在根的位置上或者放在离根较近的位置上。基于此,静态最优查找树/次优查找树的思想就是在折半查找二叉树的基础上求解一个带权(数据概率)路径长度最小/近视最小的树。总体上说,静态最优/次优查找树的时间复杂度也在 O(log2 N)数量级上(特别是在数据具有查找概率的情况下也能保证这个效率)。
此外,还有一些别的静态查找结构:索引顺序表的分块查找,线性冲突再散列的hash表的查找(Hash表将在一个专题里面讲到)等。
上面介绍查找表都属于静态查找结构,也就是说当需要查找的数据集合动态变化的时候,这些结构都很不方便。 比如: 折半查找: 当查找过程中没有发现元素A的时候,需要动态添加元素A,此时整个有序表将面临重新排序的问题。 静态最优查找树: 新增元素不光要重新排序,而且原有的整棵带权路径长度最小值的树也将重建(最优解变化了)。这在代价上是沉重的,这也是为什么 静态最优查找树并不适合实际应用的巨大缺陷了。
正是由于实际应用中,很多时候需要在查找的同时进行添加,删除操作。因此便捷的动态查找结构就十分的总要了。下面我们用专题的形式详细讲解二叉查找树 ,平衡二叉树 ,红黑树 ,B-树/B+树 。
|
http://hxraid.iteye.com/blog/609312 当所有的静态查找结构添加和删除一个数据的时候,整个结构都需要重建。这对于常常需要在查找过程中动态改变数据而言,是灾难性的。因此人们就必须去寻找高效的动态查找结构,我们在这讨论一个非常常用的动态查找树——二叉查找树 。
二叉查找树的特点
下面的图就是两棵二叉查找树,我们可以总结一下他的特点: (1) 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值 (2) 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
我们中序遍历这两棵树发现一个有序的数据序列: 【1 2 3 4 5 6 7 8 】
二叉查找树的操作
插入操作: 现在我们要查找一个数9,如果不存在则,添加进a图。我们看看二叉查找树动态添加的过程: 1). 数9和根节点4比较(9>4),则9放在节点4的右子树中。 2). 接着,9和节点5比较(9>5),则9放在节点5的右子树中。 3). 依次类推:直到9和节点8比较(9>8),则9放在节点8的右子树中,成为节点8的右孩子。 这个过程我们能够发现,动态添加任何一个数据,都会加在原树结构的叶子节点上,而不会重新建树。 由此可见,动态查找结构确实在这方面有巨大的优势。
删除操作: 如果二叉查找树中需要删除的结点左、右子树都存在,则删除的时候需要改变一些子树结构,但所需要付出的代价很小。
具体的插入,删除算法请参加《数据结构算法与应用——搜索树》P5-8。[该章节已经上传到《查找结构专题(6):动态查找树比较 》中]。
二叉查找树的效率分析
那么我们再来看看二叉查找树的效率问题
很显然,在a,b两图的二叉查找树结构中查找一个数据,并不需要遍历全部的节点元素,查找效率确实提高了。但是有一个很严重的问题:我们在a图中查找8需要比较5次数据,而在B图中只需要比较3次。更为严重的是:如果按有序序列[1 2 3 4 5 6 7 8]建立一颗二叉查找树,整棵树就退化成了一个线性结构(如c输入图:单支树),此时查找8需要比较8次数据,和顺序查找没有什么不同。 总结一下:最坏情况下,构成的二叉排序树蜕变为单支树,树的深度为n,其查找时间复杂度与顺序查找一样O(N)。最好的情况是二叉排序树的形态和折半查找的判定树相同,其平均查找长度和log2(N)成正比 (O(log2(n)))。
这说明:同样一组数据集合,不同的添加顺序会导致查找树的结构完全不一样,直接影响了查找效率。
那么如何解决这个问题呢? 我们会在下面的专题中:《平衡二叉树》 中来解决。
|
http://hxraid.iteye.com/blog/609949 在上一个专题中,我们在谈论二叉查找树的效率的时候。不同结构的二叉查找树,查找效率有很大的不同(单支树结构的查找效率退化成了顺序查找)。如何解决这个问题呢?关键在于如何最大限度的减小树的深度。正是基于这个想法,平衡二叉树出现了。
平衡二叉树的定义 (AVL—— 发明者为Adel'son-Vel'skii 和 Landis)
平衡二叉查找树,又称 AVL树。 它除了具备二叉查找树的基本特征之外,还具有一个非常重要的特点:它 的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值(平衡因子 ) 不超过1。 也就是说AVL树每个节点的平衡因子只可能是-1、0和1(左子树高度减去右子树高度)。
那么如何是二叉查找树在添加数据的同时保持平衡呢?基本思想就是:当在二叉排序树中插入一个节点时,首先检查是否因插入而破坏了平衡,若 破坏,则找出其中的最小不平衡二叉树,在保持二叉排序树特性的情况下,调整最小不平衡子树中节点之间的关系,以达 到新的平衡。所谓最小不平衡子树 指离插入节点最近且以平衡因子的绝对值大于1的节点作为根的子树。
平衡二叉树的操作
1. 查找操作 平衡二叉树的查找基本与二叉查找树相同。
2. 插入操作 在平衡二叉树中插入结点与二叉查找树最大的不同在于要随时保证插入后整棵二叉树是平衡的。那么调整不平衡树的基本方法就是: 旋转 。 下面我们归纳一下平衡旋转的4中情况 1) 绕某元素左旋转 80 90 / \ 左旋 / \ 60 90 ---- -> 80 120 / \ / \ / 85 120 60 85 100 / 100 a) BST树 b ) AVL树 分析一下:在插入数据100之前,a图的B ST树只有80节点的平衡因子是-1(左高-右高),但整棵树还是平衡的。加入100之后,80节点的平衡因子就成为了-2,此时平衡被破坏。需要左旋转成b 图。 当树中节点X的右孩子的右孩子上插入新元素,且平衡因子从-1变成-2后,就需要绕节点X进行左旋转。
2) 绕某元素右旋转 100 85 / \ 右旋 / \ 85 120 ------ -> 60 100 / \ \ / \ 60 90 80 90 120 \ 80 a) B ST树 b) AVL树 当树中节点X的左孩子的左孩子上插入新元素,且平衡因子从1变成2后,就需要绕节点X进行右旋转。
3) 绕某元素的左子节点左旋转,接着再绕该元素自己右旋转。 此情况下就是左旋与右旋 的结合,具体操作时可以分解成这两种操作,只是围绕点不一样而已。
100 100 90 / \ 左旋 / \ 右旋 / \ 80 120 ------> 90 120 ------> 80 100 / \ / / \ \ 60 90 80 60 85 120 / / \ 85 60 85 当树中节点X的左孩子的右孩子上插入新元素,且 平衡因子从1变成2后,就需要 先绕X的左子节点Y左旋转,接着再绕X右旋转
4) 绕某元素的右子节点右旋转,接着再绕该元素自己左旋转。 此情况下就是 右旋与左旋 的结合,具体操作时可以分解 成这两种操作,只是围绕点不一样而已 。
80 80 85 / \ 右 旋 / \ 左 旋 / \ 60 100 ------> 60 85 -------> 80 100 / \ \ / / \ 85 120 100 60 90 120 \ / \ 90 90 120 当树中节点X的右孩子的左孩子上插入新元素,且 平衡因子从-1变成-2后,就需要 先绕X的右子节点Y右旋转,接着再绕X左旋转
平衡二叉树性能分析
平衡二叉树的性能优势: 很显然,平衡二叉树的优势在于不会出现普通二叉查找树的最差情况。其查找的时间复杂度为O(logN)。
平衡二叉树的缺陷: (1) 很遗憾的是,为了保证高度平衡,动态插入和删除的代价也随之增加。因此,我们在下一专题中讲讲《红黑树》 这种更加高效的查找结构。
(2) 所有二叉查找树结构的查找代价都与树高是紧密相关的,能否通过减少树高来进一步降低查找代价呢。我们可以通过多路查找树的结构来做到这一点,在后面专题中我们将通过《多路查找树/B-树/B+树 》来介绍。
(3) 在大数据量查找环境下(比如说系统磁盘里的文件目录,数据库中的记录查询 等),所有的二叉查找树结构(BST、AVL、RBT)都不合适。如此大规模的数据量(几G数据),全部组织成平衡二叉树放在内存中是不可能做到的。那么把这棵树放在磁盘中吧。问题就来了:假如构造的平衡二叉树深度有1W层。那么从根节点出发到叶子节点很可能就需要1W次的硬盘IO读写。大家都知道,硬盘的机械部件读写数据的速度远远赶不上纯电子媒体的内存。 查找效率在IO读写过程中将会付出巨大的代价。在大规模数据查询这样一个实际应用背景下,平衡二叉树的效率就很成问题了。对这一问题的解决:我们也会在《多路查找树/B-树/B+树 》 将详细分析。
上面提到的红黑树和多路查找树都是属于深度有界查找树(depth-bounded tree —DBT)
相关问题1:N层平衡二叉树至少多少个结点
假设F(N)表示N层平衡二叉树的结点个数,则F[1]=1,F[2]=2。而F(N)=F(N-2)+F(N-1)+1
为什么呢?我们可以这样考虑,假设现在又一个(N-2)层和(N-1)层的最少结点平衡二叉树。要构造一棵N层的平衡二叉树,则只需加入一个根节点,其左右子树分别(N-2)层和(N-1)层的树即可 。由于两个子树都是最少结点的,所有N层的也是最少结点的。
|
大部分转载:http://yanglongylj.blog.163.com/blog/static/563834532009113021438417/ http://hxraid.iteye.com/blog/611816
红黑树的性质与定义 红黑树(red-black tree) 是一棵满足下述性质的二叉查找树: 1. 每一个结点要么是红色,要么是黑色。 2. 根结点是黑色的。 3. 所有叶子结点都是黑色的(实际上都是Null指针,下图用NIL表示)。叶子结点不包含任何关键字信息,所有查询关键字都在非终结点上。 4. 每个红色结点的两个子节点必须是黑色的。换句话说:从每个叶子到根的所有路径上不能有两个连续的红色结点 5. 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点
黑深度 ——从某个结点x出发(不包括结点x本身)到叶结点(包括叶子结点)的路径上的黑结点个数,称为该结点x的黑深度,记为bd(x),根结点的黑深度就是该红黑树的黑深度。叶子结点的黑深度为0。比如:上图bd(13)=2,bd(8)=2,bd(1)=1 内部结点 —— 红黑树的非终结点 外部节点 —— 红黑树的叶子结点
红黑树相关定理 1. 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。 根据上面的性质5我们知道上图的红黑树每条路径上都是3个黑结点。因此最短路径长度为2(没有红结点的路径)。再根据性质4(两个红结点不能相连)和性质1,2(叶子和根必须是黑结点)。那么我们可以得出:一条具有3个黑结点的路径上最多只能有2个红结点(红黑间隔存在)。也就是说黑深度为2(根结点也是黑色)的红黑树最长路径为4,最短路径为2。从这一点我们可以看出红黑树是 大致平衡的。 (当然比平衡二叉树要差一些,AVL的平衡因子最多为1)
2. 红黑树的树高(h)不大于两倍的红黑树的黑深度(bd),即h<=2bd 根据定理1,我们不难说明这一点。bd是红黑树的最短路径长度。而可能的最长路径长度(树高的最大值)就是红黑相间的路径,等于2bd。因此h<=2bd。
3. 一棵拥有n个内部结点(不包括叶子结点)的红黑树的树高h<=2log(n+1) 下面我们首先证明一颗有n个内部结点的红黑树满足n>=2^bd-1。这可以用数学归纳法证明,施归纳于树高h。当h=0时,这相当于是一个叶结点,黑高度bd为0,而内部结点数量n为0,此时0>=2^0-1成立。假设树高h<=t时,n>=2^bd-1成立,我们记一颗树高 为t+1的红黑树的根结点的左子树的内部结点数量为nl,右子树的内部结点数量为nr,记这两颗子树的黑高度为bd'(注意这两颗子树的黑高度必然一 样),显然这两颗子树的树高<=t,于是有nl>=2^bd'-1以及nr>=2^bd'-1,将这两个不等式相加有nl+nr>=2^(bd'+1)-2,将该不等式左右加1,得到n>=2^(bd'+1)-1,很显然bd'+1>=bd,于是前面的不等式可以 变为n>=2^bd-1,这样就证明了一颗有n个内部结点的红黑树满足n>=2^bd-1。 在根据定理2,h<=2bd。即n>=2^(h/2)-1,那么h<=2log(n+1) 从这里我们能够看出,红黑树的查找长度最多不超过2log(n+1),因此其查找时间复杂度也是O(log N)级别的。
红黑树的操作
因为每一个红黑树也是一个特化的二叉查找树,因此红黑树上的查找操作与普通二叉查找树上的查找操作相同。 然而,在红黑树上进行插入操作和删除操作会导致不 再符合红黑树的性质。恢复红黑树的属性需要少量(O(log n))的颜色变更(实际是非常快速的)和不超过三次树旋转(对于插入操作是两次)。 虽然插入和删除很复杂,但操作时间仍可以保持为 O(log n) 次 。
插入操作 我们首先以二叉查找树的方法增加节点并标记它为红色。 ( 如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过颜色调换(color flips)和树旋转来调整。) 下面要进行什么操作取决于其他临近节点的颜色。同人类的家族树中一样,我们将使用术语叔父节点来指一个节点的父节点的兄弟节点。
假设新加入的结点为N,父亲结点为P,叔父结点为Ui(叔父结点就是一些列P的兄弟结点),祖父结点G(父亲结点P的父亲)。下面会给出每一种情况,我们将使用C示例代码来展示。通过下列函数,可以找到一个节点的叔父和祖父节点: C代码 1. node grandparent(node n) { 2. return n->parent->parent; 3. } 4. 5. node uncle(node n) { 6. if (n->parent == grandparent(n)->left) 7. return grandparent(n)->right; 8. else 9. return grandparent(n)->left; 10. }
情况1. 当前红黑树为空,新结点N位于树的根上,没有父结点。
此时很简单,我们将直接插入一个黑结点N(满足性质2),其他情况下插入的N为红色(原因在前面提到了)。 C代码 1. void insert_case1(node n) { 2. if (n->parent == NULL) 3. n->color = BLACK; 4. else 5. insert_case2(n); //插入情况2 6. } 情况2. 新结点N的父结点P是黑色。
在这种情况下,我们插入一个红色结点N(满足性质5)。 Java代码 1. void insert_case2(node n) { 2. if (n->parent->color == BLACK) 3. return; // 树仍旧有效 4. else 5. insert_case3(n); //插入情况3 6. }
注意:在情况3,4,5下,我们假定新节点有祖父节点,因为父节点是红色;并且如果它是根,它就应当是黑色。所以新节点总有一个叔父节点,尽管在情形4和5下它可能是叶子。
情况3.如果父节点P和叔父节点U二者都是红色。
如下图,因为新加入的N结点必须为红色,那么我们可以将父结点P(保证性质4),以及N的叔父结点U(保证性质5)重新绘制成黑色。如果此时祖父结点G是根,则结束变化。如果不是根,则祖父结点重绘为红色(保证性质5)。但是,G的父亲也可能是红色的,为了保证性质4。我们把G递归当做新加入的结点N在进行各种情况的重新检查。
C代码 1. void insert_case3(node n) { 2. if (uncle(n) != NULL && uncle(n)->color == RED) { 3. n->parent->color = BLACK; 4. uncle(n)->color = BLACK; 5. grandparent(n)->color = RED; 6. insert_case1(grandparent(n)); 7. } 8. else 9. insert_case4(n); 10. }
注意:在情形4和5下,我们假定父节点P 是祖父结点G 的左子节点。如果它是右子节点,情形4和情形5中的左和右应当对调。
情况4. 父节点P是红色而叔父节点U是黑色或缺少; 另外,新节点N是其父节点P的右子节点,而父节点P又是祖父结点G的左子节点。
如下图, 在这种情形下,我们进行一次左旋转调换新节点和其父节点的角色(与AVL树的左旋转相同); 这导致某些路径通过它们以前不通过的新节点N或父节点P中的一个,但是这两个节点都是红色的,所以性质5没有失效。但目前情况将违反性质4,所以接着,我们按下面的情况5继续处理以前的父节点P。
C代码 1. void insert_case4(node n) { 2. 3. if (n == n->parent->right && n->parent == grandparent(n)->left) { 4. rotate_left(n->parent); 5. n = n->left; 6. } else if (n == n->parent->left && n->parent == grandparent(n)->right) { 7. rotate_right(n->parent); 8. n = n->right; 9. } 10. insert_case5(n) 11. }
情况5. 父节点P是红色而叔父节点U 是黑色或缺少,新节点N 是其父节点的左子节点,而父节点P又是祖父结点的G的左子节点。
如下图: 在这种情形下,我们进行针对祖父节点P 的一次右旋转; 在旋转产生的树中,以前的父节点P现在是新节点N和以前的祖父节点G 的父节点。我们知道以前的祖父节点G是黑色,否则父节点P就不可能是红色。我们切换以前的父节点P和祖父节点G的颜色,结果的树满足性质4[3]。性质 5[4]也仍然保持满足,因为通过这三个节点中任何一个的所有路径以前都通过祖父节点G ,现在它们都通过以前的父节点P。在各自的情形下,这都是三个节点中唯一的黑色节点。
C代码 1. void insert_case5(node n) { 2. n->parent->color = BLACK; 3. grandparent(n)->color = RED; 4. if (n == n->parent->left && n->parent == grandparent(n)->left) { 5. rotate_right(grandparent(n)); 6. } else { 7. /* Here, n == n->parent->right && n->parent == grandparent(n)->right */ 8. rotate_left(grandparent(n)); 9. } 10. }
删除操作
如果需要删除的节点有两个儿子,那么问题可以被转化成删除另一个只有一个儿子的节点的问题(为了表述方便,这里所指的儿子,为非叶子节点的儿子)。 对于二叉查找树,在删除带有两个非叶子儿子的节点的时候,我们找到要么在它的左子树中的最大元素、要么在它的右子树中的最小元素,并把它的值转移到要删除 的节点中(如在这里所展示的那样)。我们接着删除我们从中复制出值的那个节点,它必定有少于两个非叶子的儿子。因为只是复制了一个值而不违反任何属性,这 就把问题简化为如何删除最多有一个儿子的节点的问题。它不关心这个节点是最初要删除的节点还是我们从中复制出值的那个节点。
在本文余下的部分中,我们只需要讨论删除只有一个儿子的节点(如果它两个儿子都为空,即均为叶子,我们任意将其中一个看作它的儿子)。如果我们删除一个红色节点,它的父亲和儿子一定是黑色的。所以我们可以简单的用它的黑色儿子替换它,并不会破坏属性3和4。通过被删除节点的所有路径只是少了一个红色 节点,这样可以继续保证属性5。另一种简单情况是在被删除节点是黑色而它的儿子是红色的时候。如果只是去除这个黑色节点,用它的红色儿子顶替上来的话,会 破坏属性4,但是如果我们重绘它的儿子为黑色,则曾经通过它的所有路径将通过它的黑色儿子,这样可以继续保持属性4。
需要进一步讨论的是在要删除的节点和它的儿子二者都是黑色的时候,这是一种复杂的情况。我们首先把要删除的节点替换为它的儿子。出于方便,称呼这个儿子为 N,称呼它的兄弟(它父亲的另一个儿子)为S。在下面的示意图中,我们还是使用P称呼N的父亲,SL称呼S的左儿子,SR称呼S的右儿子。我们将使用下述 函数找到兄弟节点: C代码 1. struct node * sibling(struct node *n) 2. { 3. if (n == n->parent->left) 4. return n->parent->right; 5. else 6. return n->parent->left; 7. } 我们可以使用下列代码进行上述的概要步骤,这里的函数 replace_node 替换 child 到 n 在树中的位置。出于方便,在本章节中的代码将假定空叶子被用不是 NULL 的实际节点对象来表示(在插入章节中的代码可以同任何一种表示一起工作)。 C代码 1. void delete_one_child(struct node *n) 2. { 3. /* 4. * Precondition: n has at most one non-null child. 5. */ 6. struct node *child = is_leaf(n->right) ? n->left : n->right; 7. 8. replace_node(n, child); 9. if (n->color == BLACK) { 10. if (child->color == RED) 11. child->color = BLACK; 12. else 13. delete_case1(child); 14. } 15. free(n); 16. } 如果 N 和它初始的父亲是黑色,则删除它的父亲导致通过 N 的路径都比不通过它的路径少了一个黑色节点。因为这违反了属性 4,树需要被重新平衡。有几种情况需要考虑:
情况1. N 是新的根。 在这种情况下,我们就做完了。我们从所有路径去除了一个黑色节点,而新根是黑色的,所以属性都保持着。 C代码 1. void delete_case1(struct node *n) 2. { 3. if (n->parent != NULL) 4. delete_case2(n); 5. }
注意: 在情况2、5和6下,我们假定 N 是它父亲的左儿子。如果它是右儿子,则在这些情况下的左和右应当对调。
情况2. S 是红色。
在这种情况下我们在N的父亲上做左旋转,把红色兄弟转换成N的祖父。我们接着对调 N 的父亲和祖父的颜色。尽管所有的路径仍然有相同数目的黑色节点,现在 N 有了一个黑色的兄弟和一个红色的父亲,所以我们可以接下去按 4、5或6情况来处理。(它的新兄弟是黑色因为它是红色S的一个儿子。) C代码 1. void delete_case2(struct node *n) 2. { 3. struct node *s = sibling(n); 4. 5. if (s->color == RED) { 6. n->parent->color = RED; 7. s->color = BLACK; 8. if (n == n->parent->left) 9. rotate_left(n->parent); 10. else 11. rotate_right(n->parent); 12. } 13. delete_case3(n); 14. }
情况 3: N 的父亲、S 和 S 的儿子都是黑色的。
在这种情况下,我们简单的重绘 S 为红色。结果是通过S的所有路径, 它们就是以前不通过 N 的那些路径,都少了一个黑色节点。因为删除 N 的初始的父亲使通过 N 的所有路径少了一个黑色节点,这使事情都平衡了起来。但是,通过 P 的所有路径现在比不通过 P 的路径少了一个黑色节点,所以仍然违反属性4。要修正这个问题,我们要从情况 1 开始,在 P 上做重新平衡处理。 、 C代码 1. void delete_case3(struct node *n) 2. { 3. struct node *s = sibling(n); 4. 5. if ((n->parent->color == BLACK) && 6. (s->color == BLACK) && 7. (s->left->color == BLACK) && 8. (s->right->color == BLACK)) { 9. s->color = RED; 10. delete_case1(n->parent); 11. } else 12. delete_case4(n); 13. }
情况4. S 和 S 的儿子都是黑色,但是 N 的父亲是红色。
在这种情况下,我们简单的交换 N 的兄弟和父亲的颜色。这不影响不通过 N 的路径的黑色节点的数目,但是它在通过 N 的路径上对黑色节点数目增加了一,添补了在这些路径上删除的黑色节点。 Java代码 1. void delete_case4(struct node *n) 2. { 3. struct node *s = sibling(n); 4. 5. if ((n->parent->color == RED) && 6. (s->color == BLACK) && 7. (s->left->color == BLACK) && 8. (s->right->color == BLACK)) { 9. s->color = RED; 10. n->parent->color = BLACK; 11. } else 12. delete_case5(n); 13. }
情况5. S 是黑色,S 的左儿子是红色,S 的右儿子是黑色,而 N 是它父亲的左儿子。
在这种情况下我们在 S 上做右旋转,这样 S 的左儿子成为 S 的父亲和 N 的新兄弟。我们接着交换 S 和它的新父亲的颜色。所有路径仍有同样数目的黑色节点,但是现在 N 有了一个右儿子是红色的黑色兄弟,所以我们进入了情况 6。N 和它的父亲都不受这个变换的影响。 C代码 1. void delete_case5(struct node *n) 2. { 3. struct node *s = sibling(n); 4. 5. if (s->color == BLACK) 6. /* this if statement is trivial, 7. due to Case 2 (even though Case two changed the sibling to a sibling's child, 8. the sibling's child can't be red, since no red parent can have a red child). */ 9. 10. // the following statements just force the red to be on the left of the left of the parent, 11. // or right of the right, so case six will rotate correctly. 12. if ((n == n->parent->left) && 13. (s->right->color == BLACK) && 14. (s->left->color == RED)) { // this last test is trivial too due to cases 2-4. 15. s->color = RED; 16. s->left->color = BLACK; 17. rotate_right(s); 18. } else if ((n == n->parent->right) && 19. (s->left->color == BLACK) && 20. (s->right->color == RED)) {// this last test is trivial too due to cases 2-4. 21. s->color = RED; 22. s->right->color = BLACK; 23. rotate_left(s); 24. } 25. } 26. delete_case6(n); 27. }
情况6. S 是黑色,S 的右儿子是红色,而 N 是它父亲的左儿子。
在这种情况下我们在 N 的父亲上做左旋转,这样 S 成为 N 的父亲和 S 的右儿子的父亲。我们接着交换 N 的父亲和 S 的颜色,并使 S 的右儿子为黑色。子树在它的根上的仍是同样的颜色,所以属性 3 没有被违反。但是,N 现在增加了一个黑色祖先: 要么 N 的父亲变成黑色,要么它是黑色而 S 被增加为一个黑色祖父。所以,通过 N 的路径都增加了一个黑色节点。 此时,如果一个路径不通过 N,则有两种可能性: 它通过 N 的新兄弟。那么它以前和现在都必定通过 S 和 N 的父亲,而它们只是交换了颜色。所以路径保持了同样数目的黑色节点。 C代码 1. void delete_case6(struct node *n) 2. { 3. struct node *s = sibling(n); 4. 5. s->color = n->parent->color; 6. n->parent->color = BLACK; 7. 8. if (n == n->parent->left) { 9. s->right->color = BLACK; 10. rotate_left(n->parent); 11. } else { 12. s->left->color = BLACK; 13. rotate_right(n->parent); 14. } 15. }
同样的,函数调用都使用了尾部递归,所以算法是就地的。此外,在旋转之后不再做递归调用,所以进行了恒定数目(最多 3 次)的旋转。
红黑树的优势
红黑树能够以O(log2(N))的时间复杂度进行搜索、插入、删除操作。此外,任何不平衡都会在3次旋转之内解决。这一点是AVL所不具备的。
而且实际应用中,很多语言都实现了红黑树的数据结构。比如 TreeMap, TreeSet(Java )、 STL(C++)等。
|
在前面专题中讲的BST、AVL、RBT都是典型的二叉查找树结构,其查找的时间复杂度与树高相关。那么降低树高自然对查找效率是有所帮助的。另外还有一个比较实际的问题:就是大量数据存储中,实现查询这样一个实际背景下,平衡二叉树由于树深度过大而造成磁盘IO读写过于频繁,进而导致效率低下。那么如何减少树的深度(当然不能减少查询数据量),一个基本的想法就是: 1. 每个节点存储多个元素 (但元素数量不能无限多,否则查找就退化成了节点内部的线性查找了)。 2. 摒弃二叉树结构,采用多叉树 (由于节点内元素数量不能无限多,自然子树的数量也就不会无限多了)。 这样我们就提出来了一个新的查找树结构 ——多路查找树。 根据AVL给我们的启发,一颗平衡多路查找树(B~树)自然可以使得数据的查找效率保证在O(logN)这样的对数级别上。
【2-4树】
(2,4)树是一棵典型的平衡多路查找树。性质如下: 1. 大小性质:每个结点最多4个子结点。 2. 深度性质:所有外部结点的深度相同。
(2,4)其实是一棵迷你型的B树,其主要应用并不是为了将大数据量存储在外存上,而是通过减少树高来降低二叉查找树的查找代价。在介绍下面的B~树/B+树之前,请大家首先了解一下《外部存储器—磁盘 》。
【B~树】
B~树,又叫平衡多路查找树。一棵m阶的B~树 (m叉树)的特性如下: 1) 树中每个结点至多有m个孩子; 2) 除根结点和叶子结点外,其它每个结点至少有[m/2]个孩子; 3) 若根结点不是叶子结点,则至少有2个孩子; 4) 所有叶子结点都出现在同一层,叶子结点不包含任何关键字信息(可以看做是外部接点或查询失败的接点,实际上这些结点不存在,指向这些结点的指针都为null); 5) 每个非终端结点中包含有n个关键字信息: (n,A0,K1,A1,K2,A2,......,Kn,An)。其中, a) Ki (i=1...n)为关键字,且关键字按顺序排序Ki < K(i-1)。 b) Ai为指向子树根的接点,且指针A(i-1)指向子树种所有结点的关键字均小于Ki,但都大于K(i-1)。 c) 关键字的个数n必须满足: [m/2]-1 <= n <= m-1
例如:下面就是一棵3阶B~树 (为了简单,这里用少量数据构造一棵2-4树的形式,其实实际应用中的B树结点中关键字很多的)
B~树的建立 由于B~树结点中的关键字个数必须>=[m/2]-1。因此和平衡二叉树不同,每一次插入一个关键字并不是在树中添加一个结点,而是首先在最低层的某个非终端结点中添加一个关键字,若该结点的关键字个数不超过m-1,则插入完成。否则,要产生结点的"分裂" 。 关于B~树以及后面B+树的插入,删除操作,在《数据结构算法与应用-搜索树 》中有详细讲解。
关于文件目录索引的B~树 磁盘 存储结构及其查询过程 既然我们说过B~树相比平衡二叉树的一个巨大的特点就是:海量数据存储时B~树利用磁盘存储的效率会高很多。那么我们现在就来简单的看看操作系统中文件目录的B~树结构是怎样的(这里只介绍简单的原理,至于想Windows,Linux的文件系统使用的是B+树结构,而且技术远远比这里介绍的复杂)。
首先,我们构造一个B树结点的信息: Java代码 1. class BTNode 2. 3. // 结点中文件个数 4. int filenum; 5. //子树的根结点指针向量 6. BTNode[] ptr=new BTNode(filenum+1); 7. //文件名向量 8. E[] filename=new E(filenum); 9. // 指向磁盘中文件存储地址向量 10. FileHardAddress[] recptr=new FileHardAddress(filenum); 11. 12. } 上面的图中比如根结点,其中17表示一个磁盘文件的文件名;小红方块表示这个17文件的内容在硬盘中的存储位置;p1表示指向17左子树的指针。 我们现在把整棵树构造在磁盘中,假如每个盘块可以正好存放一个B~树的结点(正好存放2个文件名)。那么一个BTNode结点就代表一个盘块,而子树指针就是存放另外一个盘块 (详细见《外部存储器—磁盘 》)的地址。 现在我们模拟查找文件29的过程: (1) 根据根结点指针找到文件目录的根磁盘块1,将其中的信息导入内存。【磁盘IO操作1次】 (2) 此时内存中有两个文件名17,35和三个存储其他磁盘页面地址的数据。根据算法我们发现17<29<35,因此我们找到指针p2。 (3) 根据p2指针,我们定位到磁盘块3,并将其中的信息导入内存。【磁盘IO操作2次】 (4) 此时内存中有两个文件名26,30和三个存储其他磁盘页面地址的数据。根据算法我们发现26<29<30,因此我们找到指针p2。 (5) 根据p2指针,我们定位到磁盘块8,并将其中的信息导入内存。【磁盘IO操作3次】 (6) 此时内存中有两个文件名28,29。根据算法我们查找到文件29,并定位了该文件内存的磁盘地址。
分析一下上面的过程,我们发现需要3次磁盘IO操作和3次内存查找操作。关于内存中的文件名查找,由于是一个有序表结构,可以利用折半查找提高效率。至于3次磁盘IO操作时影响整个B~树查找效率的决定因素。 当然,如果我们使用平衡二叉树的磁盘存储结构来进行查找,磁盘IO操作最少4次,最多5次。而且文件越多,B~树比平衡二叉树所用的磁盘IO操作次数将越少,效率也越高。
【B+树】
B+树:是应文件系统所需而产生的一种B~树的变形树。 一棵m阶的B+树和m阶的B-树的差异在于: 1) 有n棵子树的结点中含有n个关键字; (B~树是n棵子树有n+1个关键字) 3) 所有的非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键字。 (B~树的非终节点也包含需要查找的有效信息)
例如:下面就是一棵3阶B+树。我们可以和B~树做一个明显的对比。
下面我们用另外一个图来看一下B+树叶子节点和非终节点的特点:
上面这个图有一个很重要的性质:B+树的叶子结点包含了所有待查询关键字,而非终节点只是作为叶子结点中最大(最小)关键字的索引。因此B+树的非终结点没有文件内容所在物理存储的地址,而B~树所有结点均有文件内容所在的磁盘物理地址(B~树结构图中结点内部的小红方块)。 这个特点是B+树的一个重要优势所在。这一点我们在下面谈及。
关于FOXPRO索引文件的B+树磁盘存储结构及其查询 B+树在数据库,文件系统的索引结构中是十分常用的。关于B+树的磁盘存储可以参见上面B~树的情况,其一个结点用一个磁盘块存储。在这里我们对FOXPRO索引文件做一个简单的介绍,让大家对B+树的磁盘存储有一个大致的了解。
FOXPRO的索引文件(后缀IDX)由索引文件头和索引文件体组成。 文件头占一个块,相对于索引文件的物理零块号,它描述索引文件的组织信息,包括索引树的根结点位置,索引关键字表达式及索引关键字长度.其有用字节的含义如下表:
索引文件体从索引文件的相对物理块号为1的块开始,文件体的每块也就是索引树的一个结点。其中重要的是索引项。索引项=关键字+指针域(4字节)。这就是我们上面常说的B+树结点中的两个重要的信息:待查询关键字和指向另一个结点的指针。文件体每块含义如下表:
B+树的查找与B~树类似。但通常B+树有两个头指针,一个指向根结点。另一个指向关键字最小的叶子节点。此外,所有叶子结点也按照大小顺序链接。因此,B+树有两种查找算法:一种从根结点出发,一种从叶子结点出发的顺序查找。
B+树的优势所在
为什么说B+树比B~树更适合实际应用中操作系统的文件索引和数据库索引?
1、B+树的磁盘读写代价更低
我们都知道磁盘时可以块存储的,也就是同一个磁道上同一盘块中的所有数据都可以一次全部读取(详见《 外部存储器—磁盘 》 )。而B+树的内部结点并没有指向关键字具体信息的指针(比如文件内容的具体地址 , 比如说不包含B~树结点中的FileHardAddress[filenum]部分) 。因此其内部结点相对B~树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。这样,一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。
举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B~树(一个结点最多8个关键字)的内部结点需要2个盘快。而B+树内部结点只需要1个盘快。当需要把内部结点读入内存中的时候,B~树就比B+数多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。
2、B+树的查询效率更加稳定。
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
|
http://hxraid.iteye.com/blog/614070 我们这个专题介绍的动态查找树主要有: 二叉查找树(BST),平衡二叉查找树(AVL),红黑树(RBT),B~/B+树(B-tree)。这四种树都具备下面几个优势: (1) 都是动态结构。在删除,插入操作的时候,都不需要彻底重建原始的索引树。最多就是执行一定量的旋转,变色操作来有限的改变树的形态。而这些操作所付出的代价都远远小于重建一棵树。这一优势在《查找结构专题(1):静态查找结构概论 》中讲到过。 (2) 查找的时间复杂度大体维持在O(log(N))数量级上。可能有些结构在最差的情况下效率将会下降很快,比如BST。这个会在下面的对比中详细阐述。
下面我们开始概括性描述这四种树,并相互比较一下优劣。
1. 二叉查找树 (Binary Search Tree) 详细见《查找结构专题(2):二叉查找树 [BST] 》
很显然,二叉查找树的发现完全是因为静态查找结构在动态插入,删除结点所表现出来的无能为力(需要付出极大的代价)。
BST 的操作代价分析: (1) 查找代价: 任何一个数据的查找过程都需要从根结点出发,沿某一个路径朝叶子结点前进。因此查找中数据比较次数与树的形态密切相关。 当树中每个结点左右子树高度大致相同时,树高为logN。则平均查找长度与logN成正比,查找的平均时间复杂度在O(logN)数量级上。 当先后插入的关键字有序时,BST退化成单支树结构。此时树高n。平均查找长度为(n+1)/2,查找的平均时间复杂度在O(N)数量级上。
(2) 插入代价: 新结点插入到树的叶子上,完全不需要改变树中原有结点的组织结构。插入一个结点的代价与查找一个不存在的数据的代价完全相同。
(3) 删除代价: 当删除一个结点P,首先需要定位到这个结点P,这个过程需要一个查找的代价。然后稍微改变一下树的形态。如果被删除结点的左、右子树只有一个存在,则改变形态的代价仅为O(1)。如果被删除结点的左、右子树均存在,只需要将当P的左孩子的右孩子的右孩子的...的右叶子结点与P互换,在改变一些左右子树即可。因此删除操作的时间复杂度最大不会超过O(logN)。
BST效率总结 : 查找最好时间复杂度O(logN),最坏时间复杂度O(N)。 插入删除操作算法简单,时间复杂度与查找差不多
2. 平衡二叉查找树 ( Balanced Binary Search Tree ) 详细见《查找结构专题(3):平衡二叉查找树 [AVL] 》
二叉查找树在最差情况下竟然和顺序查找效率相当,这是无法仍受的。事实也证明,当存储数据足够大的时候,树的结构对某些关键字的查找效率影响很大。当然,造成这种情况的主要原因就是BST不够平衡(左右子树高度差太大)。
既然如此,那么我们就需要通过一定的算法,将不平衡树改变成平衡树。因此,AVL树就诞生了。
AVL 的操作代价分析: (1) 查找代价: AVL是严格平衡的BST(平衡因子不超过1)。那么查找过程与BST一样,只是AVL不会出现最差情况的BST(单支树)。因此查找效率最好,最坏情况都是O(logN)数量级的。
(2) 插入代价: AVL必须要保证严格平衡(|bf|<=1),那么每一次插入数据使得AVL中某些结点的平衡因子超过1就必须进行旋转操作。事实上,AVL的每一次插入结点操作最多只需要旋转1次(单旋转或双旋转)。因此,总体上插入操作的代价仍然在O(logN)级别上(插入结点需要首先查找插入的位置)。
(3) 删除代价:AVL删除结点的算法可以参见BST的删除结点,但是删除之后必须检查从删除结点开始到根结点路径上的所有结点的平衡因子。因此删除的代价稍微要大一些。每一次删除操作最多需要O(logN)次旋转。因此,删除操作的时间复杂度为O(logN)+O(logN)=O(2logN)
AVL 效率总结 : 查找的时间复杂度维持在O(logN),不会出现最差情况 AVL树在执行每个插入操作时最多需要1次旋转,其时间复杂度在O(logN)左右。 AVL树在执行删除时代价稍大,执行每个删除操作的时间复杂度需要O(2logN)。
3. 红黑树 (Red-Black Tree ) 详细见《查找结构专题(4):红黑树 [RBT] 》
二叉平衡树的严格平衡策略以牺牲建立查找结构(插入,删除操作)的代价,换来了稳定的O(logN) 的查找时间复杂度。但是这样做是否值得呢?
能不能找一种折中策略,即不牺牲太大的建立查找结构的代价,也能保证稳定高效的查找效率呢? 答案就是:红黑树。
RBT 的操作代价分析: (1) 查找代价:由于红黑树的性质(最长路径长度不超过最短路径长度的2倍),可以说明红黑树虽然不像AVL一样是严格平衡的,但平衡性能还是要比BST要好。其查找代价基本维持在O(logN)左右,但在最差情况下(最长路径是最短路径的2倍少1),比AVL要略逊色一点。
(2) 插入代价:RBT插入结点时,需要旋转操作和变色操作。但由于只需要保证RBT基本平衡就可以了。因此插入结点最多只需要2次旋转,这一点和AVL的插入操作一样。虽然变色操作需要O(logN),但是变色操作十分简单,代价很小。
(3) 删除代价:RBT的删除操作代价要比AVL要好的多,删除一个结点最多只需要3次旋转操作。
RBT 效率总结 : 查找 效率最好情况下时间复杂度为O(logN),但在最坏情况下比AVL要差一些,但也远远好于BST。 插入和删除操作改变树的平衡性的概率要远远小于AVL(RBT不是高度平衡的)。因此需要的旋转操作的可能性要小,而且一旦需要旋转,插入一个结点最多只需要旋转2次,删除最多只需要旋转3次(小于AVL的删除操作所需要的旋转次数)。虽然变色操作的时间复杂度在O(logN),但是实际上,这种操作由于简单所需要的代价很小。
4. B~树/B+树 (B-Tree ) 详细见《查找结构专题(5):B~树/B+树 》
对于在内存中的查找结构而言,红黑树的效率已经非常好了(实际上很多实际应用还对RBT进行了优化)。但是如果是数据量非常大的查找呢?将这些数据全部放入内存组织成RBT结构显然是不实际的。实际上,像OS中的文件目录存储,数据库中的文件索引结构的存储.... 都不可能在内存中建立查找结构。必须在磁盘中建立好这个结构。那么在这个背景下,RBT还是一种好的选择吗?
在磁盘中组织查找结构,从任何一个结点指向其他结点都有可能读取一次磁盘数据,再将数据写入内存进行比较。大家都知道,频繁的磁盘IO操作,效率是很低下的(机械运动比电子运动要慢不知道多少)。显而易见,所有的二叉树的查找结构在磁盘中都是低效的。因此,B树很好的解决了这一个问题。
B-Tree的操作代价分析: (1) 查找代价: B-Tree作为一个平衡多路查找树(m-叉)。B树的查找分成两种:一种是从一个结点查找另一结点的地址的时候,需要定位磁盘地址(查找地址),查找代价极高。另一种是将结点中的有序关键字序列放入内存,进行优化查找(可以用折半),相比查找代价极低。而B树的高度很小,因此在这一背景下,B树比任何二叉结构查找树的效率都要高很多。而且B+树作为B树的变种,其查找效率更高。
(2)插入代价: B-Tree的插入会发生结点的分裂操作。当插入操作引起了s个节点的分裂时,磁盘访问的次数为h(读取搜索路径上的节点)+2s(回写两个分裂出的新节点)+1(回写新的根节点或插入后没有导致分裂的节点)。因此,所需要的磁盘访问次数是h+2s+1,最多可达到3h+1。因此插入的代价是很大的。
(3)删除代价:B-Tree的删除会发生结点合并操作。最坏情况下磁盘访问次数是3h=(找到包含被删除元素需要h次
B-Tree效率总结: 由于考虑磁盘储存结构,B树的查找、删除、插入的代价都远远要小于任何二叉结构树(读写磁盘次数的降低)。
动态查找树结构的对比:
(1) 平衡二叉树和红黑树 [AVL PK RBT]
AVL 和RBT 都是二叉查找树的优化。其性能要远远好于二叉查找树。他们之间都有自己的优势,其应用上也有不同。
结构对比: AVL的结构高度平衡,RBT的结构基本平衡。平衡度AVL > RBT.
查找对比: AVL 查找时间复杂度最好,最坏情况都是O(logN)。 RBT 查找时间复杂度最好为O(logN),最坏情况下比AVL略差。
插入删除对比: 1. AVL的插入和删除结点很容易造成树结构的不平衡,而RBT的平衡度要求较低。因此在大量数据插入的情况下,RBT需要通过旋转变色操作来重新达到平衡的频度要小于AVL。 2. 如果需要平衡处理时,RBT比AVL多一种变色操作,而且变色的时间复杂度在O(logN)数量级上。但是由于操作简单,所以在实践中这种变色仍然是非常快速的。 3. 当插入一个结点都引起了树的不平衡,AVL和RBT都最多需要2次旋转操作。但删除一个结点引起不平衡后,AVL最多需要logN 次旋转操作,而RBT最多只需要3次。因此两者插入一个结点的代价差不多,但删除一个结点的代价RBT要低一些。 4. AVL和RBT的插入删除代价主要还是消耗在查找待操作的结点上。因此时间复杂度基本上都是与O(logN) 成正比的。
总体评价:大量数据实践证明,RBT的总体统计性能要好于平衡二叉树。
(2) B~树和B+树 [ B~Tree PK B+Tree]
B+树是B~树的一种变体,在磁盘查找结构中,B+树更适合文件系统的磁盘存储结构。
结构对比: B~树是平衡多路查找树,所有结点中都包含了待查关键字的有效信息(比如文件磁盘指针)。每个结点若有n个关键字,则有n+1个指向其他结点的指针。 B+树严格意义上说已经不是树,它的叶子结点之间也有指针链接。B+树的非终结点中并不含有关键字的信息,需要查找的关键字的全部信息都包含在叶子结点上。非终结点中只作为叶子结点关键字的索引而存在。
查找对比:1. 在相同数量的待查数据下,B+树查找过程中需要调用的磁盘IO操作要少于普通B~树。由于B树所在的磁盘存储背景下,因此B+树的查找性能要好于B~树。 2. B+树的查找效率更加稳定,因为所有叶子结点都处于同一层中,而且查找所有关键字都必须走完从根结点到叶子结点的全部历程。因此同一颗B+树中,任何关键字的查找比较次数都是一样的。而B树就不一定了,可能查找到某一个非终结点就结束了。
插入删除对比: B+树与B~树在插入删除操作中的效率是差不多的。
总体评价:在应用背景下,特别是文件结构存储中。B+树的应用要更多,其效率也要比B~树好。
字符串查找结构
这次专题所讲的BST、AVL、BRT、B~Tree等可以胜任对任何关键字数据进行查找。但对字符串的查找(字符串匹配)结构,有专门的结构和算法。详见:《KMP算法 》,《Trie Tree 》
· 数据结构算法与应用-搜索树.pdf (1.7 MB) · 下载次数: 229
|