【《STL源码剖析》提炼总结】 第4节:容器_2 关联式容器:红黑树

一. 关联式容器简介

​ 所谓关联式容器,观念上类似关联式数据库:每笔数据(每个元素)都有一个键值(key)和一个实值(value)。当元素被插到关联式容器中时,容器内部结构便按照其键值大小,以某种特定规则将这个元素放置于适当位置。

​ 关联式容器没有头尾,所以不会有所谓push_back(),pop_back(),push_front(),pop_front()

​ 首先,关联式容器结构为一个value,value内部为一个键值对构成,也就是 key:data结构,不过对于set(集合)类型的数据结构来说,它的key就是value本身,对于map(映射表)来说,key只是value中的一小部分。因为这些差异,所以对于关联式容器来说,要有一个从value中取出key的规则,这个将在后续介绍。

PS:从“键值对”这个说法来说,本应是key:value结构,python中也采用了这个写法,而STL中value表示数字的全部,键值为key:data,源码中也全部是这个写法,因此采用与源码相同的说法。

二.使用红黑树作为底层的容器

(1)红黑树 RB-tree

1. 什么是红黑树

二叉搜索树

​ 对于一棵二叉树,我们可以赋予其特殊的规则:对于任意一棵子树,其根节点的值比其左子树的所有制都小,比右子树的所有值都大(因此没有重复值)。经过这样的处理后,数据变得有序了。经过中序遍历,我们便可以得到其排序后的结果。

​ 但是二叉搜索树有其局限性,其非常依赖于插入数据的顺序,因为若数据为升序或者降序(即非随机性质),那根节点会偏小/大,导致“失衡”,即左右子树的高度差距过大,最极端的情况为一条直线,导致查找的效率退化为O(n)。

平衡二叉搜索树 AVL tree

​ 为了解决二叉搜索树面对极端情况下的表现,出现了严格的平衡二叉搜索树,其规定左右子树的高度差距不能大于1,因此若插入一个值后,会导致一系列的重新调整(该操作被称为旋转,将在后面介绍)。

​ 平衡二叉树的规则使得其深度为log(n),搜索效率很高,但是频繁的调整也导致其插入时有许多额外操作,因此插入效率不一定高。

红黑树

​ 红黑树就是节点有红色和黑色两种颜色的二叉搜索树,也被称为“高度平衡的二叉搜索树”。它独特的结构保证了任意节点到尾端的任何路径,黑节点数相同,而红节点的数目小于等于黑节点的数目,因此保证了最深的子树的深度不会大于最小深度的2倍。

​ 因为不是严格的平衡,红黑树在插入时进行的调整比较小,同时也保证了左右子树基本的平衡,所以插入效率和查找效率都很高,被广泛应用于各种数据结构。

红黑树的规则

  • 每个节点不是红色就是黑色
  • 根节点为黑色
  • 若节点为红,其子节点必须为黑——不会有两个相连的红色节点
  • 任一节点至叶子节点的任何路径,所含黑节点数必须相同

2. 红黑树的调整——旋转

​ 在AVL tree和红黑树中,面对需要调整的情况,这种时候要使用旋转操作:因为只要保证根节点的左子树比其小,右子树比其大即可,因此可以将其左孩子或者右孩子往上移动,转为父节点,而父节点会响应地变为右孩子/左孩子(左孩子变为父节点,那原父节点比左孩子大,因此是右孩子,另一边同理)

​ 在父节点变为孩子以及孩子变为父节点的过程中,父节点为“认领”孩子多出的那部分子树。比如右旋过程中,左孩子变为父节点,原左孩子的现左孩子为原父节点,那左孩子的原左子树就多出来了,交给原父节点作为其左子树。

3. STL中红黑树的基本结构

节点的结构

​ 红黑树中常常需要向上操作,因此要保存一个指针指向其父节点,因此有三个指针,指向其左孩子、右孩子、父节点。

​ 但是根节点是一个特例——其没有父节点,为了处理这个问题,STL中的红黑树进行了巧妙的设计。

​ 节点中还需要保存其value和颜色两个值。

特殊的header

​ 在红黑树中,设有一个物理意义上的根节点header,它的parent指向逻辑意义上的根节点root,header的left指针指向最左下侧的节点(也就是最小的节点),right指针指向最右下侧的节点(也就是最大的节点)。同时root的parent指针也指向header,即root和header互为父节点。

​ 这样设计的好处是,就像链表的头指针一样,不需要对整个树没有节点时那样进行特别处理(因为至少会有一个header),同时header也维护了最小和最大的元素。

​ 同时在迭代器begin和end的设计中,可以很容易想到,header->right(最小节点)即begin,而header本身作为一个不应该被访问到的节点,即作为end。

4. 红黑树的迭代器

