文章目录
-
- 从理论到实践:深入理解哈希表的实现原理
- 一、哈希表基础概念
-
- [1.1 什么是哈希](#1.1 什么是哈希)
- [1.2 直接定址法](#1.2 直接定址法)
- 二、哈希函数与哈希冲突
-
- [2.1 哈希冲突的产生](#2.1 哈希冲突的产生)
- [2.2 负载因子](#2.2 负载因子)
- [2.3 关键字类型转换](#2.3 关键字类型转换)
- 三、常见的哈希函数
-
- [3.1 除法散列法(除留余数法)](#3.1 除法散列法(除留余数法))
- [3.2 乘法散列法](#3.2 乘法散列法)
- [3.3 全域散列法](#3.3 全域散列法)
- [3.4 其他哈希方法](#3.4 其他哈希方法)
- 四、哈希冲突的解决方法
-
- [4.1 开放定址法概述](#4.1 开放定址法概述)
- [4.2 线性探测](#4.2 线性探测)
- [4.3 二次探测](#4.3 二次探测)
- [4.4 双重散列](#4.4 双重散列)
- 五、开放定址法的代码实现
-
- [5.1 实现思路说明](#5.1 实现思路说明)
- [5.2 数据结构设计](#5.2 数据结构设计)
- [5.3 扩容机制](#5.3 扩容机制)
- [5.4 类型转换问题](#5.4 类型转换问题)
- [5.5 完整代码实现](#5.5 完整代码实现)
- 六、链地址法(哈希桶)
-
- [6.1 基本思想](#6.1 基本思想)
- [6.2 扩容策略](#6.2 扩容策略)
- [6.3 极端情况处理](#6.3 极端情况处理)
- [6.4 数据结构设计](#6.4 数据结构设计)
- [6.5 完整代码实现](#6.5 完整代码实现)
- 七、两种方法的对比与选择
-
- [7.1 性能对比](#7.1 性能对比)
- [7.2 实际应用](#7.2 实际应用)
- 八、总结与展望
从理论到实践:深入理解哈希表的实现原理
💬 欢迎讨论:如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区留言!我们一起学习、一起成长。
👍 点赞、收藏与分享:如果你觉得这篇文章对你有帮助,记得点赞、收藏并分享给更多的朋友!
🚀 逐步实现哈希表:本篇文章将带你深入理解哈希表的核心概念,从哈希函数的设计到冲突解决方案,再到完整的代码实现,确保每个步骤都详细讲解,让你真正掌握这一高效的数据结构。
一、哈希表基础概念
1.1 什么是哈希
哈希(Hash)又称散列,是一种高效的数据组织方式。从字面意思来看,"散列"表示数据以分散的方式存储。哈希的核心思想是通过哈希函数建立关键字Key与存储位置之间的映射关系,使得在查找数据时,可以直接通过计算得到数据的存储位置,从而实现快速查找。
这种映射关系的建立,使得哈希表在理想情况下能够实现O(1)时间复杂度的查找、插入和删除操作,这是其他数据结构难以达到的性能表现。
1.2 直接定址法
直接定址法是哈希方法中最简单直观的一种。当关键字的范围比较集中时,这种方法非常高效。其基本思想是:用关键字的值直接作为数组下标,或者通过简单的线性变换得到数组下标。
适用场景示例
假设我们有一组关键字都在[0, 99]之间,那么只需要开辟一个大小为100的数组,每个关键字的值就是它在数组中的存储位置。再比如,一组关键字都是[a, z]的小写字母,我们可以开辟一个大小为26的数组,用字符的ASCII码减去'a'的ASCII码作为下标。
实际应用案例
这种方法在计数排序中已经使用过。下面通过一道LeetCode题目来加深理解:
387. 字符串中的第一个唯一字符
cpp
class Solution {
public:
int firstUniqChar(string s) {
// 每个字母的ASCII码 - 'a'的ASCII码作为下标映射到count数组
// 数组中存储该字符出现的次数
int count[26] = {0};
// 统计每个字符出现的次数
for(auto ch : s)
{
count[ch - 'a']++;
}
// 按顺序查找第一个只出现一次的字符
for(size_t i = 0; i < s.size(); ++i)
{
if(count[s[i] - 'a'] == 1)
return i;
}
return -1;
}
};
这个例子充分展示了直接定址法的优势:时间复杂度为O(n),空间复杂度仅为O(26),即O(1)。
直接定址法的局限性
虽然直接定址法简单高效,但它的缺点也很明显。当关键字的范围比较分散时,会造成严重的空间浪费,甚至可能出现内存不足的情况。例如,如果我们只有10个数据,但这些数据的值分布在[0, 1000000]之间,使用直接定址法就需要开辟一个百万大小的数组,造成了极大的空间浪费。
二、哈希函数与哈希冲突
2.1 哈希冲突的产生
为了解决直接定址法的空间浪费问题,我们需要引入哈希函数。假设我们有N个数据,数据范围是[0, 9999],但我们只想用一个大小为M的数组来存储(通常M >= N),这时就需要哈希函数h(key)将关键字映射到[0, M)范围内的位置。
哈希冲突的定义
由于多个不同的关键字可能被映射到同一个位置,这种现象称为哈希冲突或哈希碰撞。理想情况下,我们希望找到一个完美的哈希函数来避免冲突,但在实际应用中,冲突是不可避免的。因此,我们需要从两个方面来解决这个问题:
- 设计优秀的哈希函数,尽可能减少冲突的概率
- 设计有效的冲突解决方案
2.2 负载因子
在讨论哈希函数之前,我们需要先理解负载因子这个重要概念。
假设哈希表中已经存储了N个元素,哈希表的大小为M,那么:
负载因子 = N M 负载因子 = \frac{N}{M} 负载因子=MN
负载因子反映了哈希表的填充程度,它直接影响哈希表的性能:
- 负载因子越大:哈希冲突的概率越高,但空间利用率也越高
- 负载因子越小:哈希冲突的概率越低,但空间利用率也越低
在实际应用中,需要在冲突概率和空间利用率之间寻找平衡点。
2.3 关键字类型转换
哈希函数通常需要对整数进行取模运算来得到数组下标。如果关键字不是整数类型(如字符串、日期等),我们需要先将其转换为整数。
转换的关键要求是:尽可能让不同的关键字转换出不同的整数值,并且让关键字的每个部分都参与到计算中。具体的转换方法我们会在后面的代码实现中详细展示。
三、常见的哈希函数
3.1 除法散列法(除留余数法)
除法散列法是最常用的哈希函数设计方法,其思想非常简单:用关键字除以哈希表大小M,取余数作为哈希值。
数学表达式
h ( k e y ) = k e y m o d M h(key) = key \bmod M h(key)=keymodM
M值的选择技巧
在使用除法散列法时,M的选择至关重要。我们应该避免以下情况:
-
避免2的幂次:如果M = 2^X,那么取模运算本质上是保留key的后X位二进制数。这会导致后X位相同的不同数值产生相同的哈希值。
例如:key1 = 63(二进制:00111111),key2 = 31(二进制:00011111),如果M = 16 = 2^4,两者的后4位都是1111,哈希值都是15。
-
避免10的幂次:如果M = 10^X,则保留的是十进制的后X位,同样会导致冲突。
例如:key1 = 112,key2 = 12312,如果M = 100 = 10^2,两者的后两位都是12,哈希值相同。
推荐做法
建议M取一个不太接近2的整数次幂的质数(素数)。质数的特性使得取模后的结果分布更加均匀。
实践中的变通方法
值得注意的是,Java的HashMap采用了不同的策略。它使用2的整数次幂作为哈希表大小,但不是简单地取模,而是采用位运算来提高效率。具体做法是:先将key右移16位得到key',然后将key和key'进行异或运算,让key的所有位都参与哈希值的计算,从而使哈希值分布更加均匀。
这个例子告诉我们,在实际工程中要灵活运用理论知识,抓住问题的本质,而不是生搬硬套。
3.2 乘法散列法
乘法散列法采用了不同的思路,它对哈希表大小M没有特殊要求。
算法步骤
- 用关键字K乘以常数A(0 < A < 1)
- 提取K×A的小数部分
- 用M乘以这个小数部分
- 对结果向下取整
数学表达式
h ( k e y ) = ⌊ M × ( ( A × k e y ) m o d 1.0 ) ⌋ h(key) = \lfloor M \times ((A \times key) \bmod 1.0) \rfloor h(key)=⌊M×((A×key)mod1.0)⌋
其中,floor表示向下取整,A ∈ (0, 1)。
A值的选择
计算机科学家Knuth建议A取黄金分割点:
A = 5 − 1 2 = 0.6180339887... A = \frac{\sqrt{5} - 1}{2} = 0.6180339887... A=25 −1=0.6180339887...
计算示例
假设M = 1024,key = 1234,A = 0.6180339887:
- A × key = 0.6180339887 × 1234 = 762.6539420558
- 取小数部分:0.6539420558
- M × 0.6539420558 = 1024 × 0.6539420558 = 669.6366651392
- 向下取整:h(1234) = 669
3.3 全域散列法
全域散列法是一种更加高级的方法,它的设计初衷是为了防止恶意攻击。
问题背景
如果哈希函数是公开且确定的,攻击者可能会构造出一组特殊的数据,使得所有数据都映射到同一个位置,从而使哈希表退化为链表,查找效率降低到O(n)。
解决思路
全域散列法通过给哈希函数增加随机性来解决这个问题。它不是使用单一的哈希函数,而是从一族哈希函数中随机选择一个使用。
数学表达式
h a b ( k e y ) = ( ( a × k e y + b ) m o d P ) m o d M h_{ab}(key) = ((a \times key + b) \bmod P) \bmod M hab(key)=((a×key+b)modP)modM
其中:
- P是一个足够大的质数
- a是从[1, P-1]中随机选择的整数
- b是从[0, P-1]中随机选择的整数
这样可以构成P×(P-1)个不同的哈希函数。
计算示例
假设P = 17,M = 6,a = 3,b = 4:
h 34 ( 8 ) = ( ( 3 × 8 + 4 ) m o d 17 ) m o d 6 = 28 m o d 17 m o d 6 = 11 m o d 6 = 5 h_{34}(8) = ((3 \times 8 + 4) \bmod 17) \bmod 6 = 28 \bmod 17 \bmod 6 = 11 \bmod 6 = 5 h34(8)=((3×8+4)mod17)mod6=28mod17mod6=11mod6=5
使用注意事项
在初始化哈希表时随机选择一个哈希函数,之后的所有操作都使用这个固定的函数。不能每次操作都随机选择,否则插入和查找使用的可能是不同的函数,导致无法找到已插入的数据。
3.4 其他哈希方法
除了上述方法,在一些数据结构教材中还介绍了平方取中法、折叠法、随机数法、数学分析法等。这些方法通常适用于特定的场景,在实际应用中相对较少使用。感兴趣的同学可以参考《算法导论》、《数据结构(C语言版)》等书籍深入学习。
四、哈希冲突的解决方法
4.1 开放定址法概述
在开放定址法中,所有的元素都存储在哈希表内部。当一个关键字key通过哈希函数计算出的位置已经被占用(发生冲突)时,按照某种规则在哈希表中寻找下一个空闲位置进行存储。
由于所有元素都存储在哈希表内,开放定址法的负载因子必须小于1。根据寻找下一个空闲位置的规则不同,开放定址法可以分为三种:线性探测、二次探测和双重散列。
4.2 线性探测
线性探测是开放定址法中最简单的一种方法。
探测规则
从发生冲突的位置开始,依次向后查找,直到找到一个空闲位置。如果查找到哈希表末尾,则回绕到哈希表开头继续查找。
数学表达式
假设h(key) = hash₀ = key % M,当hash₀位置发生冲突时:
h a s h i = ( h a s h 0 + i ) m o d M , i ∈ 1 , 2 , 3 , . . . , M − 1 hash_i = (hash_0 + i) \bmod M, \quad i \in {1, 2, 3, ..., M-1} hashi=(hash0+i)modM,i∈1,2,3,...,M−1
由于负载因子小于1,最多探测M-1次,一定能找到一个空闲位置。
插入示例
将数据{19, 30, 5, 36, 13, 20, 21, 12}插入到大小M=11的哈希表中:
- h(19) = 8,直接插入位置8
- h(30) = 8,位置8已占用,探测到位置9插入
- h(5) = 5,直接插入位置5
- h(36) = 3,直接插入位置3
- h(13) = 2,直接插入位置2
- h(20) = 9,位置9已占用,探测到位置10插入
- h(21) = 10,位置10已占用,探测到位置0插入
- h(12) = 1,直接插入位置1
线性探测的问题
线性探测存在一个明显的问题,称为群集(堆积)现象。当某个位置连续发生冲突时,会形成一个连续占用的区域。后续映射到这个区域附近的值都会争夺相邻的空闲位置,使得这个区域越来越大,进一步加剧冲突。
4.3 二次探测
二次探测通过改进探测方式来缓解线性探测的群集问题。
探测规则
从发生冲突的位置开始,按照二次方的增量进行左右跳跃式探测。
数学表达式
h a s h i = ( h a s h 0 ± i 2 ) m o d M , i ∈ 1 , 2 , 3 , . . . , ⌊ M ⌋ hash_i = (hash_0 \pm i^2) \bmod M, \quad i \in {1, 2, 3, ..., \lfloor\sqrt{M}\rfloor} hashi=(hash0±i2)modM,i∈1,2,3,...,⌊M ⌋
实现细节
当计算hash_i = (hash₀ - i²) % M时,如果结果为负数,需要加上M使其落在有效范围内。
插入示例
将数据{19, 30, 52, 63, 11, 22}插入到大小M=11的哈希表中:
- h(19) = 8,直接插入位置8
- h(30) = 8,冲突,+1²探测到位置9插入
- h(52) = 8,冲突,+1²到9占用,-1²到7插入
- h(63) = 8,冲突,经过多次探测插入
- h(11) = 0,直接插入位置0
- h(22) = 0,冲突,+1²探测到位置1插入
二次探测相比线性探测能够更好地分散冲突的元素,减少群集现象。
4.4 双重散列
双重散列使用两个哈希函数,进一步改进冲突解决的效率。
探测规则
第一个哈希函数h₁(key)计算初始位置,如果发生冲突,使用第二个哈希函数h₂(key)计算探测的步长。
数学表达式
h 1 ( k e y ) = h a s h 0 = k e y m o d M h_1(key) = hash_0 = key \bmod M h1(key)=hash0=keymodM
h a s h i = ( h a s h 0 + i × h 2 ( k e y ) ) m o d M , i ∈ 1 , 2 , 3 , . . . , M hash_i = (hash_0 + i \times h_2(key)) \bmod M, \quad i \in {1, 2, 3, ..., M} hashi=(hash0+i×h2(key))modM,i∈1,2,3,...,M
h₂(key)的要求
- h₂(key) < M
- h₂(key)与M互质(最大公约数为1)
这样可以保证能够遍历到哈希表的所有位置。如果h₂(key)与M的最大公约数为p > 1,那么只能访问到M/p个位置,无法充分利用整个哈希表。
h₂(key)的取值方法
- 当M为2的整数幂时:h₂(key)从[0, M-1]中任选一个奇数
- 当M为质数时:h₂(key) = key % (M-1) + 1
插入示例
将数据{19, 30, 52, 74}插入到大小M=11的哈希表中,设h₂(key) = key % 10 + 1:
- h₁(19) = 8,h₂(19) = 10,直接插入位置8
- h₁(30) = 8,冲突,h₂(30) = 1,探测位置(8+1)%11=9插入
- h₁(52) = 8,冲突,h₂(52) = 3,探测位置(8+3)%11=0插入,位置0占用,继续探测(8+2×3)%11=3插入
- h₁(74) = 8,冲突,h₂(74) = 5,依次探测找到空闲位置插入
五、开放定址法的代码实现
5.1 实现思路说明
在实际应用中,开放定址法的性能不如后面要讲的链地址法,因为开放定址法中的所有元素都存储在哈希表内部,冲突的元素之间会相互影响。因此,我们选择最简单的线性探测方式来实现开放定址法,重点是理解其原理。
5.2 数据结构设计
状态枚举
在开放定址法中,每个位置需要标记三种状态:
cpp
enum State
{
EXIST, // 位置已存储数据
EMPTY, // 位置为空
DELETE // 位置的数据已删除
};
为什么需要DELETE状态?考虑下面的场景:
假设我们依次插入了19、30、20三个值,它们的哈希值分别映射到位置8、8、9。由于30与19冲突,30被存储在位置9;20本应存储在位置9,但位置9已被30占用,因此20被存储在位置10。
如果我们删除30时直接将位置9标记为EMPTY,那么查找20时会在位置9停止(因为遇到了EMPTY),导致无法找到位置10的20。但如果我们将位置9标记为DELETE,查找20时会跳过DELETE状态继续查找,就能正确找到20。


节点结构
cpp
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 存储键值对
State _state = EMPTY; // 状态标识,默认为空
};
哈希表结构
cpp
template<class K, class V>
class HashTable
{
private:
vector<HashData<K, V>> _tables; // 哈希表数组
size_t _n = 0; // 已存储的元素个数
};
5.3 扩容机制
我们将负载因子控制在0.7,即当已存储元素个数达到表大小的70%时进行扩容。
质数表的使用
为了保持哈希表大小为质数,我们采用STL中的做法,使用一个预定义的质数表,每次扩容时从表中获取下一个合适的质数:
cpp
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;
}
这个质数表中的每个数大约是前一个数的2倍,既保证了扩容的效率,又保持了质数的特性。
5.4 类型转换问题
当关键字Key不是整数类型时(如string、Date等),我们需要先将其转换为整数才能进行取模运算。为了实现这个功能,我们使用仿函数(函数对象)。
默认仿函数
对于可以直接转换为整数的类型,使用默认仿函数:
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key; // 直接转换为size_t类型
}
};
字符串特化
字符串是最常见的非整数关键字类型,我们为其提供专门的哈希函数:
cpp
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131; // 使用BKDR哈希算法
hash += ch;
}
return hash;
}
};
这里使用的是BKDR哈希算法,它通过将前一次的结果乘以一个质数(通常选择31、131等),然后加上当前字符的ASCII码,使得字符串的每个字符都参与到哈希值的计算中,并且不同顺序的相同字符会产生不同的哈希值。
例如,"abcd"和"bcad"虽然包含相同的字符,但由于顺序不同,计算出的哈希值也不同。
哈希表模板参数
cpp
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
// ...
};
这里将Hash作为模板参数,并提供了默认值。如果Key类型需要自定义哈希函数,可以自己实现一个仿函数并传入。
5.5 完整代码实现
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:
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;
}
HashTable()
{
_tables.resize(__stl_next_prime(0));
}
bool Insert(const pair<K, V>& kv)
{
// 检查是否已存在
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() + 1));
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)
{
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;
}
else
{
// 标记为已删除状态,不能直接删除
ret->_state = DELETE;
--_n;
return true;
}
}
private:
vector<HashData<K, V>> _tables; // 哈希表数组
size_t _n = 0; // 表中存储的元素个数
};
}
代码要点说明
-
插入操作:先检查元素是否已存在,再判断是否需要扩容,最后使用线性探测找到合适位置插入。
-
查找操作:使用线性探测方式查找,遇到EMPTY状态时停止,遇到DELETE状态时继续探测。
-
删除操作:将元素状态标记为DELETE而非直接删除,保证后续查找操作的正确性。
六、链地址法(哈希桶)
6.1 基本思想
链地址法也称为拉链法或哈希桶,它采用了与开放定址法完全不同的思路来解决冲突问题。
在链地址法中:
- 哈希表的每个位置不直接存储数据,而是存储一个指针
- 当没有数据映射到某个位置时,该位置的指针为空
- 当有数据映射到某个位置时,该数据被插入到该位置对应的链表中
- 当多个数据映射到同一位置时,这些数据形成一个链表
这种方法将冲突的元素通过链表串联起来,避免了开放定址法中元素之间相互影响的问题。
插入示例
将数据{19, 30, 5, 36, 13, 20, 21, 12, 24, 96}插入到大小M=11的哈希表中:
- h(19) = 8,创建节点挂在位置8
- h(30) = 8,创建节点插入位置8的链表
- h(5) = 5,创建节点挂在位置5
- h(36) = 3,创建节点挂在位置3
- h(13) = 2,创建节点挂在位置2
- h(20) = 9,创建节点挂在位置9
- h(21) = 10,创建节点挂在位置10
- h(12) = 1,创建节点挂在位置1
- h(24) = 2,创建节点插入位置2的链表
- h(96) = 8,创建节点插入位置8的链表
最终形成的哈希表中,位置8、2分别有多个节点形成的链表。
6.2 扩容策略
与开放定址法不同,链地址法的负载因子可以大于1,因为冲突的元素存储在链表中,而不占用哈希表的其他位置。
负载因子的影响
- 负载因子越大:空间利用率越高,但链表可能越长,查找效率降低
- 负载因子越小:链表较短,查找效率高,但空间利用率较低
在STL的unordered容器中,通常将最大负载因子控制在1左右。当负载因子达到1时进行扩容,我们的实现也采用这个策略。
6.3 极端情况处理
问题场景
在某些极端情况下,可能出现某个桶特别长的情况。虽然通过合理的哈希函数和扩容策略可以降低这种情况的概率,但仍然需要考虑应对方案。
解决方案
-
使用全域散列法:通过随机选择哈希函数,避免被恶意攻击
-
链表转红黑树:Java 8的HashMap采用了这种策略。当某个桶的链表长度超过阈值(8)时,将链表转换为红黑树,保证查找效率从O(n)提升到O(log n)
在我们的实现中,为了保持代码简洁,不实现这些优化。但在实际工程项目中,可以根据需要添加这些优化。
6.4 数据结构设计
节点结构
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)
{}
};
哈希表结构
cpp
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _tables; // 指针数组,每个元素指向一个链表
size_t _n = 0; // 表中存储的元素总数
};
6.5 完整代码实现
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;
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:
HashTable()
{
_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)
{
// 检查是否已存在
if (Find(kv.first))
return false;
Hash hs;
// 负载因子等于1时扩容
if (_n == _tables.size())
{
// 创建新表
vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
// 将旧表中的节点移动到新表
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);
}
// 计算插入位置
size_t 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:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 元素总数
};
}
代码要点说明
-
构造函数:初始化哈希表为一个质数大小的指针数组,所有指针初始化为nullptr
-
析构函数:必须实现深度释放,遍历每个桶,释放链表中的所有节点
-
插入操作:
- 先检查元素是否已存在
- 判断是否需要扩容(负载因子达到1)
- 使用头插法将新节点插入对应位置的链表
-
扩容操作:
- 创建新的更大的哈希表
- 将旧表中的节点移动(而非复制)到新表
- 这种移动方式避免了重新创建节点,提高了效率
-
查找操作:计算哈希值后,遍历对应位置的链表查找目标元素
-
删除操作:找到目标节点后,调整链表指针并释放节点内存
七、两种方法的对比与选择
7.1 性能对比
开放定址法
优点:
- 数据存储紧凑,缓存友好
- 不需要额外的指针空间
缺点:
- 负载因子必须小于1,空间利用率受限
- 冲突元素相互影响,容易产生群集现象
- 删除操作较复杂,需要标记状态
链地址法
优点:
- 负载因子可以大于1,空间利用率更高
- 冲突元素互不影响,性能更稳定
- 删除操作简单直接
- 易于实现和维护
缺点:
- 需要额外的指针空间
- 内存不连续,缓存性能略差
7.2 实际应用
在工程实践中,链地址法因其更好的综合性能被广泛采用。例如:
- C++ STL的unordered_map和unordered_set使用链地址法
- Java的HashMap使用链地址法(并在链表过长时转为红黑树)
- Python的dict也采用类似的思想
开放定址法主要在一些对内存布局有特殊要求的场景中使用。
八、总结与展望
哈希表作为一种高效的数据结构,在现代软件开发中扮演着重要角色。通过合理设计哈希函数和选择适当的冲突解决策略,我们可以实现接近O(1)时间复杂度的数据操作。本文从基础概念出发,详细讲解了哈希表的各种实现方法,特别是开放定址法和链地址法两种主流方案。在实际应用中,我们需要根据具体场景选择合适的实现方式,平衡时间效率和空间开销。掌握哈希表的原理和实现,不仅能帮助我们更好地使用标准库中的容器,也为深入理解数据结构和算法打下坚实基础。
以上就是关于哈希表实现的全部内容,各位读者如有疑问欢迎在评论区指正,或者私信交流。您的支持是我持续创作的最大动力!❤️
