一句话概括:unordered_map 是基于散列表实现的 map。
定义和操作
散列表是一种数据结构,它通过哈希函数把键(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() {
// 创建新的更大的哈希表,把所有元素重新分配到新的哈希表
}
哈希表的模板参数
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
是先确定插入的位置,然后在这个位置的链表头部插入新的键值对。
定义
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),这是通过哈希表实现的。当哈希碰撞很少时,这些操作的时间复杂度可以认为是常数时间。
一句话概括:迭代器提供了一种访问容器内部元素,同时不会暴露容器内部实现的方式。
解引用和成员访问
迭代器主要用于遍历和访问容器中的元素。通过解引用迭代器(例如 *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 中使用
考虑哪些问题
在设计迭代器时,需要考虑的主要问题有:
定义了迭代器的5种类型
STL 定义了5种迭代器,分别是:
这五种迭代器之间存在包含关系,具体为:输入迭代器 < 前向迭代器 < 双向迭代器 < 随机访问迭代器,输出迭代器独立于此关系。
迭代器萃取
迭代器萃取是指从迭代器类型中提取出迭代器的特性,例如迭代器的类别、值类型、指针类型、引用类型等。这是通过模板的特化来实现的。
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) {
// 低效的排序算法
}
输入迭代器
输入迭代器只读并且只能读取一次,常见的例子是 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;
迭代器失效是指在对容器进行一些操作(如插入、删除元素)后,之前获取的迭代器可能不再有效。失效的迭代器不应再被使用,否则可能导致未定义的行为。
序列型容器
这些容器维护一个严格的线性序列,例如 vector
、deque
、queue
。对这类容器进行插入或删除操作时,需要注意迭代器的失效情况。
关联型容器
这些容器维护的是一个排序的关键字集合,例如 set
、map
、multiset
、multimap
。在这些容器中插入或删除元素,可能会导致迭代器失效。
链表型容器
链表型容器包括 forward_list
、list
、以及unordered_*
系列容器。由于这些容器底层采用了链表结构,所以在插入或删除元素时,除了涉及操作的迭代器外,其他迭代器通常都不会失效。
单独删除或插入
当单独对某个位置进行插入或删除操作时,会影响到一部分迭代器的有效性。
插入
插入操作的方法有 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 之后相同的方式在遍历中删除元素。在这些容器中,除了被删除元素的迭代器会失效外,其他迭代器不会失效。
连续存储容器
对于如 vector
、string
、deque
这类连续存储的容器,插入或删除元素可能会导致所有迭代器失效。在遍历过程中删除元素时,需要更新迭代器:
it = container.erase(it);
非连续存储容器
对于如 list
、set
、map
、unordered_*
这类非连续存储的容器,在插入或删除元素时,只有指向被插入或删除元素的迭代器会失效。在遍历过程中删除元素时,也需要更新迭代器:
it = container.erase(it);
或者:
container.erase(it++);
在多线程环境下,如果多个线程同时操作同一个容器,那么就需要考虑线程安全问题。STL中的容器并不是线程安全的,也就是说,它们并没有内部机制来防止并发操作带来的数据竞争或其他问题。
STL容器的内部实现是已经固定的,它们没有加锁机制,也不能在其源码中添加锁。当多个线程并发操作同一容器时,可能会引发数据竞争或者其它未定义的行为。
容器内部实现原理
扩缩容
对于vector
和deque
以及基于deque
的容器(如priority_queue
、queue
、stack
),当容器空间不足以容纳新的元素时,就需要进行扩容,即重新分配更大的内存空间,并将原来的元素复制到新的内存空间中。在多线程环境下,如果有一个线程在对容器进行扩容操作,而另一个线程试图访问或者修改容器的元素,那么就可能发生错误。
rehash
对于unordered_*
系列容器,当容器内元素数量增多时,为了保持良好的查找性能,它们会进行rehash操作,即增加哈希表的桶数量,并将原来的元素重新进行哈希放入新的桶中。这同样可能引起多线程下的问题。
节点关系
对于vector
,当在其中间位置插入或删除元素时,会引起该位置之后的所有元素移动,改变它们与容器的关系。对于基于红黑树的容器(如set
、map
等),插入或删除元素可能会引起树的rebalance操作,改变节点间的关系。这两种情况在多线程环境下都可能造成问题。
加锁
一个直接的解决方案就是加锁,即在对容器进行操作前先获得锁,操作完成后再释放锁。
互斥锁
互斥锁可以防止两个线程同时对同一个容器进行操作。例如,在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);
// 在此区域内进行写入操作
}
不加锁,避免加锁
除了使用锁,还可以通过设计避免在多线程中对同一个容器进行操作。一种方法是预先分配足够的节点,并将数据分成多份,每个线程只操作专属的那份数据。这样可以避免线程之间的冲突,但可能会引入新的问题,比如线程操作的不均衡等。