C++:哈希表
目录
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) { //如果找到了重复元素,直接返回false if (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; else cout << "没找到" << 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; else cout << "没找到" << endl; ht.Erase(12); ret = ht.Find(12); if (ret) cout << "找到了" << endl; else cout << "没找到" << 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😴✨🎞
