文章目录
- 1. unordered_map底层实现原理
- 1.1 散列表
- 1.2 STL 中的 unordered_map 的实现
- 1.3 unordered_map
- 2. 迭代器底层实现原理及种类
- 2.1 主要作用
- 2.2 底层原理
- 2.3 迭代器类型属性
- 3. 迭代器失效
- 3.1 容器类别
- 3.2 失效情况
- 3.3 C++11容器类别
- 4. STL容器的线程安全
- 4.1 背景知识
- 4.2 解决方案
1. unordered_map底层实现原理
一句话概括:unordered_map 是基于散列表实现的 map。
1.1 散列表
-
定义和操作
散列表是一种数据结构,它通过哈希函数把键(key)映射到哈希表的一个位置,然后在这个位置上存储键值对(key-value pair)。在 C++ 中,哈希表可以通过一个数组和一个哈希函数实现。
template<typename Key, typename Value> class HashTable { vector<list<pair<Key, Value>>> data; // 哈希表数据 // 其他数据成员和函数成员... };
-
哈希碰撞和解决方案
哈希碰撞是指多个不同的键被哈希函数映射到哈希表的同一个位置。主要有以下三种处理哈希碰撞的方法:
- 线性探测:当碰撞发生时,向后寻找下一个空位存放数据。
- 开放寻址:同样是在发生碰撞时向后寻找,但会使用二次哈希等方法改变探测的步长。
- 拉链法:使用链表处理碰撞,把同一位置的键值对存入一个链表。
STL 中的 unordered_map 使用的是拉链法处理哈希碰撞。
// 拉链法的实现示例 int hashFunc(Key key) { /* 哈希函数... */ } void insert(Key key, Value value) { int index = hashFunc(key); data[index].push_back(make_pair(key, value)); // 在链表尾部添加键值对 }
-
负载因子
负载因子是散列表的实际元素数量和位置数量(桶的数量)的比值。当负载因子过高时,哈希碰撞的概率增加,查询效率降低。
-
重新哈希
当负载因子大于预设阈值(通常为1)时,会发起重新哈希(rehash),即扩大位置数量,并重新分配所有元素。
void rehash() { // 创建新的更大的哈希表,把所有元素重新分配到新的哈希表 }
1.2 STL 中的 unordered_map 的实现
-
哈希表的模板参数
unordered_map
采用了复杂的模板参数设计,主要包括键类型_Key
、值类型_Value
、内存分配器_Alloc
、提取键的函数_ExtractKey
、比较键是否相等的函数_Equal
、哈希函数_H1
、映射函数_H2
、调用哈希函数和映射函数的函数_Hash
、重新哈希策略_RehashPolicy
以及内存相关的_Traits
等。 -
数据成员
_M_buckets
:哈希表的指针数组,数组的每个元素是一个链表。_M_bucket_count
:数组长度,即哈希表的位置数量。_M_element_count
:实际存储的元素数量。_M_before_begin
:一个特殊的节点,它的下一个节点是哈希表的第一个节点。_M_rehash_policy
:重新哈希的策略对象,它决定何时需要重新哈希。_M_single_bucket
:一个临时的单节点桶,用于临时存储元素。
-
节点定义
每个节点是一个结构体,包含了键值对的存储空间
_M_storage
和其他需要的信息。struct _Hash_node : _Hash_node_base { __stored_ptr _M_storage; };
-
主要的接口
unordered_map
主要的接口包括插入、查找、删除等操作,它们的实现都是调用哈希表的对应函数。其中,插入操作_M_insert_bucket_begin
是先确定插入的位置,然后在这个位置的链表头部插入新的键值对。
1.3 unordered_map
-
定义
unordered_map
是一个模板类,它的模板参数包括键类型、值类型、哈希函数、比较函数和内存分配器。它的主要数据成员是一个_Hashtable
对象。template<typename _Key, typename _Tp, typename _Hash = std::hash<_Key>, typename _Pred = std::equal_to<_Key>, typename _Alloc = std::allocator<std::pair<const _Key, _Tp>>> class unordered_map { // 使用 __umap_hashtable 作为底层的哈希表实现 typedef __umap_hashtable<_Key, _Tp, _Hash, _Pred, _Alloc> _Hashtable; _Hashtable _M_h; // 其他函数成员... };
-
插入操作
插入操作
insert
实际上是调用_M_insert_bucket_begin
函数,把新的键值对插入到哈希表的对应位置。pair<iterator, bool> insert(const value_type& __x) { return _M_h._M_insert_bucket_begin(__x, _M_h._M_bucket_index(__x)); }
-
特点
unordered_map
是无序的,它不保证元素的顺序。它的搜索、插入和删除操作的时间复杂度都接近 O(1),这是通过哈希表实现的。当哈希碰撞很少时,这些操作的时间复杂度可以认为是常数时间。
2. 迭代器底层实现原理及种类
一句话概括:迭代器提供了一种访问容器内部元素,同时不会暴露容器内部实现的方式。
2.1 主要作用
-
解引用和成员访问
迭代器主要用于遍历和访问容器中的元素。通过解引用迭代器(例如
*it
),可以访问当前迭代器所指向的元素。通过成员访问(例如it->member
),可以访问当前元素的成员。 -
统一不同容器的访问方式
迭代器可以统一不同容器的访问方式,使得算法可以对不同类型的容器进行操作。例如,
find
、min_element
、upper_bound
、reverse
、sort
等算法,它们都接收迭代器作为参数,用于访问容器的元素。vector<int> vec = {1, 2, 3, 4, 5}; auto it = find(vec.begin(), vec.end(), 3); // find 在 vector 中使用
2.2 底层原理
-
考虑哪些问题
在设计迭代器时,需要考虑的主要问题有:
- 泛型编程:迭代器不是基于面向对象的思想编程,而是基于泛型编程的思想。泛型编程注重代码的可复用性,可以编写出容器无关的代码。
- 通用算法问题:需要确定迭代器的类型,然后根据迭代器的类型来实现相应的算法。因此,需要定义迭代器的类型别名,这样可以通过类型别名来获取迭代器的类型。对于指针类型,由于不能定义类型别名,因此需要通过泛型特化的方式来处理。
-
定义了迭代器的5种类型
STL 定义了5种迭代器,分别是:
- 输入迭代器(Input Iterator):只读,只能向前移动。
- 输出迭代器(Output Iterator):只写,只能向前移动。
- 前向迭代器(Forward Iterator):可读写,只能向前移动。
- 双向迭代器(Bidirectional Iterator):可读写,可以向前和向后移动。
- 随机访问迭代器(Random Access Iterator):可读写,可以随机访问。
这五种迭代器之间存在包含关系,具体为:输入迭代器 < 前向迭代器 < 双向迭代器 < 随机访问迭代器,输出迭代器独立于此关系。
-
迭代器萃取
迭代器萃取是指从迭代器类型中提取出迭代器的特性,例如迭代器的类别、值类型、指针类型、引用类型等。这是通过模板的特化来实现的。
template<class Iterator> struct iterator_traits { typedef typename Iterator::iterator_category iterator_category; typedef typename Iterator::value_type value_type; typedef typename Iterator::difference_type difference_type; typedef typename Iterator::pointer pointer; typedef typename Iterator::reference reference; };
对于指针类型,需要进行特化:
template<class T> struct iterator_traits<T*> { typedef std::random_access_iterator_tag iterator_category; typedef T value_type; typedef ptrdiff_t difference_type; typedef T* pointer; typedef T& reference; };
-
函数重载
可以通过函数重载,根据不同的迭代器类型选择不同的算法实现。例如,对于随机访问迭代器,可以使用更高效的算法。
template <class RandomAccessIterator> inline void sort(RandomAccessIterator first, RandomAccessIterator last, random_access_iterator_tag) { // 高效的排序算法 } template <class BidirectionalIterator> inline void sort(BidirectionalIterator first, BidirectionalIterator last, bidirectional_iterator_tag) { // 低效的排序算法 }
2.3 迭代器类型属性
-
输入迭代器
输入迭代器只读并且只能读取一次,常见的例子是
istream_iterator
。在迭代器传递过程中,上一个迭代器会失效。istream_iterator<int> it(cin);
-
输出迭代器
输出迭代器只写并且只能写入一次,常见的例子是
ostream_iterator
。在迭代器传递过程中,上一个迭代器会失效。ostream_iterator<int> it(cout, " ");
-
前向迭代器
前向迭代器可以读写并且可以多次读写,可以保存迭代器。例如
forward_list
、unordered_map
、unordered_set
的迭代器。forward_list<int> lst = {1, 2, 3}; auto it = lst.begin();
-
双向迭代器
双向迭代器可以读写并且可以多次读写,可以保存迭代器,并且可以向前和向后移动。例如
list
、map
、set
、multimap
、multiset
的迭代器。list<int> lst = {1, 2, 3}; auto it = lst.begin(); ++it; --it;
-
随机访问迭代器
随机访问迭代器可以读写并且可以多次读写,可以保存迭代器,并且可以进行随机访问。例如
vector
、deque
的迭代器。vector<int> vec = {1, 2, 3}; auto it = vec.begin() + 2;
-
属性图
3. 迭代器失效
迭代器失效是指在对容器进行一些操作(如插入、删除元素)后,之前获取的迭代器可能不再有效。失效的迭代器不应再被使用,否则可能导致未定义的行为。
3.1 容器类别
-
序列型容器
这些容器维护一个严格的线性序列,例如
vector
、deque
、queue
。对这类容器进行插入或删除操作时,需要注意迭代器的失效情况。 -
关联型容器
这些容器维护的是一个排序的关键字集合,例如
set
、map
、multiset
、multimap
。在这些容器中插入或删除元素,可能会导致迭代器失效。 -
链表型容器
链表型容器包括
forward_list
、list
、以及unordered_*
系列容器。由于这些容器底层采用了链表结构,所以在插入或删除元素时,除了涉及操作的迭代器外,其他迭代器通常都不会失效。
3.2 失效情况
-
单独删除或插入
当单独对某个位置进行插入或删除操作时,会影响到一部分迭代器的有效性。
-
插入
插入操作的方法有
insert
、emplace
、push_back
、push_front
等。在vector
或deque
中,如果在中间位置插入元素,可能会导致所有迭代器失效;如果在尾部插入元素,可能会导致所有迭代器失效,因为可能引发容器的扩容。在list
、forward_list
、以及unordered_*
系列容器中,插入元素不会导致其他迭代器失效。 -
删除
删除操作的方法有
erase
、pop_back
、pop_front
、clear
等。在vector
或deque
中,删除元素会导致从删除位置到尾部的所有迭代器失效。在list
、forward_list
、以及unordered_*
系列容器中,删除元素只会让指向被删除元素的迭代器失效。
-
-
遍历删除
在遍历过程中删除元素需要特别注意,一般需要在删除元素后及时更新迭代器。
-
序列型容器
对于
vector
、deque
等序列型容器,可以使用以下方式在遍历中删除元素:for (auto it = container.begin(); it != container.end(); ) { if (shouldDelete(*it )) { it = container.erase(it); } else { ++it; } }
-
关联型容器
对于
set
、map
、multiset
、multimap
等关联型容器,在 C++11 之前,通常采用以下方式在遍历中删除元素:for (auto it = container.begin(); it != container.end(); ) { if (shouldDelete(*it)) { container.erase(it++); } else { ++it; } }
在 C++11 之后,可以像处理序列型容器一样,直接使用
erase
方法的返回值更新迭代器:for (auto it = container.begin(); it != container.end(); ) { if (shouldDelete(*it)) { it = container.erase(it); } else { ++it; } }
-
链表型容器
对于
forward_list
、list
、unordered_*
系列容器,由于其底层为链表结构,因此可以采用与关联型容器在 C++11 之后相同的方式在遍历中删除元素。在这些容器中,除了被删除元素的迭代器会失效外,其他迭代器不会失效。
-
3.3 C++11容器类别
-
连续存储容器
对于如
vector
、string
、deque
这类连续存储的容器,插入或删除元素可能会导致所有迭代器失效。在遍历过程中删除元素时,需要更新迭代器:it = container.erase(it);
-
非连续存储容器
对于如
list
、set
、map
、unordered_*
这类非连续存储的容器,在插入或删除元素时,只有指向被插入或删除元素的迭代器会失效。在遍历过程中删除元素时,也需要更新迭代器:it = container.erase(it);
或者:
container.erase(it++);
4. STL容器的线程安全
在多线程环境下,如果多个线程同时操作同一个容器,那么就需要考虑线程安全问题。STL中的容器并不是线程安全的,也就是说,它们并没有内部机制来防止并发操作带来的数据竞争或其他问题。
4.1 背景知识
STL容器的内部实现是已经固定的,它们没有加锁机制,也不能在其源码中添加锁。当多个线程并发操作同一容器时,可能会引发数据竞争或者其它未定义的行为。
-
容器内部实现原理
-
扩缩容
对于
vector
和deque
以及基于deque
的容器(如priority_queue
、queue
、stack
),当容器空间不足以容纳新的元素时,就需要进行扩容,即重新分配更大的内存空间,并将原来的元素复制到新的内存空间中。在多线程环境下,如果有一个线程在对容器进行扩容操作,而另一个线程试图访问或者修改容器的元素,那么就可能发生错误。 -
rehash
对于
unordered_*
系列容器,当容器内元素数量增多时,为了保持良好的查找性能,它们会进行rehash操作,即增加哈希表的桶数量,并将原来的元素重新进行哈希放入新的桶中。这同样可能引起多线程下的问题。 -
节点关系
对于
vector
,当在其中间位置插入或删除元素时,会引起该位置之后的所有元素移动,改变它们与容器的关系。对于基于红黑树的容器(如set
、map
等),插入或删除元素可能会引起树的rebalance操作,改变节点间的关系。这两种情况在多线程环境下都可能造成问题。
-
4.2 解决方案
-
加锁
一个直接的解决方案就是加锁,即在对容器进行操作前先获得锁,操作完成后再释放锁。
-
互斥锁
互斥锁可以防止两个线程同时对同一个容器进行操作。例如,在C++中可以使用
std::mutex
:std::mutex mtx; // ... { std::lock_guard<std::mutex> lock(mtx); // 在此区域内对容器进行操作 }
-
读写锁
如果一个容器主要用于读取操作,只偶尔进行写入操作,那么可以使用读写
锁来提高效率。在C++中可以使用
std::shared_mutex
:std::shared_mutex smtx; // ... { std::shared_lock<std::shared_mutex> lock(smtx); // 在此区域内进行读取操作 } // ... { std::unique_lock<std::shared_mutex> lock(smtx); // 在此区域内进行写入操作 }
-
-
不加锁,避免加锁
除了使用锁,还可以通过设计避免在多线程中对同一个容器进行操作。一种方法是预先分配足够的节点,并将数据分成多份,每个线程只操作专属的那份数据。这样可以避免线程之间的冲突,但可能会引入新的问题,比如线程操作的不均衡等。