STL关联式容器底层实现——红黑树和哈希表

    STL关联式容器中:

    set和map的底层数据结构为红黑树,因为map和set要求是自动排序的,红黑树能够实现这一功能,并且各个操作的时间复杂度都较低,而unordered_set和unordered_map的底层数据结构为哈希表,查找时间复杂度为常数级。

    只要是前缀带了unordered的就是无序,后缀带了multi的就是允许键值重复,插入采用 insert_equal 而不是 insert_unique。如下表所示:

 

容器名 底层实现 键值重复 插入元素 迭代器性质
set 红黑树 不允许 insert_unique const_iterator
multiset 红黑树 允许 insert_equal const_iterator
unordered_set 哈希表 不允许 insert_unique const_iterator
unordered_multiset 哈希表 允许 insert_equal const_iterator
map 红黑树 不允许 insert_unique 非const_iterator
multimap 红黑树 允许 insert_equal 非const_iterator
unordered_map 哈希表 不允许 insert_unique 非const_iterator
unordered_multimap 哈希表 允许 insert_equal 非const_iterator

1.红黑树

(1)定义

    红黑树(R-B TREE,全称:Red-Black Tree),本身是一棵特殊的二叉查找树,在其基础上附加了两个要求:

    1)树中的每个结点增加了一个用于存储颜色的标志域;

    2)树中没有一条路径比其他任何路径长出两倍,整棵树要接近于“平衡”的状态。

    这里所指的路径,指的是从任何一个结点开始,一直到其子孙的叶子结点的长度;接近于平衡:红黑树并不是平衡二叉树,只是由于对各路径的长度之差有限制,所以近似于平衡的状态。

    红黑树示例如图所示:

    图中每个结点附带一个整形数值,表示的是此结点的黑高度(从该结点到其子孙结点中包含的黑结点数,用 bh(x) 表示(x 表示此结点)),nil 的黑高度为 0,颜色为黑色(在编程时为节省空间,所有的 nil 共用一个存储空间)。在计算黑高度时,也看做是一个黑结点。 红黑树中每个结点都有各自的黑高度,整棵树也有自己的黑高度,即为根结点的黑高度,例如上图中的红黑树的黑高度为 3。

(2)性质

红黑树对于结点的颜色设置不是任意的,需满足以下性质的二叉查找树才是红黑树:

1)树中的每个结点颜色不是红的,就是黑的;

2)根结点的颜色是黑的;

3)所有为 nil 的叶子结点的颜色是黑的;(注意:叶子结点说的只是为空(nil 或 NULL)的叶子结点!)

4)如果此结点是红的,那么它的两个孩子结点全部都是黑的;

5)对于每个结点,从该结点到该结点的所有子孙结点的所有路径上包含有相同数目的黑结点

6)对于一棵具有 n 个结点的红黑树,树的高度至多为:2log(n+1)。

    由此可推出红黑树进行查找操作时的时间复杂度为O(logn),因为对于高度为 h 的二叉查找树的运行时间为O(h),而包含有 n 个结点的红黑树本身就是最高为 logn(简化之后)的查找树(h=logn),所以红黑树的时间复杂度为O(logn)。

(3)红黑树结点定义

enum RBTColor{RED, BLACK};
​
template<class T>
class RBTNode
{
pulic:
    RBTColor color;
    T key;
    RBTNode *left;
    RBTNode *right;
    RBTNode *parent;
    
    RBTNode(T val, RBTColor, RBTNode *p, RBTNode *l, RBTNode *r):
        key(val), color(col), parent(p), left(l), right(r){}
}

(4)红黑树的基本操作

1)旋转

    红黑树本身作为一棵二叉查找树,所以其任务就是用于动态表中数据的插入和删除的操作。在进行该操作时,避免不了会破坏红黑树的结构,此时就需要进行适当的调整,使其重新成为一棵红黑树,可以从两个方面着手对树进行调整:

  • 调整树中某些结点的指针结构

  • 调整树中某些结点的颜色

    当使用红黑树进行插入或者删除结点的操作时,可能会破坏红黑树的性质,从而变成了一棵普通树,此时就可以通过对树中的某些子树进行旋转,从而使整棵树重新变为一棵红黑树。