​ 红黑树的迭代器即指向树中的某个节点,这里详细介绍一下其移动操作。

  • ++

    假如当前节点有右孩子,那往右移动即可;但是假如没有,那便需要往上查找,查找的方法为:

    寻找包含这个节点的最小左子树的根节点。原理为:++即找到当前节点大的最小值,因此需要找到包括其的左子树,而左子树的根节点比左子树的节点都大,这便是下一个节点。

  • --

    同理,若当前节点有左孩子,那直接移动即可,若不存在,则需要找到包含这个节点的最小右子树的根节点,原理与++相同。

5. 红黑树的数据结构

​ 刚刚已经介绍了header,header可以以O(1)的复杂度直接访问根节点、begin,本身也是end,因此header已经可以代表一棵二叉树。

成员

  • header
  • 一个size_t变量,存储树的大小
  • 比较器(用于比较节点的key的大小)(本身是一个实例化的仿函数)

构造红黑树需要提供的接口

  • 比较器:比较key的大小
  • KeyofValue:用于从value中取出key,这在set中有不同的使用
  • Alloc:这个一般使用默认的alloc

6. 红黑树的元素操作

  • 插入

    • insert_unique 不允许插入相同的值:在检测到相同值时说明无法插入

    其返回一个pair,为插入/查找位置,后一个值为bool,即插入结果

    假如每次对当前节点判断是往左/右,同时还要判断其是否与插入值相等,开销太大了,因此算法会进行上溯——假如它在左子树里,那这个左子树的根节点是否与其相等(这也就是迭代器的--操作),相等说明重复了。

    • insert_equal

    操作与insert_unique相同,但是允许重复的值

  • 调整

    进行插入后可能会导致红黑树失衡,因此要进行旋转和颜色改变,来使其符合要求。

  • 查找

    每次查找都要比较大于/小于/等于,是一笔不小的开销,因此查找也采用了巧妙的方法:其维护了一个值,指向遍历到的最后一个大于等于查找的值的位置,即!comp(key(x),k)为true,因此若这个值与查找的值相等,说明找到了。

    同时查找返回值是一个迭代器,找到则在对应位置,没找到则为end

值得注意的是,RB-tree中的comp(比较器)表示的是<操作,即comp(a,b) => a<b ,同样,在算法组件中,比较器也表示<,而没有任何一个接口表示>,我认为这是为了统一接口,毕竟a>b就是b<aa>=b就是 !a<b,都可以用< 来完全表示。

(2)set

set的特点

  • set即集合,内部通过封装红黑树实现,因为是红黑树实现的,所以数据结构是有序的。

  • 同时set不允许重复,因此使用的是红黑树中的unique_insert。

  • set的值不允许迭代器修改,因为那样会破坏红黑树有序的结构,因此虽然提供迭代器作为遍历的接口,set的迭代器都是const类型,提供的引用也是const类型。

set的API

  • insert
    • 插入值
    • 插入值,同时提供参考位置,这个位置只作为参考,最后还是会进行调整
    • 插入一个范围的值(提供两个迭代器)
  • 删除 erase
    • 删除一个位置
    • 删除一个值
    • 删除一个范围
  • find 非常常用,用于查找值的位置
  • count 因为只有1/0个对应的值,所以常用来判断值时候存在
  • lower_bound/upper_bound

(3)map

map的特点

  • 和set一样,map底层也是红黑树结构,不过map表示的是键值对,因此是 key:data结构。
  • 因为键和value不同(里面还有data),map通常使用pair来实现键值的对应,first为key,second为data。map内部调用了select1st 作为 KeyofValue的接口
  • 因为map的值允许修改,因此map提供的迭代器是pair<const Key,T>类型的,迭代器不是const类型,但是其中的key仍然为const

map的API

  • insert
    • 插入值
    • 插入值,以及参考位置
    • 插入范围
  • 删除 erase
    • 删除值
    • 删除位置
    • 删除范围
  • find 常用,用来判断值是否存在(和end比较)
  • operator[] : 使用类似访问数组下标的方式,[]内为对应的key
    • []不是单纯的查找,其调用红黑树的insert,因此实际上是有“副作用”的
    • 因为insert在插入失败后虽然返回false,但是也顺路返回元素所在位置(插入成功自然也返回新插入的位置)。因此[] 内部调用insert插入key:T的默认构造临时对象 T(),假如key已存在那插入失败,假如key不存在则插入T()
    • (*((insert(value_type(k,T()))).first).second) 这是其中使用isnert的语句
    • 这样的好处是平常对其赋值不需要确认值是否存在,比如value是一个vector,那不管其是否存在,可以直接使用语句map[key].push_back(xx)对其操作
    • 不过取值的时候需要判断值是否存在,毕竟任何情况都有返回值

(4) multiset

  • 看名字可以得出,和set本质的结构和API都一模一样,区别在于multiset可以存储相同的值
  • 这是通过调用红黑树的equal_insert实现的

(5)multimap

  • multimap同理,与map相同,但是key允许重复
  • 因为key可能不止一个对应的value,所以multimap不允许使用[]运算符(没有进行重载)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值