菜鸟笔记
提升您的技术认知

一篇文章彻底搞懂红黑树

0.概述

如上图整理所示 红黑树是一种平衡的二叉树,引入它为了解决的问题是因为普通的二叉查找树在插入元素是正序或者逆序的时候,左右子树的高度会变得极奇不平衡,导致他就丧失了作为二叉查找树查找复杂度O(logN)的优势,复杂度会增长到O(N).
所以就引入了AVL树和红黑树这两种能够在元素插入删除时候自动按照给定的规则调整元素间的顺序关系,
从而保持平衡.

 

1.定义

 综上所述,我们可以把红黑树概括为 
红黑树是一种含有红黑结点并能自平衡的二叉查找树。

2.性质

它必须满足下面性质:

  • 性质1:每个节点要么是黑色,要么是红色。
  • 性质2:根节点是黑色。
  • 性质3:每个叶子节点(NIL)是黑色。
  • 性质4:从根节点到叶子节点任何一条路径上 不能出现两个连续的红结点
  • 性质5:从根节点到叶子节点任何一条路径上都包含数量相同的黑结点。

上图就是一颗简单的红黑树。其中Nil为叶子结点,并且它是黑色的。(H和M的黑色叶子节点没画出来)

介绍到此,为了后面讲解不至于混淆,我们还需要来约定下红黑树一些结点的叫法,如图2所示。

图2 结点叫法约定

我们把正在处理(遍历)的结点叫做当前结点,如图2中的D,它的父亲叫做父结点,它的父亲的另外一个子结点叫做兄弟结点,父亲的父亲叫做祖父结点。

3.基本操作

前面讲到红黑树能自平衡,它靠的是什么?三种操作:左旋、右旋和变色。

  • 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。如图3。
  • 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。如图4。
  • 变色:结点的颜色由红变黑或由黑变红。


图3 左旋


图4 右旋

上面所说的旋转结点也即旋转的支点,图4和图5中的P结点。
我们先忽略颜色,可以看到旋转操作不会影响旋转结点的父结点,父结点以上的结构还是保持不变的。
左旋只影响旋转结点和其右子树的结构,把右子树的结点往左子树挪了。
右旋只影响旋转结点和其左子树的结构,把左子树的结点往右子树挪了。

所以旋转操作是局部的。另外可以看出旋转能保持红黑树平衡的一些端详了:当一边子树的结点少了,那么向另外一边子树“借”一些结点;当一边子树的结点多了,那么向另外一边子树“租”一些结点。

但要保持红黑树的性质,结点不能乱挪,还得靠变色了。怎么变?具体情景又不同变法,后面会具体讲到,现在只需要记住红黑树总是通过旋转和变色达到自平衡

 

4.功能

4.1查找 

 

因为红黑树是一颗二叉平衡树,并且查找不会破坏树的平衡,所以查找跟二叉平衡树的查找无异:

  1. 从根结点开始查找,把根结点设置为当前结点;
  2. 若当前结点为空,返回null;
  3. 若当前结点不为空,用当前结点的key跟查找key作比较;
  4. 若当前结点key等于查找key,那么该key就是查找目标,返回当前结点;
  5. 若当前结点key大于查找key,把当前结点的左子结点设置为当前结点,重复步骤2;
  6. 若当前结点key小于查找key,把当前结点的右子结点设置为当前结点,重复步骤2;

如图5所示。

图5 二叉树查找流程图

非常简单,但简单不代表它效率不好。正由于红黑树总保持黑色完美平衡,所以它的查找最坏时间复杂度为O(2lgN),也即整颗树刚好红黑相隔的时候。能有这么好的查找效率得益于红黑树自平衡的特性,而这背后的付出,红黑树的插入操作功不可没~

 

4.2 插入
 

插入操作包括两部分工作:

一查找插入的位置  即找到要插入的父节点;
二插入后自平衡。

查找插入的父结点很简单,跟查找操作区别不大:

  1. 从根结点开始查找;
  2. 若根结点为空,那么插入结点作为根结点,结束。
  3. 若根结点不为空,那么把根结点作为当前结点;
  4. 若当前结点为null,返回当前结点的父结点,结束。
  5. 若当前结点key等于查找key,那么该key所在结点就是插入结点,更新结点的值,结束。
  6. 若当前结点key大于查找key,把当前结点的左子结点设置为当前结点,重复步骤4;
  7. 若当前结点key小于查找key,把当前结点的右子结点设置为当前结点,重复步骤4;

如图6所示。

图6 红黑树插入位置查找

ok,插入位置已经找到,把插入结点放到正确的位置就可以啦,但插入结点是应该是什么颜色呢?答案是红色。理由很简单,红色在父结点(如果存在)为黑色结点时,红黑树的黑色平衡没被破坏,不需要做自平衡操作。但如果插入结点是黑色,那么插入位置所在的子树黑色结点总是多1,必须做自平衡。

 

所有插入情景如图7所示。

图7 红黑树插入情景

