从零开始的C++学习生活 15:哈希表的使用和封装unordered_map/set

个人主页:Yupureki-CSDN博客
C++专栏:C++_Yupureki的博客-CSDN博客
目录
前言
1. unordered系列容器详解
1.1 unordered_set和unordered_map基本介绍
1.2 与map/set的主要差异
1. 对Key的要求不同
2. 迭代器差异
1.3 基本使用示例
unordered_set使用
unordered_map使用
1.4 哈希相关接口
2. 哈希表底层原理
2.1 哈希概念
哈希冲突
负载因子
将key转为整数
2.2 哈希函数
2.2.1 除法散列法/除留余数法
2.2.2 乘法散列法
2.3 处理哈希冲突
2.3.1 开放定址法
线性探测
二次探测
开放定址法代码实现
2.3.2 链地址法(哈希桶)
链地址法代码实现
2.4 关键问题解决
Key转换为整型
质数表扩容
3. 封装实现unordered_map和unordered_set
3.2 迭代器实现
哈希表完整实现
unordered_map完整实现
总结
核心知识点总结
实际应用建议
上一篇:从零开始的C++学习生活 14:map/set的使用和封装-CSDN博客
前言
红黑树和AVL树都是高级的增删查改的数据结构,我们甚至利用红黑树封装了map和set在C++的标准库容器中供我们使用。
但是树这种结构终究比较复杂,特别是还得保证效率,实现就更复杂了。因此有前人就实现了相对平民点的数据结构-哈希表,也能提供高效的增删查改
并且为了应对时代趋势,我们还会利用哈希表封装unorderd_map和unorderd_set。
无论你是希望在实际项目中合理选择容器类型,还是想要深入理解哈希表这一重要数据结构,我都将为你提供全面的指导。

