跳到主要内容
C++ 哈希表:概念、冲突解决与代码实现 | 极客日志
C++ 算法
C++ 哈希表:概念、冲突解决与代码实现 综述由AI生成 C++ 中基于哈希表的无序容器 unordered_set 和 unordered_map。阐述了哈希概念、哈希函数设计(除法散列、乘法散列)、负载因子及哈希冲突处理。重点讲解了两种冲突解决策略:开放定址法(线性探测)和链地址法(哈希桶),并提供了相应的 C++ 类实现框架及插入、查找、删除操作代码示例。
不知所云 发布于 2026/3/28 更新于 2026/5/30 32 浏览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 ,
class Hash = hash<Key>,
class Pred = equal_to<Key>,
class Alloc = allocator<Key>
>
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 ,
class T ,
class Hash = hash<Key>,
Pred = equal_to<Key>,
Alloc = allocator< pair< Key,T> >
>
unordered_map;
class
class
const
class
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 个空间 ,实际上很难做到,但是要可能往这方面设计。
哈希函数设置为:hash(key) = key % capacity,capacity 为存储空间的总大小。
直接定址法 直接定址法适合关键字的范围比较集中时实现哈希表 ,比如一组关键字在 [0, 49] 之间,那么我们开个大小为 50 个数的数组,每个关键字直接对应着数组下标,再比如一组关键字在 [a, z] 的小写字母,那就开个 26 个数的数组,每个关键字的 ASCII 码就是数组下标。这个方法可以在解决 OJ 上的一些问题。
直接定址法也有缺点,那就是当数据范围比较分散且上下限差距较大时 ,开的空间比较大 ,会浪费内存 。
哈希冲突 假如往哈希表里插入元素 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);
}
};
template <>
struct HashFunc <string> {
size_t operator () (const string& s) {
size_t hash = 0 ;
for (auto ch : s) {
hash += ch;
hash *= 131 ;
}
return hash;
}
};
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;
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;
size_t _n;
};
哈希表的插入 **1.扩容函数:**当负载因子大于等于 0.7 时需要扩容,如果采用旧表映射到新表的方法,这样的逻辑跟 Insert 函数太重复了,有点浪费,我们可以直接在函数栈帧建立一个新表复用 Insert 函数,最后交换两个 tables 就可以完成扩容了。
**2.线性探测:**通过 key 计算得到 hashi 后,还不能直接往其插入,得判断元素状态。如果 hashi 位置的状态为 EXIST,则不能往该位置插入,因为被其他元素占有了,得线性探测往后寻找空位置。
void CheckCapacity () {
if (_n * 10 / _tables.size () >= 7 ) {
HashTable<K, V> newht;
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) {
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);
}
};
template <>
struct HashFunc <string> {
size_t operator () (const string& s) {
size_t hash = 0 ;
for (auto ch : s) {
hash += ch;
hash *= 131 ;
}
return hash;
}
};
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;
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 ()) {
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 );
}
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online