unordered系列关联式容器
在之前的博文中介绍过关联式容器中的map与set,同map与set一样,unordered_set与unordered_set也是关联式容器。
在C++98中,STL提供了底层为红黑树结构的一系列关联式容器,查询效率可以达到logN;在C++11中,STL又提供了4个unordered系列的关联式容器,这四个容器与红黑树结构的关联式容器的使用方法类似,但是其底层的实现不同。
unordered_map与unordered_set的底层实现都是哈希表。unordered关联容器与其他关联容器的区别是:
- unordered是单项迭代器
- unordered遍历出来的数据不是有序的
- unordered_set只能去重。不可以用于排序
- unordered_map也不可以用于排序
- 无序数据可以使用unordered关联容器具有优势,有序数据使用其他容器较快。
unordered_map的介绍
unordered_map的文档介绍
std::unordered_map
- unordered_map是存储< key,value >键值对的关联式容器,其允许通过keys快速的索引到与其对应的value。
- 在unordered_map中,键值通常用于唯一地标识元素,而映射值是一个对象,其内容与此键关联。键和映射值的类型可能不同。
- 在内部,unordered_map没有对< key,value >按照任何特定的顺序排序,为了可以在常数范围内找到key所对应的value,unordered_map将相同哈希值的键值对放在相同的桶中。
- unordered_map容器通过key访问单个元素要比map快,但它通常在遍历元素子集的范围迭代器方面效率较低。
- unordered_map实现了直接访问操作符operator[ ],它允许使用key作为参数直接访问value。
- unordered_map的迭代器至少是前向迭代器。
unordered_map的接口说明
unordered_map的构造
函数说明 | 功能介绍 |
unordered_map | 构造不同格式的unordered_map的对象 |
unordered_map的容量
函数说明 | 功能介绍 |
bool empty() const noexcept; |
检测unordered_map是否为空 |
size_type size() const noexcept; |
获取unordered_map的有效元素个数 |
unordered_map的迭代器
函数说明 | 功能介绍 |
begin | 返回unordered_map第一个元素的迭代器 |
end | 返回unordered_map最后一个元素下一个位置的迭代器 |
rbegin | 返回unordered_map第一个元素的const迭代器 |
rend | 返回unordered_map最后一个元素下一个位置的const迭代器 |
unordered_map的元素访问
函数说明 | 功能介绍 |
operator[] | 返回与key对应的value,没有一个默认值 |
【注意】:该函数实际上调用了哈希桶的插入操作,用参数key与value构造一个默认值往底层哈希桶中插入,如果key不在哈希桶中,插入成功,返回value;插入失败,说明key已经在哈希桶值,将key对应的value返回。
unordered_map的查询
函数说明 | 功能介绍 |
iterator find ( const key_type& k ); |
返回key在哈希桶中的位置 |
size_type count ( const key_type& k ) const; |
返回哈希桶中关键值为key的键值对的个数 |
【注意】:unordered_map中的key是不能重复的,因此count函数的返回值最大为1
unordered_map的修改操作
函数说明 | 功能介绍 |
insert | 向容器中插入键值对 |
erase | 删除容器中的键值对 |
clear | 清空容器中有效元素的个数 |
swap | 交换俩个容器中的元素 |
unordered_map桶操作
函数说明 | 功能介绍 |
bucket_count |
返回哈希桶中的桶的总数 |
bucket_size |
返回n号桶中有效元素的个数 |
bucket |
返回元素key所在的桶号 |
哈希底层结构
哈希概念
哈希(散列):存储的值跟存储位置建立出一个对应关系。
顺序容器以及平衡树中,元素关键码与其存储位置之间没有对应关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中的元素的比较次数。
下面介绍一种方法:哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)或者散列表。
该方法可以不经过任何比较,一次直接从表中得到想要的元素,通过某种函数使元素的存储位置与其关键码之间能够建立一一映射的关系,在查找的时候通过该函数可以很快找到该元素。
插入元素:根据待插入的关键码,以此函数计算出该元素的存储位置并按照此位置进行存放。
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按照此位置取元素比较,若关键码相等,则搜索成功。
哈希设计介绍
哈希函数设计原则:
- 哈希函数的定义域必须包括存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中。
常见哈希函数:
1.直接定址法
取关键字的某个线性函数为散列地址:Hash(key) = A*key + B;
【优点】:简单,均匀
【缺点】:需要事先知道关键字的分布情况
【使用场景】:适合查找范围小,数据集中的情况。
2.除留余数法
设散列表中允许的地址数为m,取一个不大于m,但是最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key)=key%p(p<=m),将关键码转换成哈希地址。
哈希冲突
哈希冲突: 不同关键字通过相同哈希函数计算出相同的哈希地址,这种现象是哈希冲突,也称为哈希碰撞。
解决哈希冲突的俩种常见的方法是:闭散列和开散列
闭散列
闭散列:也叫开放地址法,当发生哈希冲突的时候,如果哈希表未被填满,说明了在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。
1.线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置。
插入方式:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除方式:
- 采用闭散列处理哈希冲突的时候,不能随便物理删除哈希表中已经存在的元素,这样可能会导致影响其他元素的搜索。
- 可以采用标记的伪删除的方式来删除一个元素
enum STATE { EXIST, EMPTY, DELETE };
哈希表的扩容
如果使用开放地址法,可以利用载荷因子:
散列表的载荷因子:a = 填入表中的元素个数 / 散列表的长度
a是散列表装满程度的标志因子,由于散列表的长度是定值,a与“填入表中的元素个数”成正比。
- 负载因子越大,冲突概率越大,空间利用率更高
- 负载因子越小,冲突概率越小,空间利用率更低(空间浪费越多)
哈希表不能满了再扩容,控制负载因子再0.7-0.8之间。如果超过0.8,查表时的CPU缓存不命中,按照指数曲线上升。
在扩容之后映射关系需要改变,重新映射,这样也会导致哈希冲突的变化。
线性探测的实现
#include<iostream> #include<string> using namespace std; template<class K> struct DefaultHashFunc { size_t operator()(const K& key) { return (size_t)key; } }; template<> struct DefaultHashFunc<string> { size_t operator()(const string& str) { // BKDR size_t hash = 0; for (auto ch : str) { hash *= 131; hash += ch; } return hash; } }; namespace open_address { enum STATE { EXIST, EMPTY, DELETE }; template<class K, class V> struct HashData { pair<K, V> _kv; STATE _state = EMPTY; }; template<class K, class V, class HashFunc = DefaultHashFunc<K>> class HashTable { public: HashTable() { _table.resize(10); } bool Insert(const pair<K, V>& kv) { if (Find(kv.first)) { return false; } // 扩容 //if ((double)_n / (double)_table.size() >= 0.7) if (_n * 10 / _table.size() >= 7) { size_t newSize = _table.size() * 2; // 遍历旧表,重新映射到新表 HashTable<K, V, HashFunc> newHT; newHT._table.resize(newSize); // 遍历旧表的数据插入到新表即可 for (size_t i = 0; i < _table.size(); i++) { if (_table[i]._state == EXIST) { newHT.Insert(_table[i]._kv); } } _table.swap(newHT._table); } // 线性探测 HashFunc hf; size_t hashi = hf(kv.first) % _table.size(); while (_table[hashi]._state == EXIST) { ++hashi; hashi %= _table.size(); } _table[hashi]._kv = kv; _table[hashi]._state = EXIST; ++_n; return true; } HashData<const K, V>* Find(const K& key) { // 线性探测 HashFunc hf; size_t hashi = hf(key) % _table.size(); while (_table[hashi]._state != EMPTY) { if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key) { return (HashData<const K, V>*) & _table[hashi]; } ++hashi; hashi %= _table.size(); } return nullptr; } // 按需编译 bool Erase(const K& key) { HashData<const K, V>* ret = Find(key); if (ret) { ret->_state = DELETE; --_n; return true; } return false; } private: vector<HashData<K, V>> _table; size_t _n = 0; // 存储有效数据的个数 }; }
【线性探测的优点】:实现简单
【线性探测的缺点】:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了棵利用的空位置,使得寻找某关键码的位置需要多次比较,导致搜索效率低下。
2.二次探测
线性探测的缺点是产生冲突之后,会将数据堆积在一起,这与其找下一个空位置有关系,查找数据需要逐个进行查找,可以使用二次探测解决问题。
二次探测:
找下一个空位置的方法:
hashi = key % n
hashi + i ^2(i为移动的位置)
i>=0
【研究表明】:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任 何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
闭散列最大的缺陷就是空间利用率低,这也是哈希的缺陷。
开散列
开散列:开散列法又称为链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一个子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
【说明】开散列使用的是哈希桶,如果不扩容,就会不断插入,某些桶就会越来越长,效率得不到保障,负载因子适当放大,一般负载因子控制到1,平均下来,每一个桶一个数据。
开散列的实现
// 泛型编程:不是针对某种具体类型,针对广泛的类型(两种及以上) -- 模板 namespace hash_bucket { template<class T> struct HashNode { T _data; HashNode<T>* _next; HashNode(const T& data) :_data(data) , _next(nullptr) {} }; // 前置声明 template<class K, class T, class KeyOfT, class HashFunc> class HashTable; template<class K, class T, class KeyOfT, class HashFunc> struct HTIterator { typedef HashNode<T> Node; typedef HTIterator<K, T, KeyOfT, HashFunc> Self; Node* _node; HashTable<K, T, KeyOfT, HashFunc>* _pht; HTIterator(Node* node, HashTable<K, T, KeyOfT, HashFunc>* pht) :_node(node) , _pht(pht) {} T& operator*() { return _node->_data; } T* operator->() { return &_node->_data; } Self& operator++() { if (_node->_next) { // 当前桶还没完 _node = _node->_next; } else { KeyOfT kot; HashFunc hf; size_t hashi = hf(kot(_node->_data)) % _pht->_table.size(); // 从下一个位置查找查找下一个不为空的桶 ++hashi; while (hashi < _pht->_table.size()) { if (_pht->_table[hashi]) { _node = _pht->_table[hashi]; return *this; } else { ++hashi; } } _node = nullptr; } return *this; } bool operator!=(const Self& s) { return _node != s._node; } }; // set -> hash_bucket::HashTable<K, K> _ht; // map -> hash_bucket::HashTable<K, pair<K, V>> _ht; template<class K, class T, class KeyOfT, class HashFunc = DefaultHashFunc<K>> class HashTable { typedef HashNode<T> Node; // 友元声明 template<class K, class T, class KeyOfT, class HashFunc> friend struct HTIterator; public: typedef HTIterator<K, T, KeyOfT, HashFunc> iterator; iterator begin() { // 找第一个桶 for (size_t i = 0; i < _table.size(); i++) { Node* cur = _table[i]; if (cur) { return iterator(cur, this); } } return iterator(nullptr, this); } iterator end() { return iterator(nullptr, this); } HashTable() { _table.resize(10, nullptr); } ~HashTable() { for (size_t i = 0; i < _table.size(); i++) { Node* cur = _table[i]; while (cur) { Node* next = cur->_next; delete cur; cur = next; } _table[i] = nullptr; } } bool Insert(const T& data) { KeyOfT kot; if (Find(kot(data))) { return false; } HashFunc hf; // 负载因子到1就扩容 if (_n == _table.size()) { size_t newSize = _table.size() * 2; vector<Node*> newTable; newTable.resize(newSize, nullptr); // 遍历旧表,顺手牵羊,把节点牵下来挂到新表 for (size_t i = 0; i < _table.size(); i++) { Node* cur = _table[i]; while (cur) { Node* next = cur->_next; // 头插到新表 size_t hashi = hf(kot(cur->_data)) % newSize; cur->_next = newTable[hashi]; newTable[hashi] = cur; cur = next; } _table[i] = nullptr; } _table.swap(newTable); } size_t hashi = hf(kot(data)) % _table.size(); // 头插 Node* newnode = new Node(data); newnode->_next = _table[hashi]; _table[hashi] = newnode; ++_n; return true; } Node* Find(const K& key) { HashFunc hf; KeyOfT kot; size_t hashi = hf(key) % _table.size(); Node* cur = _table[hashi]; while (cur) { if (kot(cur->_data) == key) { return cur; } cur = cur->_next; } return nullptr; } bool Erase(const K& key) { HashFunc hf; KeyOfT kot; size_t hashi = hf(key) % _table.size(); Node* prev = nullptr; Node* cur = _table[hashi]; while (cur) { if (kot(cur->_data) == key) { if (prev == nullptr) { _table[hashi] = cur->_next; } else { prev->_next = cur->_next; } --_n; delete cur; return true; } prev = cur; cur = cur->_next; } return false; } void Print() { for (size_t i = 0; i < _table.size(); i++) { printf("[%d]->", i); Node* cur = _table[i]; while (cur) { cout << cur->_kv.first << ":" << cur->_kv.second << "->"; cur = cur->_next; } printf("NULL\n"); } cout << endl; } private: vector<Node*> _table; // 指针数组 size_t _n = 0; // 存储了多少个有效数据 }; }
开散列与闭散列的比较
优先使用开散链的方法。
虽然链地址的方法处理溢出,需要增设链接指针,但是开地址法必须保持大量的空闲空间以确保搜索效率,而表项所占的空间比指针大,所以使用链地址法比开地址法节省空间。