嗯,插入情景很多呢,8种插入情景!但情景1、2和3的处理很简单,而情景4.2和情景4.3只是方向反转而已,懂得了一种情景就能推出另外一种情景,所以总体来看,并不复杂,后续我们将一个一个情景来看,把它彻底搞懂。

另外,根据二叉树的性质,除了情景2,所有插入操作都是在叶子结点进行的。这点应该不难理解,因为查找插入位置时,我们就是在找子结点为空的父结点的。

在开始每个情景的讲解前,我们还是先来约定下,如图8所示。

图8 插入操作结点的叫法约定

图8的字母并不代表结点Key的大小。I表示插入结点,P表示插入结点的父结点,S表示插入结点的叔叔结点,PP表示插入结点的祖父结点。

好了,下面让我们一个一个来分析每个插入的情景以其处理。

插入情景1:红黑树为空树

最简单的一种情景,直接把插入结点作为根结点就行,但注意,根据红黑树性质2:根节点是黑色。还需要把插入结点设为黑色。

处理:把插入结点作为根结点,并把结点设置为黑色

插入情景2:插入结点的Key已存在

插入结点的Key已存在,既然红黑树总保持平衡,在插入前红黑树已经是平衡的,那么把插入结点设置为将要替代结点的颜色,再把结点的值更新就完成插入。

处理:

  • 把I设为当前结点的颜色
  • 更新当前结点的值为插入结点的值

插入情景3:插入结点的父结点为黑结点

由于插入的结点是红色的,当插入结点的黑色时,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。

处理:直接插入

插入情景4:插入结点的父结点为红结点

再次回想下红黑树的性质2:根结点是黑色。如果插入的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。这点很重要,因为后续的旋转操作肯定需要祖父结点的参与。

情景4又分为很多子情景,下面将进入重点部分,各位看官请留神了。

插入情景4.1:叔叔结点存在并且为红结点
从红黑树性质4可以,祖父结点肯定为黑结点,因为不可以同时存在两个相连的红结点。那么此时该插入子树的红黑层数的情况是:黑红红。显然最简单的处理方式是把其改为:红黑红。如图9和图10所示。

处理:

  • 将P和S设置为黑色
  • 将PP设置为红色
  • 把PP设置为当前插入结点

图9 插入情景4.1_1

图10 插入情景4.1_2

可以看到,我们把PP结点设为红色了,如果PP的父结点是黑色,那么无需再做任何处理;但如果PP的父结点是红色,根据性质4,此时红黑树已不平衡了,所以还需要把PP当作新的插入结点,继续做插入操作自平衡处理,直到平衡为止。

试想下PP刚好为根结点时,那么根据性质2,我们必须把PP重新设为黑色,那么树的红黑结构变为:黑黑红。换句话说,从根结点到叶子结点的路径中,黑色结点增加了。这也是唯一一种会增加红黑树黑色结点层数的插入情景

我们还可以总结出另外一个经验:红黑树的生长是自底向上的。这点不同于普通的二叉查找树,普通的二叉查找树的生长是自顶向下的。

插入情景4.2:叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的左子结点
单纯从插入前来看,也即不算情景4.1自底向上处理时的情况,叔叔结点非红即为叶子结点(Nil)。因为如果叔叔结点为黑结点,而父结点为红结点,那么叔叔结点所在的子树的黑色结点就比父结点所在子树的多了,这不满足红黑树的性质5。后续情景同样如此,不再多做说明了。

前文说了,需要旋转操作时,肯定一边子树的结点多了或少了,需要租或借给另一边。插入显然是多的情况,那么把多的结点租给另一边子树就可以了。

插入情景4.2.1:插入结点是其父结点的左子结点
处理:

  • 将P设为黑色
  • 将PP设为红色
  • 对PP进行右旋

图11 插入情景4.2.1

由图11可得,左边两个红结点,右边不存在,那么一边一个刚刚好,并且因为为红色,肯定不会破坏树的平衡。

咦,可以把PP设为红色,I和P设为黑色吗?答案是可以!看过《算法:第4版》的同学可能知道,书中讲解的就是把PP设为红色,I和P设为黑色。但把PP设为红色,显然又会出现情景4.1的情况,需要自底向上处理,做多了无谓的操作,既然能自己消化就不要麻烦祖辈们啦~

插入情景4.2.2:插入结点是其父结点的右子结点
这种情景显然可以转换为情景4.2.1,如图12所示,不做过多说明了。

处理:

  • 对P进行左旋
  • 把P设置为插入结点,得到情景4.2.1
  • 进行情景4.2.1的处理

图12 插入情景4.2.2

插入情景4.3:叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的右子结点
该情景对应情景4.2,只是方向反转,不做过多说明了,直接看图。

插入情景4.3.1:插入结点是其父结点的右子结点
处理:

  • 将P设为黑色
  • 将PP设为红色
  • 对PP进行左旋

图13 插入情景4.3.1

插入情景4.3.2:插入结点是其父结点的右子结点
处理:

  • 对P进行右旋
  • 把P设置为插入结点,得到情景4.3.1
  • 进行情景4.3.1的处理