2)插入

    STL中,当创建一个红黑树或者向已有红黑树中插入新的数据时,有两种方式,如下:

//不允许键值重复插入
pair<iterator, bool> insert_unique(const value_type& x);
​
//允许键值重复插入
iterator insert_equal(const value_type& x);

3)删除

    在红黑树中删除结点,需要完成 2 步操作:

  • 将红黑树按照二叉查找树删除结点的方法删除指定结点;

  • 重新调整删除结点后的树,使之重新成为红黑树;(还是通过旋转和重新着色的方式进行调整)

    在二叉查找树删除结点时,分为 3 种情况:

  • 若该删除结点本身是叶子结点,则可以直接删除;

  • 若只有一个孩子结点(左孩子或者右孩子),则直接让其孩子结点顶替该删除结点;

  • 若有两个孩子结点,则找到该结点的右子树中值最小的叶子结点来顶替该结点,然后删除这个值最小的叶子结点。

(5)红黑树相比于BST和AVL的优点

    红黑树是牺牲了严格的高度平衡的优越条件为代价,它只要求部分地达到平衡要求,降低了对旋转的要求,从而提高了性能。红黑树能够以O(log2n)的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。当然,还有一些更好的,但实现起来更复杂的数据结构能够做到一步旋转之内达到平衡,但红黑树能够给我们一个比较“便宜”的解决方案。

    相比于BST(二叉查找树),因为红黑树可以能确保树的最长路径不大于两倍的最短路径的长度,所以可以看出它的查找效果是有最低保证的。在最坏的情况下也可以保证O(logN)的,这是要好于二叉查找树的。因为二叉查找树最坏情况可以让查找达到O(N)。

    红黑树的算法时间复杂度和AVL(平衡二叉树)相同,但统计性能比AVL树更高,所以在插入和删除中所做的后期维护操作肯定会比红黑树要耗时好多,但是他们的查找效率都是O(logN),所以红黑树应用还是高于AVL树的. 实际上插入 AVL 树和红黑树的速度取决于你所插入的数据.如果你的数据分布较好,则比较宜于采用 AVL树(例如随机产生系列数),但是如果你想处理比较杂乱的情况,则红黑树是比较快的。

(6)总结

    红黑树隶属于二叉查找树,但是二叉查找树的时间复杂度会受到其树深度的影响,而红黑树可以保证在最坏情况下的时间复杂度仍为O(logn)。当数据量多到一定程度时,使用红黑树比二叉查找树的效率要高

    同平衡二叉树相比较,红黑树没有像平衡二叉树对平衡性要求的那么苛刻,虽然两者的时间复杂度相同,但是红黑树在实际测算中的速度要更胜一筹

 

2.哈希表

2.1 哈希表介绍

(1)哈希表的概念

    数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。那么能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?这就是哈希表。哈希表涉及到的概念如下:

  • 负责将某一个元素映射为一个”大小可接受内的索引“,这样的函数称为 hash function(散列函数)。

  • 使用散列函数可能会带来问题:可能会有不同的元素被映射到相同的位置,这无法避免,因为元素个数有可能大于分配的 array 容量,这就是所谓的碰撞问题,解决碰撞问题一般有:线性探测、二次探测、开链等。

    哈希表可以视为一种字典结构,在插入、删除、查找等操作上也具有”常数平均时间“的表现。

(2)哈希表查找步骤

    1)在存储时,通过哈希函数计算记录的哈希地址,并按次哈希地址存储该记录。

    2)查找记录时,同样通过哈希函数计算记录的哈希地址,并按次哈希地址访问该记录。

    哈希表最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但是对于一个关键字对应多个记录的数据结构,则不适合用哈希表

