跳到主要内容
C++ 哈希表详解:开散列与闭散列 | 极客日志
C++ 算法
C++ 哈希表详解:开散列与闭散列 综述由AI生成 C++ 中哈希表的核心概念,包括哈希函数设计(除法、乘法、全域散列)、负载因子及哈希冲突处理。重点讲解了两种解决冲突的方法:开放定址法(闭散列,含线性探测、二次探测、双重散列)和链地址法(开散列/哈希桶)。文章提供了闭散列与开散列的完整 C++ 代码实现,涵盖插入、查找、删除、扩容等关键操作,并讨论了素数表扩容策略及仿函数在类型转换中的应用。
芝士奶盖 发布于 2026/3/27 更新于 2026/6/1 35 浏览C++ 哈希表详解
1. 哈希的概念
哈希 (hash) 又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 Key 存储的位置,进行快速查找。
1.1 直接定址法
当关键字的范围比较集中时,直接定址法就是非常简单高效的方法。比如一组关键字都在 [0,99] 之间,那么我们开一个 100 个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在 [a,z] 的小写字母,那么我们开一个 26 个数的数组,每个关键字 ascii 码 -a ascii 码就是存储位置的下标。
也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
直接定址法的缺点也非常明显:当关键字的范围比较分散时,就很浪费内存甚至内存不够用。
假设我们只有数据范围是 [0, 9999] 的 N 个值,我们要映射到一个 M 个空间的数组中 (一般情况下 M >= N),那么就要借助哈希函数 (hash function) hf,关键字 key 被放到数组的 h(key) 位置,这里要注意的是 h(key) 计算出的值必须在 [0, M) 之间。
1.2 哈希冲突
这里存在的一个问题就是,两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。
理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。
1.3 负载因子
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么 负载因子 = N/M(M 分之 N),负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
负载因子的大小最好是 <=0.7。
1.4 哈希函数
一个好的哈希函数应该让 N 个关键字被等概率的均匀的散列分布到哈希表的 M 个空间中,但是实际中却很难做到,但是我们要尽量往这个方向去考量设计。
1.4.1 除法散列法/除留余数法
除法散列法也叫做除留余数法,顾名思义,假设哈希表的空间大小为 M,那么通过 Key%M
k*ey(数据个数) 除以 M(表的空间大小)得到的余数作为映射位置的下标。
也就是哈希函数为:h(key) = key % M
当使用除法散列法时,要避免 M 为某些值,如 2 的冥,10 的冥等。
如果是 2X,那么 key % 本质相当于保留 key 的后 X 位,那么后 x 位相同的值,计算出的哈希值都是一样的,就冲突了。
如:{63 , 31} 看起来没有关联的值,如果 M 是 16,也就是 2^4,那么计算出的哈希值都是 15,因为 63 的二进制后 8 位是 00111111,31 的二进制后 8 位是 00011111。如果是 10X,就更明显了,保留的都是 10 进值的后 x 位,如:{112, 12312},如果 M 是 100,也就是 10^2,那么计算出的哈希值都是 12。
当使用除法散列法时,建议 M 取不太接近 2 的整数次幂的一个质数 (素数)。
1.4.2 乘法散列法
乘法散列法对哈希表大小 M 没有要求,他的大思路第一步:
a. 用关键字 K 乘上常数 A (0<A<1),并抽取出 kA 的小数部分
b. 再用 M 乘以 k A 的小数部分,再向下取整
本质就是用 M*(0~1) 之间的小数
h(key) = floor(M × ((A × key)%1.0)) ,其中 floor 表示对表达式进行下取整,A∈(0,1),这里最重要的是 A 的值应该如何设定,Knuth 认为 A = (√5 − 1)/2 = 0.6180339887.... (黄金分割点) 比较好
乘法散列法对哈希表大小 M 是没有要求的,假设 M 为 1024,key 为 1234,A = 0.6180339887, Akey= 762.6539420558,取小数部分为 0.6539420558, M×((A×key)%1.0) = 0.6539420558 1024 =669.6366651392,那么 h(1234) = 669
1.4.3 全域散列法
如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集。
比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
hab (key) = ((a × key + b)%P)%M,P 需要选一个足够大的质数,a 可以随机选 [1,P-1] 之间的任意整数,b 可以随机选 [0,P-1] 之间的任意整数,这些函数构成了一个 P*(P-1) 组全域散列函数组。
假设 P=17,M=6,a = 3,b = 4, 则 h34 (8) = ((3 × 8 + 4)%17)%6 = 5
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了。
1.5 处理哈希冲突 实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种方法,开放定址法和链地址法。
1.5.1 开放定址法(闭散列) 闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把 key 存放到冲突位置中的下一个'空位置中去。
在开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于 1 的。这里的规则有三种:线性探测、二次探测、双重探测。
1. 线性探测(挨着查找)
从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置(回绕方法就是进行取模)。
h(key) = hash0 = key % M , hash0 位置冲突了,则线性探测公式为:
hc(key, i) = hashi = (hash0 + i) % M,i = {1, 2, 3, ..., M − 1},因为负载因子小于 1,则最多探测 M-1 次,一定能找到一个存储 key 的位置。
线性探测的比较简单且容易实现,线性探测的问题假设,hash0 位置连续冲突,hash0,hash1,hash2 位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3 的值都会争夺 hash3 位置,这种现象叫做群集/堆积,下面的二次探测可以一定程度改善这个问题。
演示 {19,30,5,36,13,20,21,12} 等这一组值映射到 M=11 的表中(key%11)
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1
2. 二次探测(跳跃着查找)
从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。
h(key) = hash0 = key % M , hash0 位置冲突了,则二次探测公式为:
hc(key, i) = hashi = (hash0 ± i * i) % M,i = {1, 2, 3, ..., M/2(二分之 M)}
二次探测当 hashi = (hash0 − i )%M 时,当 hashi<0 时,需要 hashi += M。
演示 {19,30,52,63,11,22} 等这一组值映射到 M=11 的表中
h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0
3. 双重散列
第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
h1 (key) = hash0 = key % M , hash0 位置冲突了,则双重探测公式为:
hc(key, i) = hashi = (hash0 + i * h2 (key)) % M,i = {1, 2, 3, ..., M}
也跳跃着查找,但是使用 i*下一个哈希函数算出来的值。
要求 h2 (key) < M 且 h2 (key) 和 M 互为质数,有两种简单的取值方法:
a. 当 M 为 2 整数幂时,h2 (key) 从 [0,M-1] 任选一个奇数
b. 当 M 为质数时,h2 (key) = key % (M − 1) + 1
保证 h2 (key) 与 M 互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数说无法充分利用整个散列表。
举例来说,若初始探查位置为 1,偏移量为 3,整个散列表大小为 12,那么所能寻址的位置为 {1, 4, 7, 10},寻址个数为 p = gcd(M, h1 (key)) > 1,那么所能寻址的位置的个数为 M/P < M,使得对于一个关键字来 12/gcd(12, 3) = 4。
演示 {19,30,52} 等这一组值映射到 M=11 的表中,设 h2 (key) = key%10 + 1
上面的三种方法都无法完全解决哈希冲突的问题,只有跳出内卷循环才能解决问题,也就是链地址法。
2. 闭散列实现哈希表
2.1 开发地址法的基础构架 开放定址法在实践中,不如下面的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的是哈希表中的空间,始终存在互相影响的问题。
enum State { EXIST,
template <class K , class V >
struct HashData {
pair<K, V> _kv;
State _state = EMPTY;
};
template <class K , class V >
class HashTable {
public :
private :
vector<HashData<K, V>> _tables;
size_t _n;
};
哈希是通过哈希函数使得元素的存储位置与它的关键码之间能够建立一一映射的关系,需要使用 pair<K,V> 类型进行存储。采用 vector 作为底层逻辑,存储元素类型为哈希节点类型 HashData<K, V>。
这里不采用 size 作为哈希表中有效元素个数,考虑到容器中结构的差异性,是由于_ size 一般用于序列式容器中表示有效元素个数,在关联式容器中命名约定一般规定_n 作为记录有效元素个数。
要注意的是这里需要给每个存储值的位置加一个状态标识,否则删除一些值以后,会影响后面冲突的值的查找。
如下图,我们删除 30,会导致查找 20 失败,当我们给每个位置加一个状态标识 {EXIST,EMPTY,DELETE},删除 30 就可以不用删除值,而是把状态改为 DELETE,那么查找 20 时是遇到 EMPTY 才能,就可以找到 20。
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1
2.2 扩容 这里我们哈希表负载因子控制在 0.7,当负载因子到 0.7 以后我们就需要扩容了,我们还是按照 2 倍扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2 倍后就不是质数了。那么如何解决了,一种方案就是上面 1.4.1 除法散列中我们讲的 Java HashMap 的使用 2 的整数幂,但是计算时不能直接取模的改进方法。另外一种方案是 sgi 版本的哈希表使用的方法,给了一个近似 2 倍的质数表,每次去质数表获取扩容后的大小。
负载因子 >= 0.7 扩容 n/m 数据个数/表的空间大小。
当哈希表进行扩容时,表的长度发生了变换。这也意味着通过哈希函数 (开发定址法) 得到的位置需要重新安排插入,所以需要再开辟空间和插入数据,重新进行映射到新表中,遍历旧表,将旧表的数据映射到新表,然后再使用新对象去调用插入,把旧表的数据插入到新表,交换新旧表的空间。
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;
}
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);
}
2.3 插入 在插入过程,元素通过除留余数法找到对应位置进行插入,期间可能会出现哈希冲突的问题,我们需要以该位置向后寻找状态标记为空的位置进行插入。
bool Insert (const pair<K, V>& kv) {
if (Find (kv.first)) return false ;
Hash hash;
size_t hash0 = hash (kv.first) % _tables.size ();
size_t hashi = hash0;
size_t i = 1 ;
int flag = 1 ;
while (_tables[hashi]._state == EXIST)
{
hashi = (hash0 + i) % _tables.size ();
++i;
}
_tables[hashi].kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true ;
}
2.4 查找 HashData<K, V>* Find (const K& key) {
Hash hash;
size_t hash0 = hash (key) % _tables.size ();
size_t hashi = hash0;
size_t i = 1 ;
while (_tables[hashi]._state != EMPTY) {
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key) {
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size ();
++i;
}
return nullptr ;
}
2.5 删除 bool Erase (const K& key) {
HashData<K, V>* ret = Find (key);
if (ret) {
ret->_state = DELETE;
return true ;
} else {
return false ;
}
}
2.6 闭散列代码
enum State { EXIST,
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;
}
namespace open_address
{
template <class K , class V , class Hash = HashFunc<K>>
class HashTable {
public :
HashTable () :_tables(__stl_next_prime(0 ))
, _n(0 )
bool Insert (const pair<K, V>& kv) {
if (Find (kv.first)) return false ;
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);
}
Hash hash;
size_t hash0 = hash (kv.first) % _tables.size ();
size_t hashi = hash0;
size_t i = 1 ;
int flag = 1 ;
while (_tables[hashi]._state == EXIST)
{
hashi = (hash0 + 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 hash0 = hash (key) % _tables.size ();
size_t hashi = hash0;
size_t i = 1 ;
while (_tables[hashi]._state != EMPTY) {
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key) {
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size ();
++i;
}
return nullptr ;
}
bool Erase (const K& key) {
auto * ret = Find (key);
if (ret) {
ret->_state = DELETE;
return true ;
}
return false ;
}
private :
vector<HashData<K, V>> _tables;
size_t _n;
};
}
3. key 不能取模的问题 当 key 是 string/Date 等类型时,key 不能取模,那么我们需要给 HashTable 增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整形。
如果 key 可以转换为整形并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个 Key 不能转换为整形,我们就需要自己实现一个仿函数传给这个参数,实现这个仿函数的要求就是尽量 key 的每值都参与到计算中,让不同的 key 转换出的整形值不同。
string 做哈希表的 key 非常常见,所以我们可以考虑把 string 特化一下。
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;
}
};
4. 链地址法(开散列/哈希桶) 解决冲突的思路
开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。
演示 {19,30,5,36,13,20,21,12,24,96} 等这一组值映射到 M=11 的表中
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1,h(24) = 2,h(96) = 88
4.1 链地址法的基础框架 namespace hash_bucket
{
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(11 ), _n(0 ) {}
private :
vector<Node*> _tables;
size_t _n = 0 ;
};
}
4.2 插入 bool Insert (const pair<K, V>& kv) {
Hash hs;
size_t hashi = kv.first % _tables.size ();
Node* newnode = new Node (kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true ;
}
4.3 扩容 开放定址法负载因子必须小于 1,链地址法的负载因子就没有限制了,可以大于 1。
负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
if (_n == _tables.size ()) {
vector<Node*> newTatble (_tables.size() * 2 ) ;
for (size_t i = 0 ; i < _tables.size (); i++) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newTatble.size ();
cur->_next = newTatble[hashi];
newTatble[hashi] = cur;
cur = next;
}
_tables[i] = nullptr ;
}
_tables.swap (newTatble);
}
4.4 查找 Node* Find (const K& key) {
Hash hash;
size_t hashi = hash (key) % _tables.size ();
Node* cur = _tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
return &cur->_kv.first;
} else {
cur = cur->_next;
}
}
return nullptr ;
}
4.5 删除 两种情况:一种是删除第一个节点,另一种是删除其他节点 prev->_next = cur->_next。
在删除节点需要前后兼顾,保存下前驱指针指向节点。
bool Erase (const K& key) {
Hash hash;
size_t hashi = hash (key) % _tables.size ();
Node* cur = _tables[hashi];
Node* prev = nullptr ;
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr ) {
_tables[hashi] = cur->_next;
} else {
prev->_next = cur->_next;
}
delete cur;
return true ;
} else {
prev = cur;
cur = cur->_next;
}
}
return false ;
}
4.6 开散列代码 namespace hash_bucket
{
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(11 ), _n(0 ) {}
bool Insert (const pair<K, V>& kv) {
if (_n == _tables.size ()) {
vector<Node*> newTatble (_tables.size() * 2 ) ;
for (size_t i = 0 ; i < _tables.size (); i++) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newTatble.size ();
cur->_next = newTatble[hashi];
newTatble[hashi] = cur;
cur = next;
}
_tables[i] = nullptr ;
}
_tables.swap (newTatble);
}
size_t hashi = kv.first % _tables.size ();
Node* newnode = new Node (kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true ;
}
Node* Find (const K& key) {
Hash hash;
size_t hashi = hash (key) % _tables.size ();
Node* cur = _tables[hashi];
while (cur) {
if (cur->_kv.first == key) {
return &cur->_kv.first;
} else {
cur = cur->_next;
}
}
return nullptr ;
}
bool Erase (const K& key) {
Hash hash;
size_t hashi = hash (key) % _tables.size ();
Node* cur = _tables[hashi];
Node* prev = nullptr ;
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr ) {
_tables[hashi] = cur->_next;
} else {
prev->_next = cur->_next;
}
delete cur;
return true ;
} else {
prev = cur;
cur = cur->_next;
}
}
return false ;
}
private :
vector<Node*> _tables;
size_t _n = 0 ;
};
}
相关免费在线工具 加密/解密文本 使用加密算法(如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