在实现 TinySTL 过程中对思路的一些梳理,并不涉及代码(大概),如有兴趣可以跳至相关实现。关联式容器每个元素都有一个键值(key)和一个实值(value),当插入元素时,容器内部结构(可能是 RB-tree,也可能是 hashtable)便依照其键值大小,以某种特定规则将这个元素放置于适当位置😉
2024-08-26 16:37:52
目录:
1.1 概述
1.2 迭代器
1.3 数据结构
2.1 概述
2.2 开链
2.3 迭代器
标准的 STL 关联式容器分为 set(集合)和 map(映射表),以及这两大类的衍生体,其实都是 adapter(配接器),其底层机制可能是 RB-tree(红黑树),也可能是 hashtable(散列表)。RB-tree 和 hashtable 也是独立容器,只是并不对外开放。说白了,理解关联式容器就在于对于 RB-tree 和 hashtable 这两种容器的探究
adapter 修改某个容器的接口而成就出另一种容器风格,如果后续有时间的话会深入阐述这个东西,现在你大致了解这一点就行了
1 RB-tree
1.1 概述
红黑树其实本质就是一个平衡二叉搜索树,只是添加了一些需要满足的规则:
- 每个节点都有颜色,非红即黑
- 根节点是黑色
- 父子节点不可以同时为红
- 任一节点至 NULL(一般视 NULL 为黑节点)的任意路径上所包含的黑节点数必须相同
红黑树的主要操作(如插入和删除)在维护二叉搜索树的基础性质(左子树的节点值小于根节点,右子树的节点值大于根节点)的同时,还会通过旋转和重新着色来调整树形以保持红黑树的平衡
为了理解红黑树的性质,接下里会以分解成图的形式来展示红黑树的插入操作,以下示例图讨论了各种情况下的插入节点:
这里为了方便讨论,为一些特殊节点命名别名,假设新节点为 X,其父节点为 P,祖父节点为 G,伯父节点为 S,曾祖父节点为 GG。根据二叉搜索树规则,新节点 X 必为叶节点。根据红黑树规则4,X 必为红。若 P 也为红(这违反了规则3,因此接下来必须调整树形),则 G 必为黑。于是根据 X 的插入位置及外围节点(S 和 GG)的颜色,有了以下四种考虑:
状况1:S 为黑且 X 为外侧插入
状况2:S 为黑且 X 为内侧插入
如果你仔细观察的话,你就会发现这里的旋转过程很不符合逻辑,首先就是在左旋(第一个单旋转)的过程中节点的颜色改变,这个颜色改变是不符合规则4的,其次是经过左旋的颜色改变后实则就不再需要调整树形了。出于逻辑的考虑,这里应该是在双旋转过程中,左旋不改变颜色,这样才会因为不满足规则3,继而右旋并改变颜色。实际上这是因为代码实现的问题,在元素操作的调整树形函数中会有所体现
状况3:S 为红且 X 为外侧插入,如果 GG 为黑
状况4:S 为红且 X 为外侧插入,如果 GG 此时为红,情况就比较复杂了
为了避免状况4 “父子节点均为红色” 需要持续向上发展的处理,这里采用了一个由上而下的程序:假设新增节点为 A,那么沿着 A 的路径由上而下修改节点颜色,如果某个节点 X 的两个子节点均为红色,就把 X 改为红色,并把两个子节点改为黑色
1.2 迭代器
RB-tree 迭代器的实现被分为两层,以下示例图展示了双层节点结构和双层迭代器结构之间的关系:
这样的设计在保持接口简洁的同时,隐藏了红黑树内部的复杂性,从而为用户提供一个功能强大又便于使用的迭代器。RB-tree 的迭代器属于双向迭代器,但不具备随机定位能力。其中特殊的是operator++
和operator--()
,它们分别调用的是基类迭代器的increment()
和decrement()
。这两个函数的实现有一些特殊技巧,稍后会说明
1.3 数据结构
RB-tree 只以三笔数据表现表现整棵树
1 | size_type node_count; // 追踪记录树的大小,也就是节点数量 |
RB-tree 以节点大小为配置单位
1.4 元素操作与空间配置
树状结构的各种操作,最需要注意的就是边界情况的发生,也就是到根节点时要有特殊处理。这里 SGI STL 采用了哨兵节点的方法简化处理,为根节点在设计一个父节点,命名为 header,并令其初始状态为示例图所示:
header 和 root 互为对方的父节点,这是一种实现技巧
每次插入节点时,不仅要调整树形来符合红黑树的规则,还要维护 header 的正确性,使其父节点指向根节点,左子节点指向最小节点,右子节点指向最大节点。树形调整稍后会说明
RB-tree 提供两种插入操作:insert_unique()
和insert_equal()
,前者要求被插入节点的键值在整棵树中必须独一无二,后者表示被插入节点的键值在整棵树中可以重复,这也是容器 set 和 map 的立身之本
在前文插入节点的状况2中我们产生过疑惑,这里粘贴一下调整树形的函数代码就能理解这其中的缘由了:
1 | inline void rb_tree_rebalance(rb_tree_node_base *x, rb_tree_node_base *&root) { |
注意,涉及向上检测的处理情况就是之前提到的由上而下的程序
2 hashtable
2.1 概述
二叉搜索树具有对数平均时间(O(log n)),但这样的表现需要建立在输入的数据要有足够的随机性。这里就要引出一种名为hashtable
的数据结构,它具有常数平均时间(O(n)),而且这种表现是以统计为基础的,并不依赖输入数据的随机性
如果输入的数据为有序,那么在插入时,每次新元素都会倾向树的某一侧,从而导致树失去平衡。RB-tree 具有自平衡机制,所以对于输入的数据并没有过多的要求
hashtable
被视为一种字典结构,因为它的操作对象是有名项。关于hashtable
的实现,可以从一个简单粗暴的想法开始:
如果插入的元素都是 16-bits 且不带正负号的整数,我们可以申请一个拥有 65535 的 array,初值全为 0,每个元素的值表示该元素出现的次数
这种解法存在两个问题,一是如果元素是 32-bits,我们不可能去准备一个大小为 232 = 4 GB 的 array,二是如果元素为字符串或者其他而非整数。第二个问题比较好解决,那就是使用 ASCII 编码,这样字符就能转换成一个 7-bits 的数值来表示。但是这样又会回到第一个问题:array 的大小,因为字符串稍长就会导致很大的索引值。怎么解决呢?办法之一就是采用某种映射函数,将大数映射为小数,即所谓的哈希函数(hash function)
但是使用 hash function 一样会带来问题:可能会有不同的元素被映射到相同的位置,即有相同的索引。这便是碰撞(collision)问题。解决碰撞问题的方法有很多种,如线性探测(linear probing)、二次探测(quadratic probing)、开链(separate chaining)等
碰撞 是无法避免的,毕竟元素个数是大于 array 的容量的。
这里这会简单提及一下线性探测和二次探测,不会做过多的讲述:
线性探测:当 hash function 计算出某个元素的插入位置,而该位置上的空间已被占用,那就循环往下一一寻找。线性探测会存在主集团(primary clustering)问题,感兴趣的你可以去网上了解😉
如果到达尾端,就绕到头部继续寻找
二次探测:当 hash function 计算出某个元素的插入位置 H,而该位置上的空间已被占用,那么就依次尝试 H + 12,H + 22,H + 32,… ,H + i2。二次探测主要用来解决主集团问题,但却可能造成次集团(secondary clustering)问题,解决的办法也有,例如复式散列(double hashing)
2.2 开链
SGI STL 中的hashtable
就是采用开链实现的,所谓开链,就是在每一个表格元素中维护一个 list
注意,bucket 维护的 linked list,并不采用 STL 的 list 或 slist,而是自行维护的 hashtable node。至于 buckets 聚合体则以 vector 完成的,以便有动态扩充能力
1 | template <typename Value> |
2.3 迭代器
hashtable
的迭代器必须永远维系着与整个 “buckets vector” 的关系,并且记录目前所指的节点。需要注意的是,其前进操作的边界问题,如果目前节点正巧是 list 的尾端,就跳至下一个 bucket 身上,那正是指向下一个 list 的头部节点。hashtable
的迭代器没有后退操作,hashtable
也没有定义所谓的逆向迭代器(reverse iterator)
1 | node* cur; // 迭代器目前所指的节点 |
2.4 数据结构与空间配置
虽然开链法并不要求表格大小必须为质数,但 SGI STL 仍然以质数来设计表格大小,并且将 28 个质数(逐渐呈现大约两倍的关系)计算好,以备随时访问,同时提供一个函数,用来查询在这 28 个质数之中,”最接近某数并大于某数” 的质数,这里只用文字描述叙述比较苍白,以下粘贴了相关代码:
1 | static const int tinystl_num_primes = 28; |
hashtable
以节点大小为配置单位
表格是否需要重建的判断原则是拿元素个数(把新增元素计入后)和 bucket vector 的大小来比。如果前者大于后者,就重建表格。由此可知,单个 bucket list 的理论最大容量就是 buckets vector 的大小。重建表格的操作分解成图说明,如下示例图所示:
buckets[ ] 是原有的 buckets,tmp[ ] 是新建的 buckets,最后会将这两个 buckets 对调,如果对调双方大小不同,大的会变小,小的会变大,然后会释放 tmp[ ]