1. unordered系列容器详解
1.1 unordered_set和unordered_map基本介绍
unordered_set和unordered_map是基于哈希表实现的关联式容器,具有以下特性:
- 平均情况下O(1)的查找、插入、删除效率
- 元素无序存储
- 支持唯一键(unordered_set)或键值对(unordered_map)
// unordered_set声明
template <class Key, // key_type/value_typeclass Hash = hash<Key>, // hasherclass Pred = equal_to<Key>, // key_equal class Alloc = allocator<Key> // allocator_type> class unordered_set;// unordered_map声明
template <class Key, // key_typeclass T, // mapped_typeclass Hash = hash<Key>, // hasherclass Pred = equal_to<Key>, // key_equalclass Alloc = allocator<pair<const Key,T>> // allocator_type> class unordered_map;
1.2 与map/set的主要差异
1. 对Key的要求不同
map/set要求:
- Key支持小于比较(<运算符)
unordered_map/unordered_set要求:
- Key支持转换为整型(用于哈希计算)
- Key支持相等比较(==运算符)
哈希表的底层实际是vector,需要进行下标的处理,因此key必须得转换成无符号整型,而我们日常传递的key肯定不只是无符号整型,还会是有符号整型,string或者是其他的自定义类型,因此我们必须传递把对应的key转换成无符号整型和比较大小的仿函数,如果不传递就用默认的函数,默认你是无符号整型然后进行处理。
2. 迭代器差异
map/set:
- 双向迭代器
- 遍历时按键升序排列
unordered_map/unordered_set:
- 单向迭代器
- 遍历时无序
在我们之后封装哈希表时就会知道,数据存在哈希表中是无需的,不是红黑树中的有序
1.3 基本使用示例
无论是unordered_set和set还是unordered_map和map在实际使用时没有太大的差别
unordered_set使用
#include <iostream>
#include <unordered_set>
#include <string>
using namespace std;void unordered_set_demo() {unordered_set<int> us = {4, 2, 7, 2, 8, 5, 9};// 插入元素us.insert(3);us.insert({1, 6, 10});// 遍历(无序)for (const auto& elem : us) {cout << elem << " ";}cout << endl;// 查找if (us.find(5) != us.end()) {cout << "5 found" << endl;}// 删除us.erase(2);cout << "Size after erase: " << us.size() << endl;// 统计cout << "Bucket count: " << us.bucket_count() << endl;cout << "Load factor: " << us.load_factor() << endl;
}
unordered_map使用
#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;void unordered_map_demo() {unordered_map<string, string> dict = {{"apple", "苹果"},{"banana", "香蕉"},{"orange", "橙子"}};// 插入dict.insert({"grape", "葡萄"});dict["peach"] = "桃子";// 遍历for (const auto& pair : dict) {cout << pair.first << ": " << pair.second << endl;}// 查找和修改if (dict.find("apple") != dict.end()) {dict["apple"] = "苹果🍎"; // 修改}// 统计单词频率vector<string> words = {"apple", "banana", "apple", "orange", "banana", "apple"};unordered_map<string, int> word_count;for (const auto& word : words) {word_count[word]++;}for (const auto& pair : word_count) {cout << pair.first << ": " << pair.second << endl;}
}
1.4 哈希相关接口
unordered系列容器提供了一些与哈希策略相关的接口:
unordered_set<int> us;// 桶相关接口
cout << "Bucket count: " << us.bucket_count() << endl;
cout << "Max bucket count: " << us.max_bucket_count() << endl;// 负载因子相关
cout << "Load factor: " << us.load_factor() << endl;
cout << "Max load factor: " << us.max_load_factor() << endl;// 设置最大负载因子
us.max_load_factor(0.8f);// 重整哈希表,减少冲突
us.rehash(1000); // 设置至少1000个桶
us.reserve(1000); // 预留至少1000个元素的空间
关于这些哈希表特有的术语,我们后面会特地地讲到
2. 哈希表底层原理
2.1 哈希概念
哈希(Hash)又称散列,是一种通过哈希函数建立关键字Key与存储位置映射关系的数据组织方式。
哈希表中底层是vector,所以是一个数组。说叫散列,就是因为数据放在哈希表中是相对无序的。但是无序就是完全的瞎搞吗?当然不是,我们可以联想到之前所使用的计数排序,还记得计数排序的时间效率吗?力压群雄,但唯一的缺点就是空间复杂度会开得太多。如果1和10000利用计数排序,就会开辟十万的空间,但实际上只有两个有效数据。
哈希冲突
在哈希表中,对于同样的key,我们可以key = key%_capacity,其中_capacity是vector的容量大小。对于初始容量,我们设置一个值,假设就1和100000有两个值,那么我们先设两个空间看看,之后1%2 = 1,100000%2 = 0,然后按照下标放到vector中,是不是就解决了空间问题?
看起来是这么简单,但实际上坑还是比较多的。如果1和3怎么说? 3%2=1,不就和1冲突了吗,这就是我们所说的哈希冲突,即多个不同的数据最后key值相同。
负载因子
负载因子 = 元素个数 / 哈希表大小
- 负载因子越大:哈希冲突概率越高,空间利用率越高
- 负载因子越小:哈希冲突概率越低,空间利用率越低
负载因子是我们设计出来来避免哈希冲突的一个变量
将key转为整数
之前说过,我们传的key不可能都是无符号整型,因此我们得自己设计一个仿函数来把key转换成无符号整型
2.2 哈希函数
哈希函数是我们设计出来来尽量避免哈希冲突的方式,是用来对key的加工处理,然后作下标
2.2.1 除法散列法/除留余数法
除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为 映射位置的下标,也就是哈希函数为:h(key)=key%M。
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。因为key%2^x本质相当于保留key的二进制的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。因此我们最好使用距离2的幂较远且为素数的值
2.2.2 乘法散列法
乘法散列法对哈希表大小M没有要求,这个方法的大思路第一步:用关键字K乘上常数A(0<A<1),并抽 取出k*A的小数部分。第二步:后再用M乘以k*A的小数部分,再向下取整。其中我们一般使用常数A为黄金分割比例,即0.6180339887
乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A=0.6180339887,A*key = 762.6539420558,取小数部分为0.6539420558, M×((A×key)%1.0)=0.6539420558*1024= 669.6366651392,那么h(1234)=669。
2.3 处理哈希冲突
我们所用的哈希函数只能够尽量避免哈希冲突,但无法完全处理。还是碰到哈希冲突时,我们就得想办法处理
2.3.1 开放定址法
在开放定址法中所有的元素都放到哈希表里,当⼀个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于的。这里的规则有三种:线性探测、二次探测、双重探测。
线性探测
1.从发生冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M hc(key,i) = hashi = (hash0+i) % M , i = {1,2,3,...,M −1} , hash0位置冲突了,则线性探测公式为: ,因为负载因子小于1, 则最多探测M-1次,一定能找到⼀个存储key的位置。
线性探测的比较简单且容易实现,线性探测的问题假设hash0位置连续冲突,hash0,hash1, hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位 置,这种现象叫做群集/堆积。下面的⼆次探测可以⼀定程度改善这个问题。