图14 插入情景4.3.2

好了,讲完插入的所有情景了。可能又同学会想:上面的情景举例的都是第一次插入而不包含自底向上处理的情况,那么上面所说的情景都适合自底向上的情况吗?答案是肯定的。理由很简单,但每棵子树都能自平衡,那么整棵树最终总是平衡的。好吧,在出个习题,请大家拿出笔和纸画下试试(请务必动手画下,加深印象):

习题1:请画出图15的插入自平衡处理过程。(答案见文末)

图15 习题1

4.3 删除

红黑树插入已经够复杂了,但删除更复杂,也是红黑树最复杂的操作了。但稳住,胜利的曙光就在前面了!

红黑树的删除操作也包括两部分工作:一查找目标结点;而删除后自平衡。查找目标结点显然可以复用查找操作,当不存在目标结点时,忽略本次操作;当存在目标结点时,删除后就得做自平衡处理了。删除了结点后我们还需要找结点来替代删除结点的位置,不然子树跟父辈结点断开了,除非删除结点刚好没子结点,那么就不需要替代。

二叉树删除结点找替代结点有3种情情景:

  • 情景1:若删除结点无子结点,直接删除
  • 情景2:若删除结点只有一个子结点,用子结点替换删除结点
  • 情景3:若删除结点有两个子结点,用后继结点(大于删除结点的最小结点)替换删除结点

补充说明下,情景3的后继结点是大于删除结点的最小结点,也是删除结点的右子树种最左结点。那么可以拿前继结点(删除结点的左子树最左结点)替代吗?可以的。但习惯上大多都是拿后继结点来替代,后文的讲解也是用后继结点来替代。另外告诉大家一种找前继和后继结点的直观的方法(不知为何没人提过,大家都知道?):把二叉树所有结点投射在X轴上,所有结点都是从左到右排好序的,所有目标结点的前后结点就是对应前继和后继结点。如图16所示。

图16 二叉树投射x轴后有序

接下来,讲一个重要的思路:删除结点被替代后,在不考虑结点的键值的情况下,对于树来说,可以认为删除的是替代结点!话很苍白,我们看图17。在不看键值对的情况下,图17的红黑树最终结果是删除了Q所在位置的结点!这种思路非常重要,大大简化了后文讲解红黑树删除的情景!

图17 删除结点换位思路

基于此,上面所说的3种二叉树的删除情景可以相互转换并且最终都是转换为情景1!

  • 情景2:删除结点用其唯一的子结点替换,子结点替换为删除结点后,可以认为删除的是子结点,若子结点又有两个子结点,那么相当于转换为情景3,一直自顶向下转换,总是能转换为情景1。(对于红黑树来说,根据性质5.1,只存在一个子结点的结点肯定在树末了)
  • 情景3:删除结点用后继结点(肯定不存在左结点),如果后继结点有右子结点,那么相当于转换为情景2,否则转为为情景1。

二叉树删除结点情景关系图如图18所示。

图18 二叉树删除情景转换

综上所述,删除操作删除的结点可以看作删除替代结点,而替代结点最后总是在树末。有了这结论,我们讨论的删除红黑树的情景就少了很多,因为我们只考虑删除树末结点的情景了。

同样的,我们也是先来总体看下删除操作的所有情景,如图19所示。

图19 红黑树删除情景

哈哈,是的,即使简化了还是有9种情景!但跟插入操作一样,存在左右对称的情景,只是方向变了,没有本质区别。同样的,我们还是来约定下,如图20所示。

图20 删除操作结点的叫法约定

图20的字母并不代表结点Key的大小。R表示替代结点,P表示替代结点的父结点,S表示替代结点的兄弟结点,SL表示兄弟结点的左子结点,SR表示兄弟结点的右子结点。灰色结点表示它可以是红色也可以是黑色。

值得特别提醒的是,R是即将被替换到删除结点的位置的替代结点,在删除前,它还在原来所在位置参与树的子平衡,平衡后再替换到删除结点的位置,才算删除完成。

万事具备,我们进入最后的也是最难的讲解。

删除情景1:替换结点是红色结点

我们把替换结点换到了删除结点的位置时,由于替换结点时红色,删除也了不会影响红黑树的平衡,只要把替换结点的颜色设为删除的结点的颜色即可重新平衡。

处理:颜色变为删除结点的颜色

删除情景2:替换结点是黑结点

当替换结点是黑色时,我们就不得不进行自平衡处理了。我们必须还得考虑替换结点是其父结点的左子结点还是右子结点,来做不同的旋转操作,使树重新平衡。

删除情景2.1:替换结点是其父结点的左子结点
删除情景2.1.1:替换结点的兄弟结点是红结点
若兄弟结点是红结点,那么根据性质4,兄弟结点的父结点和子结点肯定为黑色,不会有其他子情景,我们按图21处理,得到删除情景2.1.2.3(后续讲解,这里先记住,此时R仍然是替代结点,它的新的兄弟结点SL和兄弟结点的子结点都是黑色)。