(3)哈希函数的构造

    哈希函数的构造原则:计算简单、哈希地址分布均匀。

    常用的哈希函数的构造方法有 6 种:直接定址法、数字分析法、平方取中法、折叠法、除留余数法和随机数法。

1)直接定址法

    其哈希函数为一次函数,即以下两种形式: H(key) = key 或者 H(key)=a * key + b(a、b为常数,H表示哈希地址)

2)数字分析法

    如果关键字由多位字符或者数字组成,就可以考虑抽取其中的2位或者多位作为该关键字对应的哈希地址。 例如下表中列举的是一部分关键字,每个关键字都是有8位十进制数组成:

    通过分析关键字的构成,很明显可以看到关键字的第 1 位和第 2 位都是固定不变的,而第 3 位不是数字 3 就是 4,最后一位只可能取 2、7 和 5,只有中间的 4 位其取值近似随机,所以为了避免冲突,可以从 4 位中任意选取 2 位作为其哈希地址。

3)平方取中法

    该方式对关键字做平方操作,取中间得几位作为哈希地址。此方法也是比较常用的构造哈希函数的方法

    例如关键字序列为{421,423,436},对各个关键字进行平方后的结果为{177241,178929,190096},则可以取中间的两位{72,89,00}作为其哈希地址。

4)折叠法

    折叠法是将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。此方法不需要知道关键字的分布,适合关键字位数较多的情况

    例如关键字9876543210,哈希表表长三位,将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0 = 1962,再求后3位得到哈希表。

5)除留余数法

    若已知整个哈希表的最大长度m,可以取一个不大于m的数p,然后对该关键字key做取余运算。此方法是最常用的构造哈希函数的方法。

    哈希函数公式为:H(key)= key % p (p≤m)

6)随机数法

    该方法是取关键字的一个随机函数值作为它的哈希地址。此方法适用于关键字长度不等的情况。

    哈希函数公式为:H(key)= random(key)

注意:

    如此多的构建哈希函数的方法,在选择的时候,需要根据实际的查找表的情况采取适当的方法。通常考虑的因素有以下几方面:

  • 关键字的长度。如果长度不等,就选用随机数法。如果关键字位数较多,就选用折叠法或者数字分析法;反之如果位数较短,可以考虑平方取中法;

  • 哈希表的大小。如果大小已知,可以选用除留余数法;

  • 关键字的分布情况;

  • 查找表的查找频率;

  • 计算哈希函数所需的时间(包括硬件指令的因素)

(4)处理哈希函数冲突的方法

    对于哈希表的建立,需要选取合适的哈希函数,但是对于无法避免的冲突,需要采取适当的措施去处理。

1)开放地址法

    该方法为一旦发生了冲突,就去寻找下一个空的哈希地址,只要哈希表足够大,空的哈希地址总能找到,并将记录存入。基本公式为:

    H(key) = (H(key)+ d) % m(其中m为哈希表的表长,d为一个增量),当得出的哈希地址冲突时,选取以下三种方法中的一种获取d的值:

  • 线性探测法:d=1,2,3,…,m-1

  • 二次探测法:d=12,-12,22,-22,32,…

  • 伪随机数探测法:d=伪随机数

2)再哈希法

    这种方法是事先准备多个哈希函数,当通过哈希函数求得的哈希地址同其他关键字产生冲突时,使用另一个哈希函数计算,直到冲突不再发生。

3)公共溢出法

    建立两张表,一张为基本表,另一张为溢出表。基本表存储没有发生冲突的数据,当关键字由哈希函数生成的哈希地址产生冲突时,就将数据填入溢出表。

4)链地址法

    将所有产生冲突的关键字所对应的数据全部存储在同一个线性链表中。这也是最常用的方法。

    例如,有一组关键字为{19,14,23,01,68,20,84,27,55,11,10,79},其哈希函数为:H(key)=key MOD 13,使用链地址法所构建的哈希表如图所示:

   

    这种方法对于可能会造成很多冲突的哈希函数来说,提供了绝不会出现找不到地址的保障,当然,这也就带来了查找时需要遍历单链表的性能损耗

