目录
unordered_set和unordered_map
unordered_set(map)的介绍
unordered_set(map) 和 set(map) 的差异
unordered_multiset / unordered_multimap
介绍哈希表
哈希概念
直接定址法
哈希冲突
负载因子
常见哈希函数
除法散列法(重点)
乘法散列法
哈希表的实现
开发定址法(闭散列)
整体框架
哈希表的插入
哈希表的查找
哈希表的删除
测试开放定址法实现的哈希表
链地址法(开散列)(重点)
整体框架
哈希表的插入
哈希表的查找
哈希表的删除
测试链地址法实现的哈希表
unordered_set和unordered_map
在 C++ 中,unordered_set 和 unordered_map 是两种基于哈希表(Hash Table)的容器,它们是 C++11 标准模板库的一部分,提供了高效的元素存储和访问。
unordered_set(map)的介绍
unordered_set 是一个无序集合,它存储唯一的元素,并且不允许重复。unordered_set 提供了平均常数时间复杂度为 O(1) 的插入、删除和查找操作。unordered_set 维护元素的唯一性,如果尝试插入一个已存在的元素,它不会被添加到集合中。
unordered_set 的声明如下
template < class Key, // unordered_set::key_type/value_type class Hash = hash<Key>, // unordered_set::hasher class Pred = equal_to<Key>, // unordered_set::key_equal class Alloc = allocator<Key> // unordered_set::allocator_type > class unordered_set;
第一个模板参数 Key:Key 是 unordered_set 底层关键字类型。 第二个模板参数 Hash(仿函数):unordered_set 默认要求Key支持转换成整形,如果Key不支持或者想按自己的需求将Key转换成整数,可以自己实现并传入。
第三个模板参数 Pred(仿函数):unordered_set 默认要求Key支持比较相等,不需要支持比较大小,这是因为 unordered_set 的底层是哈希表(哈希桶), 其实现不需要比较Key的大小,但其Find函数和Erase函数需要比较Key是否相等。如果 Key 支持或者想按自己的需求来,可以自己实现并传入。
第四个模板参数 Alloc:空间配置器,unordered_set 底层存储数据的内存是从空间配置器申请的,如果需要可以自己实现内存池并传入。
unordered_map 的声明如下
template < class Key, // unordered_map::key_type class T, // unordered_map::mapped_type class Hash = hash<Key>, // unordered_map::hasher class Pred = equal_to<Key>, // unordered_map::key_equal class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type > class unordered_map;
unordered_map 和 unordered_set 除了参数多了T,其余都是一样的,而这个 T 就是 Key-Value 中的value类型。
unordered_set(map) 和 set(map) 的差异
unordered_set 和 set 的增删查操作是一摸一样的,不作演示了。
unordered_set 和 set的第一个差异是对Key的要求不同,set 要求支持小于比较, 因为底层是红黑树(二叉搜索树),unordered_set 要求支持转换成整型且支持等于比较。unordered_set 和 set的第二个差异是迭代器的不同,set 是双向迭代器,而 unordered_set 是单向迭代器。set 底层是红黑树,中序遍历是有序的,迭代器遍历是有序的,而 unordered_set 底层是哈希表,迭代器遍历是无序的。
unordered_set 和 set的第三个差异是性能上的差异,unordered_set 的增删查操作是 O (1) 的时间复杂度,而set 的 增删查操作是 O(logN) 的时间复杂度,unordered_set 的速度较快些。
unordered_map 和 map 的差异跟 unordered_set 和 set 的差异基本上是一模一样的,不过多解释了。
unordered_multiset / unordered_multimap
unordered_multiset / unordered_multimap 跟 multiset / multimap 功能完全一样,支持Key冗余。unordered_multiset / unordered_multimap 和 multiset / multimap 的差异也是 key 要求的差异、iterator 及其遍历顺序的差异和性能的差异。介绍哈希表
哈希概念
哈希(hash)又称散列,本质是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。
说简单点就是,将关键字 Key 转换成地址编号,当你需要 Key 或者 Key-Value 中的 Value ,直接根据地址编号就能找到,这样就不用经过任何的 Key 比较,搜索效率有了极大地提高。其中哈希函数的作用是将关键字 Key 跟存储位置建立映射关系,其实就是将 Key 转换成整数。
一个好的哈希函数应该让 N 个关键字被等概率的均匀的分布在哈希表的 M 个空间,实际上很难做到,但是要可能往这方面设计。
例如:数据集合{1,4,5,6}
哈希函数设置为:hash(key) = key % capacity,capacity为存储空间的总大小。
直接定址法
直接定址法适合关键字的范围比较集中时实现哈希表,比如一组关键字在 [0, 49] 之间,那么我们开个大小为50个数的数组,每个关键字直接对应着数组下标,再比如一组关键字在 [a, z] 的小写字母,那就开个26个数的数组,每个关键字的 ASCII 码就是数组下标。这个方法可以在解决 OJ 上的一些问题。
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
class Solution {public: int firstUniqChar(string s) { vector<int> cnt(26,0); int n = s.size(); for(auto ch : s) { cnt[ch - 'a']++; } for(int i = 0; i < n; ++i) { if(cnt[s[i] - 'a'] == 1) return i; } return -1; }};
直接定址法也有缺点,那就是当数据范围比较分散且上下限差距较大时,开的空间比较大,会浪费内存。
哈希冲突
回到一开始举的例子
假如往哈希表里插入元素 44 ,会发生什么事情?hash(44) = 44 % 10 = 4 ,此时两个不同的Key通过同一个哈希函数映射到了同一位置上去。
这时就出现了哈希冲突,解决方法就是设计一个更好的哈希函数以减少冲突,但实际上,无论什么哈希函数,哈希冲突都是不可避免的。
负载因子
假设哈希表存储了 N 个值,哈希表的大小为 M ,其负载因子 = N / M。负载因子越大,哈希冲突的概率也越高,空间利用率也越高。
负载因子越小,哈希冲突的概率也越低,空间利用率也越低。
常见哈希函数
哈希函数的设计尽可能的减小哈希冲突发生的概率,一下是一些常见的哈希函数。
除法散列法(重点)
假设哈希表的大小为 M ,通过 key 除以 M 的余数作为映射位置的下标。
哈希函数为:hash(key) = key % M。
M 也是有要求的,尽可能避免取2的幂(2 ^ x,x是正整数)、10的幂等整数,尽可能取不接近2的幂的质数。
为什么不取2的幂、10的幂等整数?
因为 key % 2 ^ x 相当于保留 key 二进制数的后 x 位,这样引发哈希冲突的概率会大些,比如,M = 16 = 2 ^ 4,63 和 31 的二进制数的后 4 位都是相同的 1111, 而模上 16 后得到的都是 15,引发了哈希冲突。
话虽如此,但 Java 的 HashMap 采用除法散列就是用2的整数幂作为哈希表的大小 M ,这样就不用取模,可以直接位运算。比如,M 是 2 ^ 16 ,那就是取后 16 位,key ^= key >> 16 ,其结果作为哈希值,映射的值还是在 [0, M) 范围内,但是让 key 所有的位都参与计算,这样映射出的哈希更均匀一下。
乘法散列法
乘法散列法对哈希大小 M 没有要求,其实现首先对 key 乘以常数 A(0 < A < 1),,并抽取 key * A的小数部分,然后用 M 乘以 key * A的小数部分,再舍去小数向下取整。
hash(key) = floor(M * ((A * key) % 1.0)) ,floor 表示向下取整。那问题又来了,A 的值如何设定,一般将 A 取黄金分割点比较好,A = 0.6180339887... 。
假设 M 为1024,key 为 1234 ,A = 0.6180339887,A * key = 762.6539420558,取小数部分为0.6539420558, M * ((A × key) % 1.0) = 0.6539420558 * 1024= 669.6366651392,那么hash(1234) = 669 。
其实还有其他哈希函数,比如全域散列法、平方取中法、折叠法、随机数法和数学分析法,这里就不过多讲解了。
哈希表的实现
实际上实现哈希表用的最多的除法散列法,但是无论什么哈希函数都无法避免哈希冲突的问题,接下来就是两种解决哈希冲突的方法,也是两种不同形式的哈希表实现。
开发定址法(闭散列)
该方法将所有元素放到哈希表中,当出现哈希冲突时,按照某种方法(线性探测、二次探测和双重探测)寻找下一个没有存储数据的位置进行存储,开放定址法中负载因子一定小于 1 。
线性探测:当 key 取模得到的 hashi 位置已经有了元素后,那么 hashi++ 向后寻找空位置,若 hashi > 哈希表的空间大小 M,让 hashi 回到哈希表头。
线性探测也有其缺点,若一个位置发生连续冲突时,会发生群集/堆积现象,这样又会带来一个问题,删除某个元素后,可能会影响后面冲突的查找,这时又需要给每个元素增加状态标识。
这里我们用开放定址实现Key-Value的哈希表。
整体框架
//状态标识,防止元素删除后对查找后面冲突值的影响enum State{EXIST,EMPTY,DELETE};//存放的元素template<class K, class V>struct HashData{pair<K, V> _kv; //一开始元素都是空的,这里用缺省值State _state = EMPTY;};//哈希函数template<class K>struct HashFunc{size_t operator()(const K& key){return size_t(key);}};//string做哈希表的key很常见,对哈希函数进行string特化template<>struct HashFunc<string>{// 字符串转换成整形,可以把字符asci码相加即可// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取// 31, 131等效果会⽐较好size_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}};//找比n大,离n最近的质数inline unsigned long __stl_next_prime(unsigned long n){//质数数组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};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;//在first和last找大于等于n位置的指针const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}//哈希表template<class K, class V, class Hash = HashFunc<K>>class HashTable{public: //构造函数HashTable():_tables(__stl_next_prime(0)),_n(0){} //......private:vector<HashData<K, V>> _tables; //用vector来存储元素size_t _n; //记录有效元素的个数};
哈希表的插入
1.扩容函数:当负载因子大于等于0.7时需要扩容,如果采用旧表映射到新表的方法,这样的逻辑跟Insert函数太重复了,有点浪费,我们可以直接在函数栈帧建立一个新表复用Insert函数,最后交换两个tables就可以完成扩容了。
2.线性探测:通过 key 计算得到 hashi 后,还不能直接往其插入,得判断元素状态。如果 hashi 位置的状态为 EXIST ,则不能往该位置插入,因为被其他元素占有了,得线性探测往后寻找空位置。
void CheckCapacity(){//当负载因子>=0.7时,扩容if (_n * 10 / _tables.size() >= 7){//方法一:旧表映射到新表/*vector<HashData<K, V>> newtables(_tables.size() * 2);for (auto& data : _tables){if (data._state == EXIST){size_t newhashi = HashFunc(data._kv.first) % newtables.size();newtables[newhashi]._kv = data._kv;newtables[newhashi]._state = EXIST;}}_tables.swap(newtables);*///方法二:利用新哈希表HashTable<K, V> newht;//newht._tables.resize(_tables.size() * 2);newht._tables.resize(__stl_next_prime(_tables.size() + 1));for (auto& data : _tables){if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}}bool Insert(const pair<K, V>& kv){//如果找到了重复元素,直接返回falseif (Find(kv.first))return false;//检查负载因子是否需要扩容CheckCapacity();//哈希函数Hash hashfunc;size_t hashi = hashfunc(kv.first) % _tables.size();//线性探测while (_tables[hashi]._state == EXIST){++hashi;hashi %= _tables.size();}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;}
哈希表的查找
哈希表的查找也要线性探测,当前元素状态不为空还不行,还需要该元素状态是存在且 key 相等,如果元素状态是空了,那说明在哈希表中真的找不到该元素了。
HashData<K, V>* Find(const K& key){Hash hashfunc;size_t hashi = hashfunc(key) % _tables.size();while (_tables[hashi]._state != EMPTY){if (_tables[hashi]._state == EXIST&& _tables[hashi]._kv.first == key){return &_tables[hashi];}hashi++;hashi %= _tables.size();}return nullptr;}
哈希表的删除
直接复用 Find 函数即可,找到了将其元素状态置删除,没找到就返回 false 即可。
bool Erase(const K& key){HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;return true;}else{return false;}}
测试开放定址法实现的哈希表
void test_open_hashtable2(){srand((unsigned int)time(0));int N = 100000;vector<int> v(N);for (int i = 0; i < N; ++i){v[i] = rand() + i;}open_address::HashTable<int, int> ht;for (auto e : v){ht.Insert({ e, e });}auto ret = ht.Find(19);if (ret)cout << "找到了" << endl;elsecout << "没找到" << endl;}
链地址法(开散列)(重点)
开放地址法将所有元素放入哈希表中,链地址法就不是这样。链地址法中哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,把冲突的数据链接成一个链表,挂在哈希表下面,这种方法又叫哈希桶。
整体框架
//哈希函数template<class K>struct HashFunc{size_t operator()(const K& key){return size_t(key);}};//string做哈希表的key很常见,对哈希函数进行string特化template<>struct HashFunc<string>{// 字符串转换成整形,可以把字符asci码相加即可// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取// 31, 131等效果会⽐较好size_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}};//找比n大,离n最近的质数inline unsigned long __stl_next_prime(unsigned long n){// 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};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;//在first和last找大于等于n位置的指针const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}//哈希表里存节点的指针template<class K, class V>struct HashNode{pair<K, V> _kv;HashNode<K, V>* _next;HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}};template<class K, class V, class hash = HashFunc<K>>class HashTable{typedef HashNode<K, V> Node;public:HashTable(): _tables(__stl_next_prime(0)),_n(0){} //...private:vector<Node*> _tables;size_t _n = 0;};
哈希表的插入
void CheckCapacity(){//当哈希表里节点的数量与哈希表大小相等时扩容if (_n == _tables.size()){//把哈希桶里的链表每个节点拆下来插入newht效率太低了/*HashTable<K, V> newht;newht._tables.resize(__stl_next_prime(_tables.size() + 1));for (size_t i = 0; i < _tables.size(); ++i){Node* cur = _tables[i];while (cur){newht.Insert(cur->_kv);cur = cur->_next;}}_tables.swap(newht._tables);*/hash hashfunc;vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));for (size_t i = 0; i < _tables.size(); ++i){Node* cur = _tables[i];while (cur){Node* next = cur->_next;size_t hashi = hashfunc(cur->_kv.first) % newtables.size();//头插cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newtables);}}bool Insert(const pair<K, V>& kv){//不允许重复插入if (Find(kv.first))return false;//检查扩容CheckCapacity();hash hashfunc;size_t hashi = hashfunc(kv.first) % _tables.size();//头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true;}
这里如果利用新哈希表复用 Insert 函数来完成扩容的话,效率太低了。因为这种方式本质是将原本哈希表的数据复制到新哈希表里,需要 new 出新节点;当将新哈希表与旧哈希表交换后,此时新哈希表挂着原来的链表数据,由于新哈希表是局部对象,出了作用域会调用哈希表的析构函数(当然这里还没写),又 new 节点 ,又析构,这样消耗太大了,导致效率很低。
所以这里选择直接将旧节点拿下放入新 tables 中,而且必须要一个一个拿,不能直接拿下头节点指针然后存入新 tables 。因为扩容后原本的冲突的数据大概率就不冲突了,在新哈希表的位置就不一样了。
哈希表的查找
查找很简单,不过多叙述了。
Node* Find(const K& key){hash hashfunc;size_t hashi = hashfunc(key) % _tables.size();Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;}
哈希表的删除
与单链表的删除类似,找删除节点的过程要记录其前一个节点,也要特殊处理删除头节点的情况,也比较容易,不做解释。
bool Erase(const K& key){hash hashfunc;size_t hashi = hashfunc(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key){//头节点的情况特殊处理if (prev == nullptr){_tables[hashi] = cur->_next;}else{prev->_next = cur->_next;}delete cur;--_n;return true;}else{prev = cur;cur = cur->_next;}}return false;}
测试链地址法实现的哈希表
void test_bucket_hashtable1(){int a[] = { 19,30,5,36,13,20,21,12,24,96, 19};hash_bucket::HashTable<int, int> ht;for (auto e : a){ht.Insert({ e, e });}auto ret = ht.Find(12);if (ret)cout << "找到了" << endl;elsecout << "没找到" << endl;ht.Erase(12);ret = ht.Find(12);if (ret)cout << "找到了" << endl;elsecout << "没找到" << endl;}
void test_bucket_hashtable2(){srand((unsigned int)time(0));int N = 100000;vector<int> v(N);for (int i = 0; i < N; ++i){v[i] = rand() + i;}hash_bucket::HashTable<int, int> ht;for (auto& e : v){ht.Insert({ e, e });} auto ret = ht.Find(12);}
拜拜,下期再见?
摸鱼ing?✨?