处理:

  • 将S设为黑色
  • 将P设为红色
  • 对P进行左旋,得到情景2.1.2.3
  • 进行情景2.1.2.3的处理

图21 删除情景2.1.1

删除情景2.1.2:替换结点的兄弟结点是黑结点
当兄弟结点为黑时,其父结点和子结点的具体颜色也无法确定(如果也不考虑自底向上的情况,子结点非红即为叶子结点Nil,Nil结点为黑结点),此时又得考虑多种子情景。

删除情景2.1.2.1:替换结点的兄弟结点的右子结点是红结点,左子结点任意颜色
即将删除的左子树的一个黑色结点,显然左子树的黑色结点少1了,然而右子树又又红色结点,那么我们直接向右子树“借”个红结点来补充黑结点就好啦,此时肯定需要用旋转处理了。如图22所示。

处理:

  • 将S的颜色设为P的颜色
  • 将P设为黑色
  • 将SR设为黑色
  • 对P进行左旋

图22 删除情景2.1.2.1

平衡后的图怎么不满足红黑树的性质?前文提醒过,R是即将替换的,它还参与树的自平衡,平衡后再替换到删除结点的位置,所以R最终可以看作是删除的。另外图2.1.2.1是考虑到第一次替换和自底向上处理的情况,如果只考虑第一次替换的情况,根据红黑树性质,SL肯定是红色或为Nil,所以最终结果树是平衡的。如果是自底向上处理的情况,同样,每棵子树都保持平衡状态,最终整棵树肯定是平衡的。后续的情景同理,不做过多说明了。

删除情景2.1.2.2:替换结点的兄弟结点的右子结点为黑结点,左子结点为红结点
兄弟结点所在的子树有红结点,我们总是可以向兄弟子树借个红结点过来,显然该情景可以转换为情景2.1.2.1。图如23所示。

处理:

  • 将S设为红色
  • 将SL设为黑色
  • 对S进行右旋,得到情景2.1.2.1
  • 进行情景2.1.2.1的处理

图23 删除情景2.1.2.2

删除情景2.1.2.3:替换结点的兄弟结点的子结点都为黑结点
好了,此次兄弟子树都没红结点“借”了,兄弟帮忙不了,找父母呗,这种情景我们把兄弟结点设为红色,再把父结点当作替代结点,自底向上处理,去找父结点的兄弟结点去“借”。但为什么需要把兄弟结点设为红色呢?显然是为了在P所在的子树中保证平衡(R即将删除,少了一个黑色结点,子树也需要少一个),后续的平衡工作交给父辈们考虑了,还是那句,当每棵子树都保持平衡时,最终整棵总是平衡的。

处理:

  • 将S设为红色
  • 把P作为新的替换结点
  • 重新进行删除结点情景处理

图24 情景2.1.2.3

删除情景2.2:替换结点是其父结点的右子结点
好啦,右边的操作也是方向相反,不做过多说明了,相信理解了删除情景2.1后,肯定可以理解2.2。

删除情景2.2.1:替换结点的兄弟结点是红结点
处理:

  • 将S设为黑色
  • 将P设为红色
  • 对P进行右旋,得到情景2.2.2.3
  • 进行情景2.2.2.3的处理

图25 删除情景2.2.1

删除情景2.2.2:替换结点的兄弟结点是黑结点
删除情景2.2.2.1:替换结点的兄弟结点的左子结点是红结点,右子结点任意颜色
处理:

  • 将S的颜色设为P的颜色
  • 将P设为黑色
  • 将SL设为黑色
  • 对P进行右旋

图26 删除情景2.2.2.1

删除情景2.2.2.2:替换结点的兄弟结点的左子结点为黑结点,右子结点为红结点
处理:

  • 将S设为红色
  • 将SR设为黑色
  • 对S进行左旋,得到情景2.2.2.1
  • 进行情景2.2.2.1的处理

图27 删除情景2.2.2.2

删除情景2.2.2.3:替换结点的兄弟结点的子结点都为黑结点
处理:

  • 将S设为红色
  • 把P作为新的替换结点
  • 重新进行删除结点情景处理

图28 删除情景2.2.2.3

综上,红黑树删除后自平衡的处理可以总结为:

  1. 自己能搞定的自消化(情景1)
  2. 自己不能搞定的叫兄弟帮忙(除了情景1、情景2.1.2.3和情景2.2.2.3)
  3. 兄弟都帮忙不了的,通过父母,找远方亲戚(情景2.1.2.3和情景2.2.2.3)

哈哈,是不是跟现实中很像,当我们有困难时,首先先自己解决,自己无力了总兄弟姐妹帮忙,如果连兄弟姐妹都帮不上,再去找远方的亲戚了。这里记忆应该会好记点~

最后再做个习题加深理解(请不熟悉的同学务必动手画下):

***习题2:请画出图29的删除自平衡处理过程。

习题2

5.红黑树的部分实现

 

 红-黑树的节点实现
 