(5)哈希表查找实现

    首先需要定义一个哈希表的结构以及一些相关的常数,其中HashTable就是哈希表结构。

    1)对于给定的关键字 K,将其带入哈希函数中,求得与该关键字对应的数据的哈希地址,如果该地址中没有数据,则证明该查找表中没有存储该数据,查找失败;

    2)如果哈希地址中有数据,就需要做进一步的证明(排除冲突的影响),找到该数据对应的关键字同 K 进行比对,如果相等,则查找成功;

    3)反之,如果不相等,说明在构造哈希表时发生了冲突,需要根据构造表时设定的处理冲突的方法找到下一个地址,同地址中的数据进行比对,直至遇到地址中数据为 NULL(说明查找失败),或者比对成功。

    如果不发生冲突,哈希查找的时间复杂度为O(1), 由于冲突的产生,使得哈希表的查找算法仍然会涉及到比较的过程,因此对于哈希表的查找效率仍需以平均查找长度来衡量。

(6)红黑树与哈希表,在选择时有什么依据

    权衡四个因素: 查找速度,数据量, 内存使用,可扩展性。

    总体来说,hash查找速度会比红黑树快,而且查找速度基本和数据量大小无关,属于常数级别;而红黑树的查找速度是log(n)级别。并不一定常数就比log(n) 小,hash还有hash函数的耗时,如果考虑效率,特别是在元素达到一定数量级时,考虑hash。但若对内存使用特别严格, 希望程序尽可能少消耗内存,那么一定要小心,特别是当hash对象特别多时,就更无法控制了,而且 hash的构造速度较慢。

   红黑树并不适应所有应用树的领域。如果数据基本上是静态的,那么让他们待在他们能够插入,并且不影响平衡的地方会具有更好的性能。如果数据完全是静态的,例如,做一个哈希表,性能可能会更好一些。

    在实际的系统中,例如,需要使用动态规则的防火墙系统,使用红黑树而不是哈希表被实践证明具有更好的伸缩性。Linux内核在管理vm_area_struct时就是采用了红黑树来维护内存块的。

 

2.2 STL中的哈希表

    STL的hashtable使用链地址法解决冲突的哈希表。表长是一个质数,开始值是53,当元素个数超过表长时会自动扩容为大概原先两倍的一个素数。源码如下所示:

template <class _Val, class _Key, class _HashFcn,
          class _ExtractKey, class _EqualKey, class _Alloc>
class hashtable {
public:
  typedef _Key key_type;
  typedef _Val value_type;
  typedef _HashFcn hasher;
  typedef _EqualKey key_equal;

  typedef size_t            size_type;
  typedef ptrdiff_t         difference_type;
  typedef value_type*       pointer;
  typedef const value_type* const_pointer;
  typedef value_type&       reference;
  typedef const value_type& const_reference;

  hasher hash_funct() const { return _M_hash; }
  key_equal key_eq() const { return _M_equals; }

private:
  typedef _Hashtable_node<_Val> _Node;

    hashtable的几个模板参数:Value指表中放的实际元素,对set来说是Key,对map来说是pair;Key不用解释;HashFcn是哈希函数;ExtractKey是指如何从Value中提取出Key;EqualKey顾名思义,代表比较Key相等的可调用对象的类型;Alloc是分配器。

    STL中实现 hash table 的方式,是在每个 buckets 表格元素中维护一个链表, 然后在链表上执行元素的插入、搜寻、删除等操作,该表格中的每个元素被称为桶 (bucket)。很多书籍上提到最好取一个素数作为hash表格的大小,STL将28个质数(大约2倍依次递增)计算好,并提供函数来查询其中最接近某数并大于某数的质数,如下所示:


/*质数表*/
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
	53, 97, 193, 389, 769,
	1543, 3079, 6151, 12289, 24593,
	49157, 98317, 196613, 393241, 786433,
	1572869, 3145739, 6291469, 12582917, 25165843,
	50331653, 100663319, 201326611, 402653189, 805306457,
	1610612741, 3221225473, 4294967291
};
 
