1. 哈希表概述
哈希表(Hash Table)是一种高效的数据结构,它通过哈希函数将关键字映射到数组中的特定位置,从而实现平均时间复杂度为 O(1) 的查找、插入和删除操作。哈希表的核心在于哈希函数 的设计和哈希冲突的解决。
2. 哈希函数:除法散列法
2.1 基本概念
哈希函数 h(key) 将关键字 key 映射到哈希表大小 M 范围内的索引:h(key) ∈ [0, M-1]。一个好的哈希函数应尽可能均匀地将关键字分布到哈希表空间中。
2.2 除法散列法(除留余数法)
除法散列法是最简单、最常用的哈希函数之一,其公式为:
h(key) = key % M
其中 M 是哈希表的大小。
2.3 M 的选择原则
- 避免 M 为 2 的幂 :如果
M = 2^k,那么key % M实际上只保留了key的低k位二进制。这会导致许多不同的key产生相同的哈希值,增加冲突概率。- 例如:
M = 16 (2^4),key = 31 (00011111)和key = 63 (00111111)的低4位都是1111,哈希值均为15。
- 例如:
- 避免 M 为 10 的幂 :类似地,如果
M = 10^k,则只保留key的十进制低k位。- 例如:
M = 100,key = 112和key = 12312的哈希值都是12。
- 例如:
- 推荐选择 :
M取一个不太接近 2 的整数次幂的质数。这有助于哈希值分布更均匀。
实践中的变通 :某些实现(如 Java 的 HashMap)仍使用 2 的幂作为
M,但通过额外的位运算(如(key ^ (key >>> 16)))让高位也参与计算,以改善均匀性。本文遵循经典理论,选择质数作为表大小。
3. 哈希冲突与开放定址法
3.1 哈希冲突
当两个不同的关键字 key1 和 key2 经过哈希函数计算得到相同的索引,即 h(key1) = h(key2),就发生了哈希冲突。冲突是不可避免的,因此需要设计解决冲突的方案。
3.2 开放定址法
开放定址法的核心思想:所有元素都存储在哈希表数组本身中。当发生冲突时,按照某种探测序列在表中寻找下一个空闲位置。
负载因子 :α = N / M,其中 N 为已存储元素个数,M 为表大小。在开放定址法中,负载因子必须小于 1(通常控制在 0.7 以下),否则可能无法找到空闲位置。
4. 线性探测
4.1 探测公式
设初始哈希位置为 hash0 = key % M。若该位置已被占用,则线性向后探测:
hash_i = (hash0 + i) % M, i = 1, 2, 3, ..., M-1
直到找到一个空闲(EMPTY)或已删除(DELETED)的位置。
4.2 示例
假设 M = 11,插入序列 {19, 30, 5, 36, 13, 20, 21, 12}:
h(19)=8→ 位置8h(30)=8冲突 → 线性探测(8+1)%11=9→ 位置9h(5)=5→ 位置5h(36)=3→ 位置3h(13)=2→ 位置2h(20)=9冲突 →(9+1)%11=10→ 位置10h(21)=10冲突 →(10+1)%11=0→ 位置0h(12)=1→ 位置1
最终存储位置:[21, 12, 13, 36, _, 5, _, _, 19, 30, 20]
4.3 线性探测的缺点:聚集
当连续位置被占用后,后续映射到该区域的关键字都会争夺后续的同一个位置,形成"聚集"现象,降低探测效率。
5. 二次探测
5.1 探测公式
二次探测通过跳跃式探测缓解聚集问题:
hash_i = (hash0 ± i²) % M, i = 1, 2, 3, ..., floor(M/2)
探测序列为:hash0+1², hash0-1², hash0+2², hash0-2², ...
注意 :当
(hash0 - i²) % M结果为负数时,需要加上M使其落在[0, M-1]范围内。
5.2 在线性探测基础上修改以实现二次探测
二次探测的实现只需修改探测步长计算方式,其他逻辑(如查找、删除)与线性探测完全相同。
6. C++ 实现:开放定址法哈希表
6.1 数据结构定义
cpp
enum State
{
EXIST, // 该位置有元素
EMPTY, // 该位置为空
DELETE // 该位置元素已被删除
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;// 这里存放键值对
State _state = EMPTY; // 初始状态为空
};
6.2 哈希函数与字符串特化
哈希表的关键在于将任意类型的关键字转换为整数,以便进行取模运算。对于不同类型的关键字,我们需要提供相应的哈希函数仿函数。
cpp
// 默认哈希函数:适用于可直接转换为 size_t 的类型(如正整数、指针等)
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
// 注意:对于有符号整数(如 int),直接转换 (size_t)key 会将负数的二进制表示
// 解释为很大的无符号数,虽然能工作但分布可能不均匀。
// 对于整数类型,建议使用特化版本(如下面的 HashFunc<int>)
return (size_t)key;
}
};
// 特化 string 类型的哈希函数
template<>
struct HashFunc<string>
{
// BKDR 哈希算法
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
// 乘数的作用:
// 1. 避免相同字符不同顺序的字符串产生相同哈希值
// 例如:"abc" 和 "cba" 如果没有乘数,hash = 'a'+'b'+'c' = 'c'+'b'+'a'
// 加入乘数后:hash = ((0*131+'a')*131+'b')*131+'c' ≠ ((0*131+'c')*131+'b')*131+'a'
// 2. 扩大哈希值的分布范围,减少冲突
// 3. 常用乘数:31, 131, 1313, 5381 等质数,这些数有良好的数学特性
hash *= 131; // 乘数常用 31, 131, 1313 等
hash += ch;
}
return hash;
}
};
使用说明:
- 整数 :对于
unsigned int,size_t等无符号类型,默认的HashFunc通过(size_t)key转换即可。 - 字符串:使用 BKDR 哈希算法,确保不同字符串尽可能映射到不同的哈希值。
在哈希表类中,我们通过模板参数 Hash = HashFunc<K> 来使用这些仿函数:
cpp
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
// ...
Hash hash; // 哈希函数对象
size_t hash0 = hash(key) % _tables.size(); // 计算哈希值
// ...
};
这样,当用户使用 HashTable<int,string> 时,会自动使用特化的 HashFunc<int>;使用 HashTable<unsigned int,string> 时,会使用默认模板;使用 HashTable<string, int> 时,会自动使用 HashFunc<string>,以此类推。
6.3 哈希表类框架
cpp
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
vector<HashData<K, V>> _tables; // 哈希表数组
size_t _n = 0; // 存储的元素个数
public:
HashTable()
{
// 直接初始化表大小为 11(示例中使用的质数)
_tables.resize(11);
}
// 插入、查找、删除等方法将在下面实现
};
为什么使用 HashData 而不是 pair?
在开放定址法哈希表的实现中,我们使用 HashData<K, V> 结构体而不是简单的 pair<K, V>,主要原因如下:
-
状态管理需求:开放定址法需要区分三种状态:
EXIST:该位置有有效元素EMPTY:该位置为空,可以插入新元素DELETE:该位置元素已被删除,但探测时不能停止(需要继续查找)
如果只使用
pair,无法表示这些状态信息。 -
删除操作的实现:在开放定址法中,删除元素不能直接清空位置,否则会破坏探测链。例如:
- 元素 A 和 B 哈希冲突,A 在位置 i,B 在位置 i+1
- 如果删除 A 后直接清空位置 i,查找 B 时会误以为位置 i 为空而停止,导致找不到 B
- 使用
DELETE状态标记已删除位置,查找时继续探测
-
内存效率与清晰性:
HashData将键值对和状态封装在一起,逻辑清晰- 状态信息与数据紧密关联,避免使用额外的状态数组
- 代码可读性更好,明确表达了每个位置的状态
-
扩展性 :如果需要添加其他元数据(如版本号、时间戳等),可以在
HashData中轻松扩展。
相比之下,链地址法(哈希桶)通常直接使用 std::pair 或节点结构,因为每个桶是链表,删除操作直接移除节点即可,不需要状态标记。
6.4 插入操作(支持线性探测与二次探测)
cpp
bool Insert(const pair<K, V>& kv)
{
// 1. 如果已存在,返回false
if (Find(kv.first))
return false;
// 2. 负载因子超过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;
// 3. 探测空闲位置
while (_tables[hashi]._state == EXIST)
{
// 线性探测
hashi = (hash0 + i) % _tables.size();
// 若要改为二次探测,只需修改为:
// hashi = (hash0 + i * i) % _tables.size(); // 二次探测公式
++i;
// 理论上最多探测 M-1 次,因为负载因子<1,一定能找到空位
if (i > _tables.size())
return false; // 实际不会走到这里
}
// 4. 插入元素
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
6.5 查找操作
cpp
HashData<K, V>* Find(const K& key)
{
HashFunc hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
// 遇到 EMPTY 才停止(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;
// 防止死循环
if (hashi == hash0) // 回到起始位置,说明已遍历整个表
break;
}
return nullptr;
}
6.6 删除操作
cpp
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
// 标记为 DELETE,而不是真正删除
ret->_state = DELETE;
--_n;
return true;
}
}
9.1 链地址法(哈希桶)与开放定址法对比
链地址法(Separate Chaining,又称哈希桶)是另一种重要的哈希冲突解决方法,与本文实现的开放定址法有显著区别。
核心思想对比
- 开放定址法:所有元素都存储在哈希表数组本身中,冲突时通过探测序列在数组中寻找下一个空闲位置。
- 链地址法:哈希表的每个位置(桶)是一个链表头节点,冲突元素直接链接到对应桶的链表中。
优缺点对比
| 特性 | 开放定址法 | 链地址法(哈希桶) |
|---|---|---|
| 存储方式 | 所有元素存储在数组中 | 数组+链表(或红黑树) |
| 负载因子限制 | 必须小于1(通常≤0.75) | 可以大于1,理论上无上限 |
| 删除操作 | 需要标记DELETE状态,不能直接置空 | 直接删除链表节点即可 |
| 内存占用 | 固定大小的数组,空间利用率低 | 动态链表,空间利用率高 |
| 缓存友好性 | 好(连续内存访问) | 较差(链表节点分散) |
| 实现复杂度 | 相对简单 | 需要链表管理,稍复杂 |
| 聚集问题 | 存在聚集现象(线性/二次) | 无聚集问题 |
| 扩容时机 | 负载因子>0.75时需扩容 | 链表长度较长时(如>8)才需树化或扩容 |
| 最坏情况 | 所有元素冲突时退化为O(n)查找 | 所有元素哈希到同一桶时退化为O(n)查找 |
适用场景
-
开放定址法适用:
- 内存空间有限,希望连续存储
- 数据量相对固定,负载因子可预测
- 对缓存性能要求高
- 实现简单,适合教学和基础场景
-
链地址法适用:
- 数据量动态变化大
- 负载因子可能很高
- 需要频繁插入删除
- 实际生产环境更常用(如Java HashMap、C++ unordered_map)
链地址法简单结构定义示例
cpp
// 链地址法哈希表节点
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)
{}
};
// 使用 using 定义 Node 别名
template<class K, class V>
using Node = HashNode<K, V>;
// 链地址法哈希表框架
template<class K, class V, class Hash = HashFunc<K>>
class HashTableChaining
{
private:
vector<Node*> _tables; // 指针数组,每个位置是链表头
size_t _n = 0; // 元素个数
// 获取下一个质数(与开放定址法相同)
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;
}
public:
// 构造函数
HashTableChaining()
:_tables(11)
,_n(0)
{}
// 析构函数:释放所有链表节点
~HashTableChaining()
{
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 hash;
size_t hashi = hash(kv.first) % _tables.size();
// 负载因子达到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 = hash(cur->_kv.first) % newTables.size();
// 头插到新表
cur->_next = newTables[newHashi];
newTables[newHashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
// 交换新旧表
_tables.swap(newTables);
// 重新计算哈希值
hashi = hash(kv.first) % _tables.size();
}
// 查找是否已存在相同key
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == kv.first)
return false; // 已存在,插入失败
cur = cur->_next;
}
// 头插法插入新节点
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; // 找到节点
cur = cur->_next;
}
return nullptr; // 未找到
}
// 删除操作
bool Erase(const K& key)
{
Hash hash;
size_t hashi = hash(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; // 未找到要删除的节点
}
// 获取元素个数
size_t Size() const
{
return _n;
}
// 获取桶的数量
size_t BucketCount() const
{
return _tables.size();
}
// 获取最大桶长度(用于性能分析)
size_t MaxBucketLength() const
{
size_t maxLen = 0;
for (size_t i = 0; i < _tables.size(); i++)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
if (len > maxLen)
maxLen = len;
}
return maxLen;
}
};
```## 10. 完整代码示例
```cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
// 状态枚举
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& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
// 开放定址法哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;
// 获取下一个质数
inline unsigned long __stl_next_prime(unsigned long n)
{
static const int __stl_num_primes
测试用例
int main()
{
//int a[] = { 19,30,52,63,11,22 };
const char* a1[] = { "abcd", "sort", "insert" };
HashTable<string, string> ht1;
for (auto& e : a1)
{
ht1.Insert({ e, e });
}
cout << HashFunc<string>()("abcd") << endl;
cout << HashFunc<string>()("bcad") << endl;
cout << HashFunc<string>()("aadd") << endl;
int a2[] = { -19,-30,5,36,13,20,21,12 };
HashTable<int, int> ht2;
for (auto e : a2)
{
ht2.Insert({ e, e });
}
return 0;
}