红-黑树是对二叉搜索树的改进,所以其节点与二叉搜索树是差不多的,只不过在它基础上增加了一个boolean型变量来表示节点的颜色,具体看RBNode类:

public class RBNode<T extends Comparable<T>>{
    boolean color; //颜色
    T key; //关键字(键值)
    RBNode<T> left; //左子节点
    RBNode<T> right; //右子节点
    RBNode<T> parent; //父节点
    
    public RBNode(T key, boolean color, RBNode<T> parent, RBNode<T> left, RBNode<T> right) {
        this.key = key;
        this.color = color;
        this.parent = parent;
        this.left = left;
        this.right = right;
    }
    
    public T getKey() {
        return key;
    }
    
    public String toString() {
        return "" + key + (this.color == RED? "R" : "B");
    }
}

 左旋的具体实现

上面对左旋的概念已经有了感性的认识了,这里就不再赘述了,我们从下面的代码中结合上面的示意图,探讨一下左旋的具体实现:

/*************对红黑树节点x进行左旋操作 ******************/
/*
 * 左旋示意图:对节点x进行左旋
 *     p                       p
 *    /                       /
 *   x                       y
 *  / \                     / \
 * lx  y      ----->       x  ry
 *    / \                 / \
 *   ly ry               lx ly
 * 左旋做了三件事:
 * 1. 将y的左子节点赋给x的右子节点,并将x赋给y左子节点的父节点(y左子节点非空时)
 * 2. 将x的父节点p(非空时)赋给y的父节点,同时更新p的子节点为y(左或右)
 * 3. 将y的左子节点设为x,将x的父节点设为y
 */
private void leftRotate(RBNode<T> x) {
    //1. 将y的左子节点赋给x的右子节点,并将x赋给y左子节点的父节点(y左子节点非空时)
    RBNode<T> y = x.right;
    x.right = y.left;
    
    if(y.left != null) 
        y.left.parent = x;
    
    //2. 将x的父节点p(非空时)赋给y的父节点,同时更新p的子节点为y(左或右)
    y.parent = x.parent;
    
    if(x.parent == null) {
        this.root = y; //如果x的父节点为空,则将y设为父节点
    } else {
        if(x == x.parent.left) //如果x是左子节点
            x.parent.left = y; //则也将y设为左子节点
        else
            x.parent.right = y;//否则将y设为右子节点
    }
    
    //3. 将y的左子节点设为x,将x的父节点设为y
    y.left = x;
    x.parent = y;        
}

右旋具体实现
 

上面对右旋的概念已经有了感性的认识了,这里也不再赘述了,我们从下面的代码中结合上面的示意图,探讨一下右旋的具体实现:

/*************对红黑树节点y进行右旋操作 ******************/
/*
 * 左旋示意图:对节点y进行右旋
 *        p                   p
 *       /                   /
 *      y                   x
 *     / \                 / \
 *    x  ry   ----->      lx  y
 *   / \                     / \
 * lx  rx                   rx ry
 * 右旋做了三件事:
 * 1. 将x的右子节点赋给y的左子节点,并将y赋给x右子节点的父节点(x右子节点非空时)
 * 2. 将y的父节点p(非空时)赋给x的父节点,同时更新p的子节点为x(左或右)
 * 3. 将x的右子节点设为y,将y的父节点设为x
 */
private void rightRotate(RBNode<T> y) {
    //1. 将y的左子节点赋给x的右子节点,并将x赋给y左子节点的父节点(y左子节点非空时)
    RBNode<T> x = y.left;
    y.left = x.right;
    
    if(x.right != null) 
        x.right.parent = y;
    
    //2. 将x的父节点p(非空时)赋给y的父节点,同时更新p的子节点为y(左或右)
    x.parent = y.parent;
    
    if(y.parent == null) {
        this.root = x; //如果x的父节点为空,则将y设为父节点
    } else {
        if(y == y.parent.right) //如果x是左子节点
            y.parent.right = x; //则也将y设为左子节点
        else
            y.parent.left = x;//否则将y设为右子节点
    }
    
    //3. 将y的左子节点设为x,将x的父节点设为y
    x.right = y;
    y.parent = x;        
}

6.应用--map/multimap

 

0.概述

和set类似 ,map的底层实现也是红黑树RB-tree,下面说一下map的几个特性

  • map的所有元素是pair,什么是pair?pair就是同时包含了实值(value)和键值(key)的一个值,pair的第一个元素视为键值 第二个元素是实值 ,它的源码定义如下


  • map所有的元素根据pair里面的键值自动排序, 两个元素之间不允许有相同的键值
  • 和set一样,我们不能通过map的迭代器更改元素里的键值key,因为改变了key会破坏map的组织规则,
    但是可以通过迭代器更改实值value 因为实值value并不影响map的组织顺序.
  • 和set一样,当删除和插入map中一个元素时,对其他元素的迭代器不会有影响,其他迭代器不会失效
  • map底层调用红黑树,即RB-Tree模板类.set要执行的所有操作,RB-tree差不多都提供了.所以几乎所有的map操作行为都是调用RB-tree的操作行为而已 
  • multimap的特性以及用法和map完全相同,唯一的差别在于它允许键值重复 因此它采用的底层机制是RB-tree的insert_equal()而非insert_unique()

 