/*以下找出上述28个质数之中,最接近并大于 n的那个质数(有的话),没有取最大*/
inline unsigned long __stl_next_prime(unsigned long n)
{
	const unsigned long* first = __stl_prime_list;
	const unsigned long* last = __stl_prime_list + __stl_num_primes;
	const unsigned long* pos = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}
 

    C++ STL 标准库中,不仅是 unordered_xxx 容器,所有无序容器的底层实现都采用的是哈希表存储结构。更准确地说,是用“链地址法”(又称“开链法”)解决数据存储位置发生冲突的哈希表,整个存储结构如图所示:

 

    其中,Pi 表示存储的各个键值对。最左边的绿色称之为 bucket 桶,可以看到,当使用无序容器存储键值对时,会先申请一整块连续的存储空间,但此空间并不用来直接存储键值对,而是存储各个链表的头指针,各键值对真正的存储位置是各个链表的节点。 当有新键值对存储到无序容器中时,整个存储过程分为如下几步:

  • 将该键值对中键的值带入设计好的哈希函数,会得到一个哈希值(一个整数,用 H 表示);

  • 将 H 和无序容器拥有桶的数量 n 做整除运算(即 H % n),该结果即表示应将此键值对存储到的桶的编号;

  • 建立一个新节点存储此键值对,同时将该节点链接到相应编号的桶上。

    哈希表存储结构还有一个重要的属性,称为负载因子(load factor)。 该属性同样适用于无序容器,用于衡量容器存储键值对的空/满程序,即负载因子越大,意味着容器越满,即各链表中挂载着越多的键值对, 这无疑会降低容器查找目标键值对的效率;反之,负载因子越小,容器肯定越空,但并不一定各个链表中挂载的键值对就越少。

    举个例子,如果设计的哈希函数不合理,使得各个键值对的键带入该函数得到的哈希值始终相同(所有键值对始终存储在同一链表上)。这种情况下,即便增加桶数是的负载因子减小,该容器的查找效率依旧很差。

    无序容器中,负载因子的计算方法为: 负载因子 = 容器存储的总键值对 / 桶数

    默认情况下,无序容器的最大负载因子为 1.0。如果操作无序容器过程中,使得最大复杂因子超过了默认值,则容器会自动增加桶数,并重新进行哈希,以此来减小负载因子的值。 需要注意的是,此过程会导致容器迭代器失效,但指向单个键值对的引用或者指针仍然有效

    C++ STL 标准库为了方便用户更好地管控无序容器底层使用的哈希表存储结构,各个无序容器的模板类中都提供表 所示的成员方法。

成员方法 功能
bucket_count 返回当前容器底层存储键值对时,使用桶的数量
max_bucket_count 返回当前系统中,unordered_xxx 容器底层最多可以使用多少个桶
bucket_size 返回第 n 个桶中存储键值对的数量
bucket(key) 返回以 key 为键的键值对所在桶的编号
load_factor 返回 unordered_map 容器中当前的负载因子
max_load_factor 返回或者设置当前 unordered_map 容器的最大负载因子
rehash(n) 尝试重新调整桶的数量为等于或大于 n 的值。如果 n 大于当前容器使用的桶数,则该方法会是容器重新哈希,该容器新的桶数将等于或大于 n。反之,如果 n 的值小于当前容器使用的桶数,则调用此方法可能没有任何作用。
reserve(n) 将容器使用的桶数(bucket_count() 方法的返回值)设置为最适合存储 n 个元素的桶
hash_function 返回当前容器使用的哈希函数对象

 

参考:

  1. 《STL源码剖析》

  2. https://github.com/rongweihe/CPPNotes/tree/master/STL-source-code-notes (微信公众号:herongwei)
  3. http://c.biancheng.net/

posted @ 2021-11-02 22:03  烟消00云散  阅读(539)  评论(0编辑  收藏  举报