在数据结构中,哈希表(Hash Table)是一种兼顾时间效率与空间效率的存储结构,其核心优势是通过 “关键字 - 存储位置” 的直接映射,实现平均 O(1) 时间复杂度的增删查改。无论是 C++ STL 中的 unordered_map/unordered_set,还是 Java 中的 HashMap,底层都基于哈希表实现。本文将从哈希表的核心概念入手,详细拆解哈希函数设计、冲突解决策略,并通过完整 C++ 代码实现两种经典哈希表(开放定址法、链地址法),帮助开发者彻底掌握哈希表的底层逻辑。
一、哈希表核心概念
在正式讲解实现前,我们需要先理清哈希表的几个基础概念,这是理解后续内容的关键。
1.1 哈希的本质:“关键字 - 位置” 的直接映射
哈希表的核心思想是:通过一个哈希函数(Hash Function),将关键字(Key)转换为哈希表中的存储位置(下标),从而实现 “直接访问”。例如,若 Key 是整数 19,哈希函数为 “取模 11”,则存储位置为 19 % 11 = 8,直接将 19 存入下标为 8 的位置。
这种设计彻底摆脱了数组 “按索引访问”、链表 “顺序查找”、红黑树 “二分查找” 的局限,是实现高效数据操作的核心。
1.2 哈希冲突:无法避免的 “小插曲”
理想情况下,每个 Key 应通过哈希函数映射到唯一位置,但实际中不同 Key 可能映射到同一个位置,这种现象称为哈希冲突(Hash Collision)。例如,Key=19 和 Key=30,通过 “取模 11” 计算:
19 % 11 = 8
30 % 11 = 8
两者均映射到下标 8,产生冲突。冲突无法完全避免,但可通过 “优化哈希函数” 减少冲突概率,通过 “冲突解决策略” 处理已发生的冲突。
1.3 负载因子:平衡冲突与空间的 “调节器”
负载因子(Load Factor)是衡量哈希表 “拥挤程度” 的指标,计算公式为:负载因子=哈希表容量(M)已存储元素个数(N)
负载因子与哈希表性能直接相关:
负载因子越大:哈希表越拥挤,冲突概率越高,访问效率越低,但空间利用率高;
负载因子越小:冲突概率越低,访问效率越高,但空间浪费越严重。
实践中,不同冲突解决策略对应不同的负载因子阈值:
开放定址法:负载因子通常控制在 0.7 以下(需保证有空闲位置);
链地址法:负载因子可允许大于 1(冲突元素通过链表存储),STL 中默认阈值为 1。
二、哈希函数设计:如何让 Key “均匀分布”
哈希函数的核心目标是让 Key 尽可能均匀地映射到哈希表的各个位置,从而减少冲突。常用的哈希函数设计方法有以下几种,其中 “除法散列法” 是最经典且实用的方案。
2.1 直接定址法:简单但局限大
直接使用 Key 或 Key 的线性变换作为存储位置,例如:
若 Key 是 [0,99] 的整数,直接用 Key 作为下标;
若 Key 是小写字母,用 Key 的 ASCII 码 - 'a' 作为下标(如 'a'→0,'b'→1)。
适用场景:Key 范围集中且连续,无浪费空间。例如 LeetCode 第 387 题 “字符串中的第一个唯一字符”,就可用此方法统计字符出现次数:
int firstUniqChar(string s) {
int count[26] = {0}; // 直接定址:字符→下标(ASCII-'a')
for (char ch : s) count[ch - 'a']++; // 统计次数
for (int i = 0; i < s.size(); i++) {
if (count[s[i] - 'a'] == 1) return i;
}
return -1;
}
缺点:若 Key 范围分散(如 Key 是 [1, 1000000] 的随机数),会导致哈希表容量过大,严重浪费空间。
2.2 除法散列法(除留余数法):最常用的方案
将 Key 对哈希表容量 M 取模,结果作为存储位置,公式为:h(Key)=Key%M
关键优化:M 的选择直接影响冲突概率,需遵循以下原则:
避免选择 2 的整数次幂(如 16、32):此时取模等价于 “保留 Key 的后 X 位”,若 Key 后 X 位重复(如 63→00111111,31→00011111),冲突概率极高;
避免选择 10 的整数次幂(如 100、1000):仅保留 Key 的后 X 位(如 112 和 12312 对 100 取模均为 12),冲突严重;
推荐选择 “不接近 2 的整数次幂的质数”(如 11、53、97):质数的约数少,能让 Key 分布更均匀。
特殊情况:Java HashMap 选择 2 的整数次幂作为 M,但通过 “位运算优化”(Key 高 16 位与低 16 位异或)让所有位参与计算,同样实现均匀分布,本质是 “灵活变通” 而非违背原则。
2.3 其他哈希函数(了解即可)
乘法散列法:通过 Key 与常数 A(推荐黄金分割比 0.6180339887)相乘,取小数部分与 M 相乘后下取整,对 M 无特殊要求,但计算稍复杂;
全域散列法:通过随机选择哈希函数(如 ha,b(Key)=((a×Key+b)%P)%M,、 随机),避免恶意构造冲突数据,但需保证每次操作使用同一函数;
平方取中法 / 折叠法:适用于 Key 位数较多的场景(如身份证号),通过平方后取中间几位、或拆分 Key 后叠加,将其转换为适合的整数。
三、哈希冲突解决策略:两种经典方案
当冲突发生时,需通过特定策略为 Key 寻找新的存储位置。主流方案分为开放定址法和链地址法,两者各有优劣,适用场景不同。
3.1 开放定址法:“占满为止” 的连续空间
开放定址法的核心逻辑:所有元素都存储在哈希表数组中,若 Key 映射的初始位置(hash0)已被占用,则按某种规则 “探测” 下一个空闲位置,直到找到为止。探测规则主要有三种:线性探测、二次探测、双重探测。
3.1.1 线性探测:简单但易 “堆积”
探测规则:从 hash0 开始,依次向后探测(若到数组末尾则回绕到开头),公式为:hi(Key)=(hash0+i)%M(i=1,2,...,M−1)
示例:将 {19,30,5,36,13,20,21,12} 存入 M=11 的哈希表:
19%11=8 → 存入下标 8;
30%11=8(冲突)→ 探测 9(空闲),存入下标 9;
5%11=5 → 存入下标 5;
后续元素以此类推,最终存储结果如下:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 元素 | 21 | 12 | 13 | 36 | - | 5 | - | - | 19 | 30 | 20 |
问题:容易产生 “堆积(群集)”—— 若某段连续位置被占用(如 8、9),后续映射到该段的 Key 会持续争夺下一个空闲位置(如 10),导致冲突连锁反应。
3.1.2 二次探测:缓解堆积的 “跳跃式” 探测
探测规则:从 hash0 开始,按 “正负二次方” 跳跃探测,公式为:hi(Key)=(hash0±i2)%M(i=1,2,...,2M)
例如,hash0=8,M=11:
第一次探测:8+1²=9;
第二次探测:8-1²=7;
第三次探测:8+2²=12%11=1;
第四次探测:8-2²=4;
优势:跳跃式探测避免了线性探测的连续堆积,冲突分布更分散;局限:需保证 M 为 “4k+3 型质数”(如 7、11、19),否则可能无法探测到所有空闲位置。
3.1.3 开放定址法的关键设计:状态标识
开放定址法中,删除元素不能直接 “清空位置”—— 若删除下标 9 的 30,后续查找 20(下标 10)时,会因下标 9 为空而停止探测,导致查找失败。
解决方案:为每个位置增加状态标识,区分 “空(EMPTY)”“存在(EXIST)”“删除(DELETE)”:
EMPTY:从未存储过元素,查找时遇到可停止;
EXIST:当前存储元素;
DELETE:元素已删除,查找时需继续探测。
3.1.4 开放定址法完整代码实现
#include <vector>
#include <algorithm>
using namespace std;
// 哈希函数仿函数(默认支持内置类型,特化支持string)
template <class K>
struct HashFunc {
size_t operator()(const K& key) {
return (size_t)key; // 内置类型直接转换为size_t
}
};
// string特化:使用BKDR哈希算法(让每个字符参与计算,减少冲突)
template <>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (char ch : key) {
hash = hash * 131 + ch; // 131是质数,增强分布均匀性
}
return hash;
}
};
namespace open_address {
// 状态标识
enum State {
EMPTY, // 空
EXIST, // 存在
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; // 已存储元素个数
// 查找下一个质数(参考SGI STL实现)
unsigned long __stl_next_prime(unsigned long n) {
static const unsigned long prime_list[] = {
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 + sizeof(prime_list)/sizeof(prime_list[0]);
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
public:
// 构造函数:初始化哈希表为最小质数(53)
HashTable() {
_tables.resize(__stl_next_prime(0));
}
// 插入键值对(成功返回true,已存在返回false)
bool Insert(const pair<K, V>& kv) {
// 1. 检查是否已存在(去重)
if (Find(kv.first) != nullptr) {
return false;
}
// 2. 负载因子超过0.7,扩容(重新映射所有元素)
if (_n * 10 / _tables.size() >= 7) {
HashTable<K, V, Hash> new_ht;
new_ht._tables.resize(__stl_next_prime(_tables.size() + 1));
// 遍历旧表,将存在的元素插入新表
for (auto& data : _tables) {
if (data._state == EXIST) {
new_ht.Insert(data._kv);
}
}
// 交换新旧表(现代C++深拷贝优化)
_tables.swap(new_ht._tables);
}
// 3. 线性探测找空闲位置
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;
}
// 4. 插入元素并更新状态
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
// 查找Key:返回节点指针(不存在返回nullptr)
HashData<K, V>* Find(const K& key) {
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 遇到EMPTY停止,遇到EXIST检查Key,遇到DELETE继续
while (_tables[hashi]._state != EMPTY) {
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) {
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size();
++i;
// 防止死循环(理论上负载因子<1,必有空闲位置)
if (i > _tables.size()) {
break;
}
}
return nullptr;
}
// 删除Key:成功返回true,不存在返回false
bool Erase(const K& key) {
HashData<K, V>* data = Find(key);
if (data == nullptr) {
return false;
}
// 标记为DELETE,而非清空(避免影响后续查找)
data->_state = DELETE;
--_n;
return true;
}
// 调试用:打印哈希表
void Print() {
for (size_t i = 0; i < _tables.size(); ++i) {
if (_tables[i]._state == EXIST) {
cout << "[" << i << "]: " << _tables[i]._kv.first << " → " << _tables[i]._kv.second << endl;
} else if (_tables[i]._state == DELETE) {
cout << "[" << i << "]: DELETE" << endl;
} else {
cout << "[" << i << "]: EMPTY" << endl;
}
}
}
};
}
3.2 链地址法(哈希桶):“链表 + 数组” 的组合拳
链地址法是工业界最常用的冲突解决策略(如 C++ STL unordered_map、Java HashMap),其核心逻辑:哈希表数组存储链表头指针,冲突元素通过链表链接,每个链表称为一个 “哈希桶”。
3.2.1 核心设计:数组 + 链表的结构
数组(哈希表主体):每个位置存储一个链表头指针,初始为 nullptr;
链表(哈希桶):若多个 Key 映射到同一数组位置,将这些 Key 对应的节点链接成链表,挂在该位置下。
示例:将 {19,30,5,36,13,20,21,12,24,96} 存入 M=11 的哈希表:
19%11=8 → 下标 8 链表添加 19;
30%11=8(冲突)→ 下标 8 链表尾部添加 30;
96%11=8(冲突)→ 下标 8 链表尾部添加 96;
24%11=2 → 下标 2 链表添加 24(与已有的 13 链接);
最终存储结果如下:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 链表 | - | 12 | 13 → 24 | 36 | - | 5 | - | - | 19 → 30 → 96 | 20 | 21 |
3.2.2 优势与优化
无堆积问题:冲突元素仅在同一链表内存储,不影响其他位置;
负载因子灵活:可大于 1(链表可无限延长,但过长会影响效率);
极端场景优化:当链表长度超过阈值(如 Java HashMap 阈值为 8),可将链表转为红黑树,将查找时间复杂度从 O(n) 降至 O(log n)。
3.2.3 链地址法完整代码实现
#include <vector>
#include <algorithm>
using namespace std;
// 复用之前的HashFunc仿函数(支持内置类型和string)
template <class K>
struct HashFunc { /* 同3.1.4节 */ };
template <>
struct HashFunc<string> { /* 同3.1.4节 */ };
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 {
private:
vector<HashNode<K, V>*> _tables; // 指针数组:存储链表头
size_t _n = 0; // 已存储元素个数
// 查找下一个质数(同开放定址法)
unsigned long __stl_next_prime(unsigned long n) { /* 同3.1.4节 */ }
public:
// 构造函数:初始化哈希表为最小质数(53)
HashTable() {
_tables.resize(__stl_next_prime(0), nullptr);
}
// 析构函数:释放所有节点(防止内存泄漏)
~HashTable() {
for (size_t i = 0; i < _tables.size(); ++i) {
HashNode<K, V>* cur = _tables[i];
while (cur != nullptr) {
HashNode<K, V>* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr; // 避免野指针
}
}
// 插入键值对(头插法,效率更高)
bool Insert(const pair<K, V>& kv) {
// 1. 检查是否已存在(去重)
if (Find(kv.first) != nullptr) {
return false;
}
// 2. 负载因子等于1,扩容(重新映射所有节点)
if (_n == _tables.size()) {
size_t new_size = __stl_next_prime(_tables.size() + 1);
vector<HashNode<K, V>*> new_tables(new_size, nullptr);
Hash hash;
// 遍历旧表,将每个节点重新映射到新表
for (size_t i = 0; i < _tables.size(); ++i) {
HashNode<K, V>* cur = _tables[i];
while (cur != nullptr) {
// 保存下一个节点
HashNode<K, V>* next = cur->_next;
// 计算新表中的位置
size_t new_hashi = hash(cur->_kv.first) % new_size;
// 头插法插入新表
cur->_next = new_tables[new_hashi];
new_tables[new_hashi] = cur;
cur = next;
}
_tables[i] = nullptr; // 旧表置空
}
// 交换新旧表
_tables.swap(new_tables);
}
// 3. 插入新节点(头插法)
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
HashNode<K, V>* new_node = new HashNode<K, V>(kv);
new_node->_next = _tables[hashi];
_tables[hashi] = new_node;
++_n;
return true;
}
// 查找Key:返回节点指针(不存在返回nullptr)
HashNode<K, V>* Find(const K& key) {
Hash hash;
size_t hashi = hash(key) % _tables.size();
HashNode<K, V>* cur = _tables[hashi];
// 遍历当前桶的链表
while (cur != nullptr) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
// 删除Key:成功返回true,不存在返回false
bool Erase(const K& key) {
Hash hash;
size_t hashi = hash(key) % _tables.size();
HashNode<K, V>* prev = nullptr;
HashNode<K, V>* cur = _tables[hashi];
// 遍历链表找待删除节点
while (cur != nullptr) {
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;
}
// 调试用:打印哈希表
void Print() {
for (size_t i = 0; i < _tables.size(); ++i) {
cout << "[" << i << "]: ";
HashNode<K, V>* cur = _tables[i];
while (cur != nullptr) {
cout << cur->_kv.first << "→" << cur->_kv.second << " ";
cur = cur->_next;
}
cout << endl;
}
}
};
}
四、开放定址法 vs 链地址法:如何选择
两种冲突解决策略各有优劣,需根据实际场景选择:
| 对比维度 | 开放定址法 | 链地址法(哈希桶) |
|---|---|---|
| 空间利用率 | 高(无链表节点开销) | 低(需存储链表指针,节点开销大) |
| 冲突影响范围 | 大(堆积导致全局冲突) | 小(仅影响当前桶) |
| 负载因子阈值 | 低(通常 ≤0.7) | 高(可 ≥1,STL 默认 1) |
| 删除操作 | 复杂(需标记 DELETE,不可真删) | 简单(直接删除链表节点) |
| 缓存友好性 | 好(数组连续存储,命中缓存率高) | 差(链表节点分散,缓存命中率低) |
| 适用场景 | 数据量小、内存紧张、Key 简单 | 数据量大、追求稳定性、工业级场景 |
工业界选择:链地址法因 “冲突影响小、删除简单、稳定性高”,成为主流方案(如 C++ STL、Java 集合框架);开放定址法仅在特殊场景(如嵌入式系统、内存极小)中使用。
五、总结
哈希表是 “时间效率” 与 “空间效率” 的完美平衡,其核心在于哈希函数设计与冲突解决策略:
- 哈希函数需让 Key 均匀分布,推荐 “除法散列法”+“质数容量”;
- 冲突解决优先选择链地址法(哈希桶),兼顾稳定性与实现复杂度;
- 负载因子是性能调节器,需根据冲突策略设置合理阈值(开放定址法 0.7,链地址法 1);
- 自定义类型作为 Key 时,需实现 “哈希函数(转整数)” 和 “相等比较”,确保哈希表正常工作。
哈希表原理与C++实现
1390

被折叠的 条评论
为什么被折叠?