2.用法
 

2,map的功能

自动建立key - value的对应。key 和 value可以是任意你需要的类型。

3,使用map

使用map得包含map类所在的头文件

#include <map>  //注意,STL头文件没有扩展名.h

map对象是模板类,需要关键字和存储对象两个模板参数:

std:map<int, string> personnel;

这样就定义了一个用int作为索引,并拥有相关联的指向string的指针.

为了使用方便,可以对模板类进行一下类型定义,

typedef map<int,CString> UDT_MAP_INT_CSTRING;

UDT_MAP_INT_CSTRING enumMap;

4,map的构造函数

map共提供了6个构造函数,这块涉及到内存分配器这些东西,略过不表,在下面我们将接触到一些map的构造方法,这里要说下的就是,我们通常用如下方法构造一个map:

map<int, string> mapStudent;

5,插入元素

// 定义一个map对象
map<int, string> mapStudent;
 
// 第一种 用insert函數插入pair
mapStudent.insert(pair<int, string>(000, "student_zero"));
 
// 第二种 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type(001, "student_one"));
 
// 第三种 用"array"方式插入
mapStudent[123] = "student_first";
mapStudent[456] = "student_second";
以上三种用法,虽然都可以实现数据的插入,但是它们是有区别的,当然了第一种和第二种在效果上是完成一样的,用insert函数插入数据,在数据的 插入上涉及到集合的唯一性这个概念,即当map中有这个关键字时,insert操作是不能在插入数据的,但是用数组方式就不同了,它可以覆盖以前该关键字对 应的值,用程序说明如下:

mapStudent.insert(map<int, string>::value_type (001, "student_one"));
 
mapStudent.insert(map<int, string>::value_type (001, "student_two"));
上面这两条语句执行后,map中001这个关键字对应的值是“student_one”,第二条语句并没有生效,那么这就涉及到我们怎么知道insert语句是否插入成功的问题了,可以用pair来获得是否插入成功,程序如下

// 构造定义,返回一个pair对象
pair<iterator,bool> insert (const value_type& val);
 
pair<map<int, string>::iterator, bool> Insert_Pair;
 
Insert_Pair = mapStudent.insert(map<int, string>::value_type (001, "student_one"));
 
if(!Insert_Pair.second)
    cout << ""Error insert new element" << endl;
我们通过pair的第二个变量来知道是否插入成功,它的第一个变量返回的是一个map的迭代器,如果插入成功的话Insert_Pair.second应该是true的,否则为false。

6, 查找元素

当所查找的关键key出现时,它返回数据所在对象的位置,如果沒有,返回iter与end函数的值相同。

// find 返回迭代器指向当前查找元素的位置否则返回map::end()位置
iter = mapStudent.find("123");
 
if(iter != mapStudent.end())
       cout<<"Find, the value is"<<iter->second<<endl;
else
   cout<<"Do not Find"<<endl;
7, 刪除与清空元素

//迭代器刪除
iter = mapStudent.find("123");
mapStudent.erase(iter);
 
//用关键字刪除
int n = mapStudent.erase("123"); //如果刪除了會返回1,否則返回0
 
//用迭代器范围刪除 : 把整个map清空
mapStudent.erase(mapStudent.begin(), mapStudent.end());
//等同于mapStudent.clear()
8,map的大小

在往map里面插入了数据,我们怎么知道当前已经插入了多少数据呢,可以用size函数,用法如下:

int nSize = mapStudent.size();
 

 9,map的基本操作函数:

     C++ maps是一种关联式容器,包含“关键字/值”对

     begin()         返回指向map头部的迭代器

     clear()        删除所有元素

     count()         返回指定元素出现的次数

     empty()         如果map为空则返回true

     end()           返回指向map末尾的迭代器

     equal_range()   返回特殊条目的迭代器对

     erase()         删除一个元素

     find()          查找一个元素

     get_allocator() 返回map的配置器

     insert()        插入元素

     key_comp()      返回比较元素key的函数

     lower_bound()   返回键值>=给定元素的第一个位置

     max_size()      返回可以容纳的最大元素个数

     rbegin()        返回一个指向map尾部的逆向迭代器

     rend()          返回一个指向map头部的逆向迭代器

     size()          返回map中元素的个数

     swap()           交换两个map

     upper_bound()    返回键值>给定元素的第一个位置

     value_comp()     返回比较元素value的函数

 

7.应用--set/multiset

 

 

1.概述

