目录
[1.1 什么是哈希](#1.1 什么是哈希)
[1.2 直接定址法与优缺点](#1.2 直接定址法与优缺点)
[2.1 负载因子](#2.1 负载因子)
[2.2 哈希函数的设计和实现](#2.2 哈希函数的设计和实现)
[2.2.1 除法散列法](#2.2.1 除法散列法)
[2.2.2 乘法散列法](#2.2.2 乘法散列法)
[2.2.3 全域散列法](#2.2.3 全域散列法)
[2.2.4 关键字转为整数](#2.2.4 关键字转为整数)
[2.3 哈希冲突的必然性](#2.3 哈希冲突的必然性)
[3.1 开放定址法](#3.1 开放定址法)
[3.1.1 线性探测](#3.1.1 线性探测)
[3.1.2 二次探测](#3.1.2 二次探测)
[3.1.3 双重散列](#3.1.3 双重散列)
[3.1.4 伪删除](#3.1.4 伪删除)
[3.1.5 链地址法扩容](#3.1.5 链地址法扩容)
[四、C++ 完整工程实现](#四、C++ 完整工程实现)
[4.1 哈希函数仿函数](#4.1 哈希函数仿函数)
[4.2 开放定址法哈希表实现](#4.2 开放定址法哈希表实现)
[4.3 链地址法哈希表实现](#4.3 链地址法哈希表实现)
[4.4 功能测试代码](#4.4 功能测试代码)
[4.5 处理非整数Key](#4.5 处理非整数Key)
[5.1 主流语言标准库的哈希表实现对比](#5.1 主流语言标准库的哈希表实现对比)
[5.2 开放定址法的工业级优化:Swiss Table](#5.2 开放定址法的工业级优化:Swiss Table)
[5.3 哈希洪水攻击与防御](#5.3 哈希洪水攻击与防御)
[5.4 其他哈希冲突解决方案](#5.4 其他哈希冲突解决方案)
本文系统拆解哈希表的底层逻辑,从核心概念、哈希函数设计、冲突解决,到 C++ 完整工程化实现,同时扩展工业级哈希表的优化方案,帮助读者从原理到实践彻底掌握哈希表。
前言
在数据结构的世界中,哈希表(Hash Table) 是一种根据关键码直接访问数据的结构,它通过哈希函数建立关键码与存储位置的映射,实现了平均 O (1) 时间复杂度的插入、查找、删除操作,性能远超数组、链表、二叉搜索树等传统数据结构。
无论是 C++ STL 中的 unordered_map/unordered_set、Java 中的 HashMap/HashSet,还是 Redis 中的字典、Python 中的 dict,其底层核心都依赖哈希表实现。本文将基于哈希表的核心理论,结合工程实践,深入剖析哈希表的核心机制。
一、哈希表的核心概念与基础思想
1.1 什么是哈希
哈希(散列)的核心本质,是通过一个哈希函数 h(key) ,将关键字 Key 映射到一段连续存储空间(通常是数组)的下标,从而实现通过 Key直接定位存储地址。
我们通过与经典数据结构的对比,直观理解哈希表的优势:
| 数据结构 | 插入平均复杂度 | 查找平均复杂度 | 删除平均复杂度 | 核心特点 |
|---|---|---|---|---|
| 数组 | O(n) | O (1)(下标) | O(n) | 下标随机访问,关键字无法定位 |
| 单链表 | O(1) | O(n) | O(n) | 插入高效,查找需遍历 |
| 二叉搜索树 | O(logn) | O(logn) | O(logn) | 有序存储,最坏退化为 O (n) |
| 哈希表 | O(1) | O(1) | O(1) | 关键字直接映射,空间换时间 |
1.2 直接定址法与优缺点
直接定址法是最简单的哈希实现,其哈希函数为:或++直接通过关键字本身计算出数组的下标,无需复杂转换。++
适用场景
关键字的取值范围++非常集中、跨度极小++,不会造成内存浪费。
-
示例 1:关键字范围为
[0,99],直接开辟 100 个元素的数组,key 值直接作为数组下标。 -
示例 2:小写英文字母
a-z,开辟 26 个元素的数组,h(key) = key - 'a',将 ASCII 码映射到 0-25 的下标。
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
该题是直接定址法的典型落地,通过字母与数组下标的直接映射,实现 O (n) 时间复杂度、O (1) 空间复杂度的解法:
cpp
class Solution {
public:
int firstUniqChar(string s) {
// 26个小写字母,直接定址映射到count数组
int count[26] = {0};
// 统计每个字符出现次数
for(auto ch : s)
{
count[ch-'a']++;
}
// 找到第一个出现次数为1的字符
for(size_t i = 0; i < s.size(); ++i)
{
if(count[s[i]-'a'] == 1)
return i;
}
return -1;
}
};
二、哈希表的三大核心要素
2.1 负载因子
负载因子是哈希表设计中最核心的权衡指标,标准定义公式 为:
N:哈希表中已存储的元素个数
M:哈希表的总空间大小
负载因子的**++核心特性++**:
- α 越大,哈希表越满,哈希冲突的概率越高,空间利用率越高
- α 越小,哈希表越空,哈希冲突的概率越低,空间利用率越低
我们通过算法导论中的经典公式,量化不同负载因子下,哈希表的平均查找长度(探测次数):
| 负载因子α | 线性探测成功查找平均次数 | 线性探测失败查找平均次数 | 链地址法成功查找平均次数 | 链地址法失败查找平均次数 |
|---|---|---|---|---|
| 0.2 | 1.13 | 1.28 | 1.10 | 0.38 |
| 0.5 | 1.50 | 2.50 | 1.25 | 0.61 |
| 0.7 | 2.17 | 6.06 | 1.35 | 0.79 |
| 1.0 | ∞(理论上无法插入) | ∞ | 1.50 | 1.37 |
| 2.0 | - | - | 2.00 | 2.14 |
工程设计核心结论:
-
开放定址法的负载因子必须小于 1 ,否则会出现无法找到空位置的情况,工程中通常控制在 0.7 以内(临界值)
-
链地址法的负载因子可以大于 1,工程中通常控制在 1 以内(C++ STL、Java HashMap 默认阈值为 1),超过则触发扩容
2.2 哈希函数的设计和实现
一个优秀的哈希函数必须满足两个核心要求:(++计算简单与++ ++分布均匀++)
-
计算简单高效,避免过度消耗 CPU
-
哈希值均匀分布,将 N 个关键字等概率映射到 M 个空间中,最大限度减少冲突
同时,哈希函数的输出必须落在[0, M)的范围内,非整数类型的关键字需要先转换为整数再进行哈希计算。
2.2.1 除法散列法
工程中最常用的哈希函数,公式为:h(key)=key%M 其中 M 为哈希表的总空间大小。
核心设计要点:
-
强烈建议 M 取++不接近 2 的整数次幂的质数++。 原因:若 M 为 2 的整数次幂,++
key%M++等价于取 key 的二进制后 X 位,高位完全不参与计算,冲突概率大幅上升;而质数的因数只有 1 和自身,能最大限度让 key 的所有位参与计算,保证哈希值均匀分布。 -
工业界灵活优化 :Java HashMap 的 M 固定为 2 的整数次幂,通过++
key ^ (key >> 16)++ 让高位也参与哈希计算,再用位运算key & (M-1)代替取模(位运算性能远高于取模),兼顾性能和分布均匀性。
需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是以2的整数次幂做哈希表的大小M,这样算的话,就不用取模,而可以直接位运算,相对而言位运算比取模更高效一些。但是它不是单纯地去取模,比如M是2^16次方,本质是取后16位,那么用key' = key>>16,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。
所以我们上面建议M取不太接近2的整数次幂的一个质数的理论,是大多数数据结构书籍中写的理论吗?但是实践中,灵活运用,抓住本质,而不能死读书。
2.2.2 乘法散列法
乘法散列法对 M 的取值无要求,公式:h(key) = floor(M * ((A * key) % 1.0)),其中A通常取黄金分割数0.618。此法对M无特殊要求。
++经典最优取值:算法导论作者 Knuth 推荐 A 取黄金分割点 ≈0.6180339887,此时哈希值分布最均匀。++
-
优点:对 M 无严格要求,无需取质数,适合 M 固定为 2 的整数次幂的场景
-
缺点:计算复杂度略高于除留余数法,工程中使用较少
2.2.3 全域散列法
解决场景:恶意攻击者针对固定哈希函数,构造大量哈希值相同的关键字,导致哈希表退化为 O (n) 的查找性能(哈希洪水攻击)。
核心思想:每次初始化哈希表时,从一组哈希函数族中随机选择一个哈希函数使用,后续增删查改固定使用该函数。攻击者无法提前预知哈希函数,无法构造冲突数据集。
经典全域散列函数公式:其中:hab(key) = ((a*key + b) % P) % M
-
P 为一个远大于关键字范围的大质数
-
a∈[1,P−1],b∈[0,P−1],每次初始化随机生成
-
M 为哈希表大小
2.2.4 关键字转为整数
哈希计算的前提是关键字可转换为整数,工程中最常见的就是字符串类型的 key。
-
朴素思路 :将字符串的每个字符 ASCII 码相加求和。但存在严重缺陷:如
"abcd"和"bcad"的求和结果完全相同,极易冲突。 -
工程最优方案 :++BKDR 哈希算法++,核心思路是通过迭代乘法,让每个字符的位置和值都参与哈希计算,最大限度减少冲突,公式为:
质数
当前字符ASCII码,其中质数通常取 31、131、1313 等,经大量实践,这些值的抗冲突效果最优。
2.3 哈希冲突的必然性
哈希冲突(哈希碰撞) :两个不同的关键字key1key2,经过哈希函数计算后得到相同的哈希值h(key1)=h(key2),导致两个 key 需要映射到同一个存储位置。
核心结论:无论哈希函数设计得多优秀,哈希冲突都无法完全避免。原因:根据鸽巢原理,当关键字的取值范围远大于哈希表的空间 M 时,必然存在多个 key 映射到同一个位置的情况。
因此,哈希表的设计核心,除了优秀的哈希函数,更重要的是高效的哈希冲突解决方案。
三、哈希冲突的两大经典解决方案
3.1 开放定址法
核心思想:所有元素都存储在哈希表本身的数组空间中,当发生哈希冲突时,按照既定规则探测哈希表中的其他空位置,直到找到可存储的位置为止。
核心特点:
-
无需额外的指针 / 链表结构,空间利用率高,缓存友好性好
-
负载因子必须严格小于 1,否则无法找到空位置
-
删除操作不能直接清空元素,需要使用伪删除(标记删除),否则会导致后续元素查找失败
根据探测规则的不同,开放定址法分为三类:
3.1.1 线性探测
线性探测是开放定址法中最简单的实现,核心规则:从冲突位置开始,依次向后线性遍历,直到找到空位置;到达表尾则回绕到表头。
-
公式 :
hashi = (hash0 + i) % M,i = 1,2,3... -
过程:若hash0被占,依次探查hash0+1,hash0+2...
-
缺点 :容易产生++一次聚集++,即冲突的键堆积连续,导致后续映射到该区域的值不断向后延伸。
插入演示
将关键字集合{19,30,5,36,13,20,21,12}插入到M=11的哈希表中,插入过程如下:
| 插入顺序 | 关键字 | 初始哈希值 | 冲突情况 | 探测次数 | 最终存储下标 | 哈希表状态(下标 0-10) |
|---|---|---|---|---|---|---|
| 1 | 19 | 8 | 无 | 0 | 8 | [空,空,空,空,空,空,空,空,19, 空,空] |
| 2 | 30 | 8 | 有 | 1 | 9 | [空,空,空,空,空,空,空,空,19,30, 空] |
| 3 | 5 | 5 | 无 | 0 | 5 | [空,空,空,空,空,5, 空,空,19,30, 空] |
| 4 | 36 | 3 | 无 | 0 | 3 | [空,空,空,36, 空,5, 空,空,19,30, 空] |
| 5 | 13 | 2 | 无 | 0 | 2 | [空,空,13,36, 空,5, 空,空,19,30, 空] |
| 6 | 20 | 9 | 有 | 1 | 10 | [空,空,13,36, 空,5, 空,空,19,30,20] |
| 7 | 21 | 10 | 有 | 1 | 0 | [21, 空,13,36, 空,5, 空,空,19,30,20] |
| 8 | 12 | 1 | 无 | 0 | 1 | [21,12,13,36, 空,5, 空,空,19,30,20] |


优缺点
-
优点:实现极其简单,无需复杂计算,内存连续,CPU 缓存命中率高
-
缺点:存在 ** 一次群集(Primary Clustering)** 问题:一旦某个位置发生冲突,后续所有映射到该位置、以及后续连续位置的 key,都会加剧冲突,导致探测长度急剧增加,性能快速下降。
3.1.2 二次探测
二次探测是为了解决线性探测的一次群集问题,核心规则:从冲突位置开始,按照二次方的步长进行跳跃式探测。
-
公式 :
hashi = (hash0 ± i²) % M,i = 1,2,3... -
优势:跳跃式探查,缓解了堆积问题,但若M不是质数,可能探查不到空位置
-
缺点:存在 ** 二次群集(Secondary Clustering)** 问题:所有初始哈希值相同的 key,都会遵循完全相同的探测序列,依然会产生群集现象。
插入演示
{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.1.3 双重散列
双重散列是开放定址法中性能最优、冲突概率最低的方案,核心规则:使用两个不同的哈希函数,第一个计算初始哈希值,第二个计算探测步长。
公式:hashi=(hash0+i×h2(key))%M其中i=1,2,...,M,hash0=h1(key)%M为初始哈希值,h2(key)为第二个哈希函数。
核心要求:h2(key)必须与 M 互质,否则会出现无法遍历整个哈希表的情况。常用实现:
-
当 M 为质数时,h2(key)=key%(M−1)+1
-
当 M 为 2 的整数次幂时,h2(key)取
[0,M-1]之间的任意奇数 -
优点:彻底解决了群集问题,不同 key 即使初始哈希值相同,探测序列也完全不同,冲突概率最低
-
缺点:实现复杂,需要设计两个优秀的哈希函数,工程中使用较少
插入演示
{19,30,52,74} 等这一组值映射到M=11的表中,设 h2(key) = key%10 + 1

3.1.4 伪删除
开放定址法中,不能直接删除元素并清空位置。
原因:删除元素后,该位置变为空,后续查找冲突链上的其他元素时,会遇到空位置提前终止,导致查找失败。
解决方案:为每个存储位置增加状态标记,分为三种:
-
EXIST:该位置存在有效数据
-
EMPTY:该位置为空,可插入数据,查找时遇到该位置可终止
-
DELETE:该位置的数据已被删除,查找时可跳过继续探测,插入时可当作空位置使用
插入示例
将关键字集合{19,30,5,36,13,20,21,12,24,96}插入到M=11的哈希表中,最终哈希桶结构如下:
| 桶下标 | 链表内容 |
|---|---|
| 0 | 空 |
| 1 | 12 |
| 2 | 13 -> 24 |
| 3 | 36 |
| 4 | 空 |
| 5 | 5 |
| 6 | 空 |
| 7 | 空 |
| 8 | 19 -> 30 -> 96 |
| 9 | 20 |
| 10 | 21 |
工业级优化
当单个桶的链表长度过长时,查找性能会退化为 O (n)。Java 1.8 HashMap中做了经典优化:当桶的链表长度超过 8,且哈希表总容量大于 64 时,将链表转换为红黑树,将最坏查找时间复杂度从 O (n) 优化到 O (logn),避免极端情况下的性能雪崩。
3.1.5 链地址法扩容
扩容时,旧表节点需重新计算映射到新表。有两种方式:
-
朴素法:创建新节点插入,浪费内存。
-
移动法:直接将旧节点摘下来,头插到新表对应桶(效率高,推荐)。
cpp
// 挪动旧表节点到新表
for (size_t i = 0; i < _tables.size(); i++) {
Node* cur = _tables[i];
while (cur) {
Node* next = cur->_next;
size_t hashi = hs(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
四、C++ 完整工程实现
基于上述原理,我们分别实现开放定址法(线性探测)和 链地址法 的哈希表,包含哈++希函数仿函数、质数表扩容、插入、查找、删除等完整功能,代码可直接编译运行++。
4.1 哈希函数仿函数
首先实现通用的哈希函数仿函数,并对 string 类型进行特化,采用++BKDR 哈希算法++:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
// 通用哈希函数仿函数
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
// 基础类型直接转换为size_t
return static_cast<size_t>(key);
}
};
// string类型特化,BKDR哈希算法
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
4.2 开放定址法哈希表实现
cpp
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
{
public:
HashTable()
{
// 初始化哈希表,初始大小为质数表第一个值53
_tables.resize(__stl_next_prime(0));
}
// 插入元素
bool Insert(const pair<K, V>& kv)
{
// 若key已存在,插入失败
if (Find(kv.first))
return false;
// 负载因子>=0.7时触发扩容
if (_n * 10 / _tables.size() >= 7)
{
// 创建新哈希表,扩容到下一个质数
HashTable<K, V, Hash> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size()));
// 将旧表数据重新插入新表
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
// 交换新旧表数据
_tables.swap(newHT._tables);
}
Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 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)
{
// 找到有效数据且key匹配
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)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
return false;
// 伪删除,仅修改状态
ret->_state = DELETE;
--_n;
return true;
}
private:
// 获取下一个质数,参考SGI STL实现
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;
// 找到第一个大于等于n的质数
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
private:
vector<HashData<K, V>> _tables; // 哈希表数组
size_t _n = 0; // 已存储的有效元素个数
};
}
4.3 链地址法哈希表实现
cpp
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()
{
// 初始化哈希表,初始大小为质数表第一个值53
_tables.resize(__stl_next_prime(0), nullptr);
}
// 析构函数,释放所有节点
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
// 插入元素(头插法)
bool Insert(const pair<K, V>& kv)
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
// 若key已存在,插入失败
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == kv.first)
return false;
cur = cur->_next;
}
// 负载因子==1时触发扩容
if (_n == _tables.size())
{
// 扩容到下一个质数,直接移动旧节点,避免重新申请内存
vector<Node*> newTables(__stl_next_prime(_tables.size()), nullptr);
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 重新计算新表中的哈希位置
size_t newHashi = hs(cur->_kv.first) % newTables.size();
// 头插到新表对应桶
cur->_next = newTables[newHashi];
newTables[newHashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
// 交换新旧表
_tables.swap(newTables);
// 重新计算当前key的哈希位置
hashi = hs(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 hs;
size_t hashi = hs(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 hs;
size_t hashi = hs(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;
}
prev = cur;
cur = cur->_next;
}
// 未找到元素,删除失败
return false;
}
private:
// 获取下一个质数,参考SGI STL实现
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;
}
private:
vector<Node*> _tables; // 哈希桶指针数组
size_t _n = 0; // 已存储的有效元素个数
};
}
4.4 功能测试代码
cpp
int main()
{
// 测试开放定址法哈希表
cout << "=== 开放定址法哈希表测试 ===" << endl;
open_address::HashTable<int, string> ht1;
ht1.Insert({1, "one"});
ht1.Insert({2, "two"});
ht1.Insert({54, "fifty-four"});
ht1.Insert({19, "nineteen"});
ht1.Insert({30, "thirty"});
auto ret1 = ht1.Find(19);
if (ret1) cout << "找到key=19,value=" << ret1->_kv.second << endl;
else cout << "未找到key=19" << endl;
ht1.Erase(30);
ret1 = ht1.Find(30);
if (ret1) cout << "找到key=30,value=" << ret1->_kv.second << endl;
else cout << "未找到key=30" << endl;
// 测试链地址法哈希表
cout << "\n=== 链地址法哈希表测试 ===" << endl;
hash_bucket::HashTable<string, int> ht2;
ht2.Insert({"apple", 5});
ht2.Insert({"banana", 3});
ht2.Insert({"orange", 7});
ht2.Insert({"pear", 2});
ht2.Insert({"apple", 10}); // 重复key,插入失败
auto ret2 = ht2.Find("banana");
if (ret2) cout << "找到key=banana,value=" << ret2->_kv.second << endl;
else cout << "未找到key=banana" << endl;
ht2.Erase("orange");
ret2 = ht2.Find("orange");
if (ret2) cout << "找到key=orange,value=" << ret2->_kv.second << endl;
else cout << "未找到key=orange" << endl;
return 0;
}
测试输出
bash
=== 开放定址法哈希表测试 ===
找到key=19,value=nineteen
未找到key=30
=== 链地址法哈希表测试 ===
找到key=banana,value=3
未找到key=orange
4.5 处理非整数Key
当Key为字符串或自定义类型,无法直接取模,需通过仿函数将其转换为整数。
cpp
template<class K>
struct HashFunc {
size_t operator()(const K& key) { return (size_t)key; }
};
// string特化 ------ BKDR哈希算法
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (char ch : key) {
hash = hash * 131 + ch; // 乘以质数,混合每位字符
}
return hash;
}
};
KDR算法通过乘以种子(如131, 1313...)使字符序列的细微差别扩散到整个哈希值,极大降低冲突。
五、哈希表的优化与进阶知识
5.1 主流语言标准库的哈希表实现对比
| 实现方案 | C++ STL unordered_map | Java 1.8 HashMap | Python dict | Google absl::flat_hash_map |
|---|---|---|---|---|
| 冲突解决方案 | 链地址法 | 链地址法 + 红黑树 | 开放定址法 | 开放定址法(Swiss Table) |
| 默认负载因子阈值 | 1.0 | 0.75 | 0.666 | 0.875 |
| 扩容规则 | 扩容到下一个质数 | 2 的整数次幂扩容 | 2 的整数次幂 | 2 的整数次幂扩容 |
| 缓存友好性 | 较差(链表节点分散) | 一般 | 优秀 | 极致优化 |
5.2 开放定址法的工业级优化:Swiss Table
Google Abseil 库中的 flat_hash_map 是目前工业界性能最优的哈希表实现之一,基于++Swiss Table技术++,核心优化点:
-
分组控制:将哈希表分为多个 8/16 字节的组,每个组对应一个控制字节,存储哈希值的高 8 位和状态标记,无需遍历整个数组即可快速判断是否存在目标 key,大幅减少内存访问次数。
-
SIMD 指令优化:利用 CPU 的 SIMD 指令,一次性并行比较一个组内的所有控制字节,查找速度提升数倍。
-
优秀的二次探测实现:最大限度减少缓存 miss,缓存命中率远超传统链地址法。
5.3 哈希洪水攻击与防御
哈希洪水攻击:攻击者针对固定哈希函数,构造大量哈希值完全相同的 key,使哈希表退化为链表,查找性能从 O (1) 降到 O (n),导致服务 CPU 占满,引发拒绝服务攻击。
防御方案:
-
全域散列法:每次初始化哈希表时随机选择哈希函数,攻击者无法提前构造冲突数据。
-
加盐哈希:在哈希计算时加入随机盐值,相同 key 在不同哈希表实例中哈希值不同。
-
链表转红黑树 / 跳表:即使发生大量冲突,最坏时间复杂度也能控制在 O (logn),避免性能雪崩。
5.4 其他哈希冲突解决方案
-
布谷鸟哈希(Cuckoo Hashing):使用两个哈希函数,每个 key 有两个可选存储位置,发生冲突时,将原有位置的元素 "踢" 到它的另一个可选位置,循环直到所有元素找到位置,最坏查找时间复杂度为 O (1),无链表,缓存友好。
-
完美哈希(Perfect Hashing):针对静态不变的关键字集合,设计无冲突的哈希函数,实现绝对 O (1) 的查找性能,适用于关键字固定不变的场景。
六、总结
哈希表的核心本质是空间换时间,通过哈希函数建立key 与地址的直接映射,实现了平均 O (1) 的增删查操作,是工程中使用最广泛的数据结构之一。
| 特性 | 开放定址法 | 链地址法 |
|---|---|---|
| 底层结构 | 数组 | 数组+链表/红黑树 |
| 负载因子 | α < 1(通常≤0.8) | α 可 ≥ 1 |
| 处理冲突 | 探测空位置 | 同位置链式存储 |
| 空间利用 | 较低(需预留空间) | 较高(动态分配) |
| 缓存友好 | 是(连续内存) | 否(链表节点分散) |
| 删除操作 | 需标记,复杂 | 直接链表删除,简单 |
| 适用场景 | 数据量可预估、稳定 | 动态数据、频繁插入删除 |
本文系统讲解了++哈希表++ 的核心原理:
-
哈希表的核心是哈希函数,优秀的哈希函数需要计算高效、分布均匀,工程中最常用除留余数法 + BKDR 字符串哈希。
-
负载因子是哈希表的核心权衡指标,决定了冲突概率和空间利用率,开放定址法通常控制在 0.7 以内,链地址法通常控制在 1 以内。
-
哈希冲突无法避免,两大经典解决方案:开放定址法和链地址法,各有优劣,分别适用于不同场景。
-
工业级哈希表需要在缓存友好性、扩容效率、抗攻击能力、极端场景性能等方面做大量优化,而非简单的原理实现。
通过本文的哈希表原理讲解和 C++ 完整实现,读者可以彻底掌握哈希表的底层逻辑,理解标准库哈希表的实现细节,在工程开发中更好地使用和优化哈希表。
参考文献
-
椰椰椰耶. 【数据结构】哈希表[EB/OL]. 腾讯云开发者社区, 2024-10-14.
-
阿里云开发者社区. 哈希冲突[EB/OL]. 2025-12-12.
-
百度百科. 哈希函数[EB/OL].
-
亿速云. Hashtable哈希表如何处理冲突[EB/OL]. 2025-05-17.
-
编程狮. C++哈希表 小结[EB/OL].
-
Oracle. Java Platform SE 24 API Specification: Hashtable[EB/OL]. 2025-07-13.
-
s1mba. 散列表(一):散列表概念、散列函数构造方法、常见字符串哈希函数[EB/OL]. 腾讯云开发者社区, 2020-10-22.
-
殷人昆. 数据结构:用面向对象方法与C++语言描述(第二版)[M]. 清华大学出版社.
-
Thomas H. Cormen 等. 算法导论(第三版)[M]. 机械工业出版社.