二次探测
从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下⼀个没有存储数据的位置为 止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表 尾的位置;
h(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为: 2 hc(key,i) = hashi = (hash0±i ) % M , i = 2 hashi = (hash0−i )%M M {1,2,3,..., M/2}
当hashi<0时,需要hashi+=M
开放定址法代码实现
开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,我们简单选择线性探测实现即可。
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 Hash = HashFunc<K>>class HashTable {private:vector<HashData<K, V>> _tables;size_t _n = 0; // 元素个数public:bool Insert(const pair<K, V>& kv) {if (Find(kv.first)) return false;// 负载因子 > 0.7 时扩容if (_n * 10 / _tables.size() >= 7) {// 扩容逻辑...}Hash hash;size_t hashi = hash(kv.first) % _tables.size();size_t i = 1;// 线性探测寻找空位置while (_tables[hashi]._state == EXIST) {hashi = (hashi + i) % _tables.size();++i;}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;}HashData<K, V>* Find(const K& key) {Hash hash;size_t hashi = hash(key) % _tables.size();size_t i = 1;while (_tables[hashi]._state != EMPTY) {if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) {return &_tables[hashi];}hashi = (hashi + i) % _tables.size();++i;}return nullptr;}};
}
2.3.2 链地址法(哈希桶)
链地址法相当于是vector中的每个元素都变成了链表,冲突的元素利用链表连接即可,因此也被形象地称为哈希桶

链地址法代码实现
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 Hash>class HashTable {private:vector<HashNode<T>*> _tables;size_t _n = 0;public:bool Insert(const T& data) {KeyOfT kot;if (Find(kot(data))) return false;// 负载因子 = 1 时扩容if (_n == _tables.size()) {vector<HashNode<T>*> new_tables(GetNextPrime(_tables.size()), nullptr);// 重新哈希所有元素..._tables.swap(new_tables);}Hash hs;size_t hashi = hs(kot(data)) % _tables.size();// 头插法HashNode<T>* new_node = new HashNode<T>(data);new_node->_next = _tables[hashi];_tables[hashi] = new_node;++_n;return true;}// 其他方法...};
}
2.4 关键问题解决
Key转换为整型
Key有多种数据,在这里我们用string为例。转换成整型的一个最好的条件就是避免哈希冲突,因此我们需要设计出合理的转换方式,例如对于string,我们可以每次加上字符的ASCII码值,然后乘上131(前人大佬的方法)
template<class K>
struct HashFunc {size_t operator()(const K& key) {return (size_t)key;}
};// string特化
template<>
struct HashFunc<string> {size_t operator()(const string& key) {// BKDR哈希算法size_t hash = 0;for (auto ch : key) {hash *= 131;hash += ch;}return hash;}
};
质数表扩容
我们之前说过,哈希表的容量最好是跟2的幂相远并且最好也是素数,那么就有人专门发明了一个素数表供我们使用
inline unsigned long GetNextPrime(unsigned long n) {static const int num_primes = 28;static const unsigned long prime_list[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 = prime_list;const unsigned long* last = prime_list + num_primes;const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}
3. 封装实现unordered_map和unordered_set
unordered_map和unordered_set的底层都是哈希表
// MyUnorderedSet.htemplate<class K, class Hash = HashFunc<K>>class unordered_set {struct SetKeyOfT {const K& operator()(const K& key) {return key;}};public:bool insert(const K& key) {return _ht.Insert(key);}private:hash_bucket::HashTable<K, K, SetKeyOfT, Hash> _ht;};// MyUnorderedMap.h template<class K, class V, class Hash = HashFunc<K>>class unordered_map {struct MapKeyOfT {const K& operator()(const pair<K, V>& kv) {return kv.first;}};public:bool insert(const pair<K, V>& kv) {return _ht.Insert(kv);}V& operator[](const K& key) {auto ret = _ht.Insert(make_pair(key, V()));return ret.first->second;}private:hash_bucket::HashTable<K, pair<K, V>, MapKeyOfT, Hash> _ht;};
3.2 迭代器实现
哈希表迭代器的核心在于operator++的实现
为了高效处理,我们所用的哈希表是链地址法,因此每个哈希节点都是链表
那么++就以下两种情况:
1.在链表里面++
2.已走到当前链表的尾部,需要跳到下一个不为空的链表中
那么如何找到下一个不为空的链表?这里我在迭代器内加上哈希表的地址和当前位置的下标,方便我们查找
template<class T,class Ptr,class Ref>
struct HashTableIterator {typedef HashNode<T> Node;typedef HashTableIterator<T, Ptr, Ref> Self;Node* _node;//哈希表的节点typename list<T>::iterator _it;//迭代器本体vector<Node>* _tables;////哈希表指针size_t _bucket_index;//当前位置的下标//......
}
Self& operator++()
{if (_node && _it != _node->_kv.end()) {//不为末尾在链表内迭代++_it;}if (_it == _node->_kv.end())//为末尾则跳到下一个不为空的链表中{while (_node){++_bucket_index;if (_bucket_index < _tables->size()){_node = &(*_tables)[_bucket_index];if (!_node->_kv.empty()) {_it = _node->_kv.begin();break;}}else{_node = nullptr;_it = typename list<T>::iterator();break;}}}return *this;
}
实现unordered_map和unordered_set的基本方法跟map和set差别不大,再次不在过多赘述
哈希表完整实现
namespace hash_bucket {template<class K, class T, class KeyOfT, class Hash>class HashTable {template<class K, class T, class Ptr, class Ref, class KeyOfT, class Hash>friend struct HTIterator;typedef HashNode<T> Node;public:typedef HTIterator<K, T, T*, T&, KeyOfT, Hash> Iterator;typedef HTIterator<K, T, const T*, const T&, KeyOfT, Hash> ConstIterator;Iterator Begin() {for (size_t i = 0; i < _tables.size(); i++) {Node* cur = _tables[i];if (cur) {return Iterator(cur, this);}}return End();}Iterator End() {return Iterator(nullptr, this);}pair<Iterator, bool> Insert(const T& data) {KeyOfT kot;Iterator it = Find(kot(data));if (it != End()) {return make_pair(it, false);}// 扩容逻辑...Hash hs;size_t hashi = hs(kot(data)) % _tables.size();// 头插Node* newnode = new Node(data);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return make_pair(Iterator(newnode, this), true);}Iterator Find(const K& key) {KeyOfT kot;Hash hs;size_t hashi = hs(key) % _tables.size();Node* cur = _tables[hashi];while (cur) {if (kot(cur->_data) == key) {return Iterator(cur, this);}cur = cur->_next;}return End();}private:vector<Node*> _tables;size_t _n = 0;};
}
unordered_map完整实现
template<class K, class V, class Hash = HashFunc<K>>class unordered_map {struct MapKeyOfT {const K& operator()(const pair<K, V>& kv) {return kv.first;}};public:typedef typename hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash>::Iterator iterator;iterator begin() {return _ht.Begin();}iterator end() {return _ht.End();}pair<iterator, bool> insert(const pair<K, V>& kv) {return _ht.Insert(kv);}V& operator[](const K& key) {pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));return ret.first->second;}iterator find(const K& key) {return _ht.Find(key);}bool erase(const K& key) {return _ht.Erase(key);}private:hash_bucket::HashTable<K, pair<const K, V>, MapKeyOfT, Hash> _ht;};void test_unordered_map() {unordered_map<string, string> dict;dict.insert({"sort", "排序"});dict.insert({"left", "左边"});dict.insert({"right", "右边"});dict["left"] = "左边,剩余";dict["insert"] = "插入";for (auto it = dict.begin(); it != dict.end(); ++it) {// it->first 是const,不能修改// it->second 可以修改it->second += "x";cout << it->first << ": " << it->second << endl;}}
}
总结
核心知识点总结
-
性能优势:unordered_map/unordered_set在平均情况下提供O(1)的查找、插入、删除性能,在大多数场景下优于map/set的O(logN)性能。
-
数据结构选择:
- 需要有序遍历:选择map/set
- 追求极致性能:选择unordered_map/unordered_set
- 内存敏感:根据实际情况测试选择
-
哈希表设计要点:
- 优秀的哈希函数减少冲突
- 合理的负载因子控制空间效率
- 合适的冲突解决策略
-
工程实践:
- 预分配空间(reserve)提升性能
- 自定义哈希函数优化特定类型
- 监控负载因子避免性能退化
实际应用建议
-
字符串处理:unordered_map<string, T>在词频统计、缓存实现等场景表现优异。
-
大数据处理:在需要快速查找的海量数据场景中,哈希表的O(1)平均复杂度优势明显。
-
缓存系统:LRU Cache等缓存系统通常基于哈希表+链表的组合实现。
哈希表作为计算机科学中最重要的数据结构之一,其思想和应用贯穿于各个领域。通过深入理解其原理和实现,我们不仅能够更好地使用STL提供的容器,还能够在需要时自己实现特定优化的哈希结构,解决实际问题。