set是什么?set即集合 它的主要特性有以下几个

  • 由于set的底层是红黑树实现的,而红黑树又是一种二叉排序树,所以set里面所有的元素键值会被自动排序,而且不允许两个元素有相同的键值,因为那样的话在树里面就不知道怎么排序了
  •  不像map那样有key(键值)和value(实值),set的key和value是一体的,
  • 通过set的迭代器不能改变set里面的元素键值.因为改变了键值,红黑树的结点就得重新排列了,就会破坏set的组织.所以set的迭代器被定义为红黑树底层的const_iterator,杜绝写入操作
  • 当对某个元素插入或删除时,其余迭代器依然是保持有效的.
  • set底层调用红黑树,即RB-Tree模板类.set要执行的所有操作,RB-tree差不多都提供了.所以几乎所有的set操作行为都是调用RB-tree的操作行为而已 
  • multiset的特性以及用法和set完全相同,唯一的差别在于它允许键值重复 因此它采用的底层机制是RB-tree的insert_equal()而非insert_unique()

 

2.用法

 

set使用方法:

begin()        ,返回set容器的第一个迭代器

end()      ,返回set容器的最后一个迭代器

clear()          ,删除set容器中的所有的元素

empty()    ,判断set容器是否为空

max_size()   ,返回set容器可能包含的元素最大个数

size()      ,返回当前set容器中的元素个数

rbegin     ,返回的值和end()相同

rend()     ,返回的值和rbegin()相同

简单操作实例:


#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    set<int> s;  
    s.insert(1);  
    s.insert(2);  
    s.insert(3);  
    s.insert(1);  
    cout<<"set 的 size 值为 :"<<s.size()<<endl;  
    cout<<"set 的 maxsize的值为 :"<<s.max_size()<<endl;  
    cout<<"set 中的第一个元素是 :"<<*s.begin()<<endl;  
    cout<<"set 中的最后一个元素是:"<<*s.end()<<endl;  
    s.clear();  
    if(s.empty())  
    {  
        cout<<"set 为空 !!!"<<endl;  
    }  
    cout<<"set 的 size 值为 :"<<s.size()<<endl;  
    cout<<"set 的 maxsize的值为 :"<<s.max_size()<<endl;  
    return 0;  
}  

运行结果:




小结:插入3之后虽然插入了一个1,但是我们发现set中最后一个值仍然是3哈,这就是set 。还要注意begin() 和 end()函数是不检查set是否为空的,使用前最好使用empty()检验一下set是否为空.

 

count() 用来查找set中某个某个键值出现的次数。这个函数在set并不是很实用,因为一个键值在set只可能出现0或1次,这样就变成了判断某一键值是否在set出现过了。

示例代码:

#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    set<int> s;  
    s.insert(1);  
    s.insert(2);  
    s.insert(3);  
    s.insert(1);  
    cout<<"set 中 1 出现的次数是 :"<<s.count(1)<<endl;  
    cout<<"set 中 4 出现的次数是 :"<<s.count(4)<<endl;  
    return 0;  
}  

equal_range() ,返回一对定位器,分别表示第一个大于或等于给定关键值的元素和 第一个大于给定关键值的元素,这个返回值是一个pair类型,如果这一对定位器中哪个返回失败,就会等于end()的值。具体这个有什么用途我还没遇到过~~~



#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    set<int> s;  
    set<int>::iterator iter;  
    for(int i = 1 ; i <= 5; ++i)  
    {  
        s.insert(i);  
    }  
    for(iter = s.begin() ; iter != s.end() ; ++iter)  
    {  
        cout<<*iter<<" ";  
    }  
    cout<<endl;  
    pair<set<int>::const_iterator,set<int>::const_iterator> pr;  
    pr = s.equal_range(3);  
    cout<<"第一个大于等于 3 的数是 :"<<*pr.first<<endl;  
    cout<<"第一个大于 3的数是 : "<<*pr.second<<endl;  
    return 0;  
}  

erase(iterator)  ,删除定位器iterator指向的值

erase(first,second),删除定位器first和second之间的值

erase(key_value),删除键值key_value的值

看看程序吧:



#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    set<int> s;  
    set<int>::const_iterator iter;  
    set<int>::iterator first;  
    set<int>::iterator second;  
    for(int i = 1 ; i <= 10 ; ++i)  
    {  
        s.insert(i);  
    }  
    //第一种删除  
    s.erase(s.begin());  
    //第二种删除  
    first = s.begin();  
    second = s.begin();  
    second++;  
    second++;  
    s.erase(first,second);  
    //第三种删除  
    s.erase(8);  
    cout<<"删除后 set 中元素是 :";  
    for(iter = s.begin() ; iter != s.end() ; ++iter)  
    {  
        cout<<*iter<<" ";  
    }  
    cout<<endl;  
    return 0;  
}  

运行结果:



小结:set中的删除操作是不进行任何的错误检查的,比如定位器的是否合法等等,所以用的时候自己一定要注意。

find()  ,返回给定值值得定位器,如果没找到则返回end()。

示例代码:



#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    int a[] = {1,2,3};  
    set<int> s(a,a+3);  
    set<int>::iterator iter;  
    if((iter = s.find(2)) != s.end())  
    {  
        cout<<*iter<<endl;  
    }  
    return 0;  
}  

insert(key_value); 将key_value插入到set中 ,返回值是pair<set<int>::iterator,bool>,bool标志着插入是否成功,而iterator代表插入的位置,若key_value已经在set中,则iterator表示的key_value在set中的位置。

inset(first,second);将定位器first到second之间的元素插入到set中,返回值是void.

示例代码:



#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    int a[] = {1,2,3};  
    set<int> s;  
    set<int>::iterator iter;  
    s.insert(a,a+3);  
    for(iter = s.begin() ; iter != s.end() ; ++iter)  
    {  
        cout<<*iter<<" ";  
    }  
    cout<<endl;  
    pair<set<int>::iterator,bool> pr;  
    pr = s.insert(5);  
    if(pr.second)  
    {  
        cout<<*pr.first<<endl;  
    }  
    return 0;  
}  

lower_bound(key_value) ,返回第一个大于等于key_value的定位器

upper_bound(key_value),返回最后一个大于等于key_value的定位器

示例代码:




#include <iostream>  
#include <set>  
  
using namespace std;  
  
int main()  
{  
    set<int> s;  
    s.insert(1);  
    s.insert(3);  
    s.insert(4);  
    cout<<*s.lower_bound(2)<<endl;  
    cout<<*s.lower_bound(3)<<endl;  
    cout<<*s.upper_bound(3)<<endl;  
    return 0;  
}  

8.问题&总结

 

 红黑树和AVL树的比较?
AVL树

AVL树是带有平衡条件的二叉查找树,一般是用平衡因子差值判断是否平衡并通过旋转来实现平衡,左右子树树高不超过1,和红黑树相比,AVL树是严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过1)。不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而它的英文旋转非常耗时的,由此我们可以知道AVL树适合用于插入与删除次数比较少,但查找多的情况
局限性:由于维护这种高度平衡所付出的代价比从中获得的效率收益还大,故而实际的应用不多,更多的地方是用追求局部而不是非常严格整体平衡的红黑树。当然,如果应用场景中对插入删除不频繁,只是对查找要求较高,那么AVL还是较优于红黑树。
应用:在Windows NT内核中广泛存在;

红黑树

简介:一种二叉查找树,但在每个节点增加一个存储位表示节点的颜色,可以是红或黑(非红即黑)。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,红黑树是一种弱平衡二叉树(由于是弱平衡,可以看到,在相同的节点情况下,AVL树的高度低于红黑树),相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,我们就用红黑树。
应用:广泛用于C ++的STL中,地图和集都是用红黑树实现的;著名的Linux的的进程调度完全公平调度程序,用红黑树管理进程控制块,进程的虚拟内存区域都存储在一颗红黑树上,每个虚拟地址区域都对应红黑树的一个节点,左指针指向相邻的地址虚拟存储区域,右指针指向相邻的高地址虚拟地址空间;io多路复用的epoll的的的实现采用红黑树组织管理的的的sockfd,以支持快速的增删改查;Nginx中用红黑树管理定时器,因为红黑树是有序的,可以很快的得到距离当前最小的定时器;Java的中TreeMap中的中的实现;

红黑树和B+树的比较?

  1. 红黑树多用在内部排序,即全放在内存中的,STL的map和set的内部实现就是红黑树。

  2. B+树多用于外存上时,B+也被成为一个磁盘友好的数据结构。

     为什么b+磁盘友好

  1. 磁盘读写代价更低
    树的非叶子结点里面没有数据,这样索引比较小,可以放在一个blcok(或者尽可能少的blcok)里面。避免了树形结构不断的向下查找,然后磁盘不停的寻道,读数据。这样的设计,可以降低io的次数。

  2. 查询效率更加稳定
    非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

  3. 遍历所有的数据更方便
    B+树只要遍历叶子节点就可以实现整棵树的遍历,而其他的树形结构 要中序遍历才可以访问所有的数据。

红黑树和哈希表的比较?

红黑树是有序的,Hash是无序的,根据需求来选择。
红黑树占用的内存更小(仅需要为其存在的节点分配内存),
而Hash事先就应该分配足够的内存存储散列表(即使有些槽可能遭弃用)。
红黑树查找和删除的时间复杂度都是O(logn),Hash查找和删除的时间复杂度都是O(1)

vector和map下标访问运算符的区别?
通过下标访问vector中的元素时不会做边界检查,即便下标越界。也就是说,下标与first迭代器相加的结果超过了finish迭代器的位置,程序也不会报错,而是返回这个地址中存储的值。如果想在访问vector中的元素时首先进行边界检查,可以使用vector中的at函数。通过使用at函数不但可以通过下标访问vector中的元素,而且在at函数内部会对下标进行边界检查。

map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有该key和value的某人值插入这个map。

map插入方式有几种?

用insert函数插入pair数据,

mapStudent.insert(pair<int, string>(1, "student_one"));  

用insert函数插入value_type数据

mapStudent.insert(map<int, string>::value_type (1, "student_one"));

在insert函数中使用make_pair()函数

mapStudent.insert(make_pair(1, "student_one"));  

用数组方式插入数据

mapStudent[1] = "student_one";  
 

map[]与find的区别?

map的下标运算符[]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值的项插入这个map。

map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。