C++修炼:哈希表的模拟实现
Hello大家好!很高兴我们又见面啦!给生活添点passion,开始今天的编程之路!
我的博客:<但凡.
我的专栏:《编程之路》、《数据结构与算法之美》、《题海拾贝》、《C++修炼之路》
欢迎点赞,关注!
今天我们来介绍并模拟实现一下哈希表,同时为下一期的unordered_map和unordered_set封装做铺垫。这一期会涉及到很多理论性内容,但是有的内容了解一下就行,还是要以模拟实现哈希表为主。
目录
1、什么是哈希表
1.1、直接定址法
1.2、哈希冲突
1.3、负载因子
1.4、哈希函数
乘法散列法
全域散列法
1.5、处理哈希冲突
开放定址法
线性探测
二次探测
双重散列
2、代码实现基于开放定址法的哈希表
2.1、哈希表
2.2、HashFunc
2.3、插入和__stl_next_prime
2.4、查找
2.5、删除
3、链地址法
4、代码实现基于链地址法的哈希表
4.1、哈希表
4.2、插入
4.3、查找
4.4、删除
4.5、析构
5、静态实现两种方式的哈希表
1、什么是哈希表
哈希表(Hash Table)是一种高效的数据结构,他通过哈希函数将键值映射到表中位置来实现快速地插入,删除,查找操作。他的插入,删除,查找操作时间复杂度都是O(1),最坏情况下(所有的元素都插入到哈希的同一个位置)所有操作时间复杂度退化为O(N)。
接下来介绍一下和哈希表相关的概念:
1.1、直接定址法
这个方法其实大家在过去的学习中或多或少都用过了,就是直接开辟一个数组,把我们想要存储的值映射进去。我们拿力扣的一道题举例:
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
题解:
class Solution {
public:int firstUniqChar(string s) {const int N=123;//‘z’的asc码为122vector<int> a(N);for(int i=0;i<s.size();i++){a[s[i]]++;}for(int i=0;i<s.size();i++){if(a[s[i]]==1) {return i;}}return -1;}
};
当然这道题还有别的解题方式,以上只是其中一种。上面这种解题方式就应用了直接定址法。 也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
1.2、哈希冲突
直接定址法缺点很明显,当两个元素差距过大时(比如1,1000000),会浪费掉很多开辟出来的空间,甚至,如果两个数相差过大,根本没办法开出这么大的数组。
所以我们一般会使用一个哈希函数计算某个值需要存放的位置。但是再好的哈希函数也避免不了哈希冲突,即某两个元素映射的位置是一样的。我们只能尽可能设计出好的哈希函数去避免哈希冲突。
1.3、负载因子
负载因子其实就是已存储的值数量/可装载的值数量(哈希表的大小)。英文名为load factor。负载因子越大,哈希冲突概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。
1.4、哈希函数
哈希函数(英语:Hash function)又称散列算法、散列函数,我们可以通过哈希函数来计算出关键字key的存储位置,也就是说,哈希函数时联系关键值key和映射位置的桥梁。
接下来我们介绍一种常用的方法:除法散列法
除法散列法是一种简单且广泛使用的哈希函数构造方法,它通过将关键字除以某个数并取余数来计算哈希值。哈希函数为:h(key)=key%M。
这种方法的优点是计算简单快捷,实现简单,并且分布表现良好。但是对m的选择有一定要求:当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是2的x次方,那么一个数模这个M实际上是保留这个数二进制位的后x位。如果两个数的二进制后x位相等的话就会发生哈希冲突。10的幂同理。
但是,在java中,除法散列法中的m选用的恰恰是2的幂。为什么呢?
在这里简单说一下,感兴趣的可以自己搜索了解一下,这不是重点:
1、Java 的 HashMap
使用 2 的幂次方作为除数,主要为了 位运算优化 和 扩容效率。
2、扰动函数(hash ^ (hash >>> 16)
) 弥补了非质数可能导致的冲突问题。
3、传统理论建议用质数,但在工程实践中,2 的幂次方 + 扰动函数是性能与均匀性的平衡选择。
我们后续的模拟实现哈希表就是基于以上方法实现。当然还有其他的哈希函数,在这里不做讲解,大家了解一下就可以了:
乘法散列法
乘法散列法是一种经典的哈希函数设计方法,它通过乘法和取模运算将键(Key)映射到哈希表的某个槽位(Bucket)。该方法计算高效,适用于整数或可转换为整数的键,并广泛应用于哈希表、布隆过滤器(Bloom Filter)等数据结构中。
乘法散列法的计算步骤如下:
-
选择一个常数 AA(0<A<10<A<1),通常取一个无理数(如黄金比例 ϕ=5−12≈0.6180339887ϕ=25−1≈0.6180339887)。
-
将键 kk 乘以 AA,提取乘积的小数部分:
hash(k)=⌊m⋅(k⋅Amod 1)⌋hash(k)=⌊m⋅(k⋅Amod1)⌋其中:
-
mm 是哈希表的大小。
-
k⋅Amod 1k⋅Amod1 表示取 k⋅Ak⋅A 的小数部分(即 k⋅A−⌊k⋅A⌋k⋅A−⌊k⋅A⌋)。
-
-
最终哈希值:将小数部分乘以 mm 并向下取整,得到槽位索引。
全域散列法
全域散列法是一种随机化哈希技术,用于解决确定性哈希函数可能面临的最坏情况冲突问题。它通过从一组精心设计的哈希函数中随机选择一个来工作,确保即使对于恶意构造的输入数据,哈希表的平均性能仍然高效。
-
哈希函数族(Universal Family)
定义一个哈希函数集合 HH,其中每个函数 h∈Hh∈H 都能将键 kk 映射到哈希表槽位 {0,1,…,m−1}{0,1,…,m−1}。 -
全域性(Universal Property)
Prh∈H[h(k1)=h(k2)]≤1mh∈HPr[h(k1)=h(k2)]≤m1
对于任意两个不同的键 k1≠k2k1=k2,哈希函数族 HH 必须满足:即,冲突概率不超过 1mm1(mm 为哈希表大小)。
-
运行时随机选择
每次初始化哈希表时,随机选择一个 h∈Hh∈H,使得攻击者无法预测冲突情况。
全域散列可以避免最坏情况,保证平均性能。
1.5、处理哈希冲突
实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种两种方法,开放定址法和链地址法。链地址法我们后面再说。
开放定址法
在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于1的。这⾥的规则有三种:线性探测、二次探测、双重探测。
线性探测
线性探测(Linear Probing)是一种用于解决哈希表中冲突(Collision)的开放寻址法(Open Addressing)策略。当向哈希表中插入一个键值对时,如果哈希函数计算出的目标槽位(Bucket)已被占用,线性探测会按顺序检查下一个槽位,直到找到空槽位为止。
-
哈希函数:首先通过哈希函数计算键(Key)的初始位置:
index=h(key)%表大小 -
冲突处理:如果该位置已被占用,则线性地检查下一个位置(通常是
index + 1, index + 2, ...
),直到找到空槽位。 -
查找操作:查找时同样从初始位置开始线性搜索,直到找到目标键或遇到空槽位(说明键不存在)
二次探测
二次探测是一种用于解决哈希表冲突的开放寻址法(Open Addressing)策略。与线性探测(顺序检查下一个槽位)不同,二次探测通过平方步长(quadratic steps)来寻找下一个可用槽位,从而减少聚集(Clustering)问题,提高哈希表的性能。
-
哈希函数:首先计算键(Key)的初始位置:
index=h(key)%表大小 -
冲突处理:如果该位置已被占用,则按照以下公式探测下一个位置:
new_index=(index+c1⋅i+c2⋅i2)%表大小其中:
-
i 是探测次数(i=1,2,3,…)
-
c1 和 c2是常数(通常 c1=0,c2=1简化计算)
-
最常用的形式是:
new_index=(index+i2)%表大小
-
-
查找操作:查找时同样按照相同方式探测,直到找到目标键或遇到空槽位(说明键不存在)。
双重散列
双重散列是一种用于解决哈希表冲突的开放寻址法(Open Addressing)策略。与线性探测(顺序检查)和二次探测(平方步长)不同,双重散列使用两个不同的哈希函数来计算探测步长,使得数据分布更加均匀,从而减少聚集(Clustering)问题,提高哈希表的性能。
-
第一个哈希函数(Primary Hash):计算键(Key)的初始存储位置:
h1(key)=hash1(key)%表大小 -
第二个哈希函数(Secondary Hash):计算探测步长(用于冲突时寻找下一个位置):
h2(key)=hash2(key)%表大小-
要求:h2(key)≠0,否则会陷入死循环。
-
通常选择 h2(key)使得步长与表大小互质(如表大小为质数时,h2(key)返回
1
到表大小-1
之间的数)。
-
-
冲突处理:如果位置 h1(key) 已被占用,则按以下方式计算下一个位置:
new_index=(h1(key)+i⋅h2(key))%表大小,i=1,2,3,… -
查找操作:查找时按照相同方式探测,直到找到目标键或遇到空槽位(说明键不存在)。
概念很多,有一些概念只做了解大家看看就好。接下来我们开始模拟实现:
2、代码实现基于开放定址法的哈希表
又到了模拟实现的环节。首先说明,在接下来的实现中我们选择最简单的线性探测法去实现一个哈希表。
2.1、哈希表
我们先把哈希表和他节点的大类写出来:
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(size_t n = __stl_next_prime(0)):_tables(n),_n(0){}
private:vector<HashData<K, V>> _tables;size_t _n;//实际存储数据个数
};
因为哈希表实现的时候,我们需要把存储的元素映射成一种可以被模的值,在模版中传入的hashfunc就是用来干这个的。我们一会再实现。
在构造函数中,__stl_next_prime函数是用来指示开辟空间和扩容大小的,我们一会会单独说。
哈希表中存储的每个元素都是包含一个pair类型和一个状态标识。这个状态可以是空,或者已插入,或者已删除。我们把delete标记叫做墓碑标记。在插入的过程中,无论是找到墓碑标记或者空标记,都可以在这个位置进行插入。
哈希表大类的私有成员函数有两个,第一个是存储元素的底层结构vector,第二个是_n,标记存储元素的个数。我们可以利用_n和_tables.size()来计算负载因子。
2.2、HashFunc
template<class K>
struct HashFunc
{size_t operator()(const K& key) const{//写仿函数要加constreturn (size_t)key;}
};//特化template<>
struct HashFunc<string>
{size_t operator()(const string& key) const{size_t hash = 0;for (auto ch : key){hash += ch;hash *= 131;//预防两个不同字符串但是相同字母映射值一样}return hash;}
};
因为要适配不同的元素类型,我们的HashFunc得写成仿函数。同时我们对string这个比较常用的类型进行特化支持。在C++11之前,常用的只有string和内置类型(如int,float,double,int*(根据指针大小比较))支持直接插入,在C++11之后额外支持了pair(pair中的两个元素类型支持哈希),C++20支持了vector<T>(要求T可哈希)。除此之外,还有一些不常用的就不多说了。总之,要想哈希表支持该元素插入,这个元素必须可以通过哈希函数映射并且支持==比较。
另外在哈希函数中我们经常用131作为模数,当然其他的质数(如31,37)也会用。使用这些数可以降低哈希冲突的概率,并且是值的分布更加均匀。这个结论是由先前的大佬们通过大量实验和数学计算得出来的。
2.3、插入和__stl_next_prime
inline unsigned long __stl_next_prime(unsigned long n)
{// Note: assumes long is at least 32 bits.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;
}
......
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first)){return false;//如果有了就不插入了}if ((double)_n / (double)_tables.size() >= 0.7){//如果因子大于0.7就扩容HashTable<K, V, Hash> newht(__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);}}//vector自带的swap是浅拷贝,只是交换一下两者指针的指向_tables.swap(newht._tables);}Hash hs;size_t hash0 = hs(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state == EXIST){hashi = hash0 + i;i++;hashi %= _tables.size();//让他成环,如果超了size就从开始接着找}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;
}
内容很多,我们一点点来看:
首先我们先说插入逻辑,我们先算出理论上的插入下标,接下来走线性探测的逻辑。需要注意的是线性探测时如果探测到末尾了,需要从最开始重新进行探测。在找到可以插入的位置后,进行插入,并更改状态。
接下来我们来看扩容逻辑。扩容是我们调用了一个函数,这个函数是 STL(标准模板库)中用于哈希表扩容时选择下一个合适质数大小的辅助函数。它的核心逻辑是:给定一个数值 n
,返回预定义质数列表中第一个大于或等于 n
的质数。如果 n
超过列表中最大质数,则返回最大的质数。质数表中的质数是成升序增长的,并且每个质数大约是前一个质数的 2 倍左右,保证二倍扩容。在这个函数中调用了lower_bound来查找大于等于n的质数,这里要说一下,lower_bound在算法库中也有一份,这个函数并不是map和set独有的。
我们进入函数先查找当前元素是否插入过,也就是调用find函数,我们一会再实现find函数。
2.4、查找
HashData<K, V>* Find(const K& key)
{Hash hs;size_t hash0 = hs(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;i++;hashi %= _tables.size();}return nullptr;
}
查找函数就是先走线性探测的逻辑,然后找到不为空的位置就返回。
2.5、删除
bool Erase(const K& key)
{HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}
}
删除函数就是对find的复用,没什么好说的。
3、链地址法
上面说了这么多方法,无论哪一种方法都是通过优化哈希函数来尽可能降低哈希冲突概率。那么出了在哈希函数上下功夫,还有什么办法来处理哈希冲突呢?(注意,哈希冲突无法避免)
链地址法也是解决这个问题的一种方式。你不是两个值映射到了一个下标吗,我就让你两个值在一个下标,我在vector中根本就不存值,而是在每个位置下坠一条链表,在链表中存储元素:
那么假设又遇到了极端情况,所有的值都映射到了同一个位置,怎么办呢?此时这个位置的链特别长,查找的话时间复杂度为O(n)。我们可以用全域散列法,这样不会出现所有的值都映射在同一个位置的情况。
除此之外,在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。这也是一种很好的解决方案,并且能够避免使用全域散列法是,偶然情况下某一个链特别长。
4、代码实现基于链地址法的哈希表
4.1、哈希表
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(size_t n= __stl_next_prime(0)):_tables(n,nullptr),_n(0){}
private:vector<Node*> _tables;size_t _n;
};
vector中存储的是节点的地址,每个节点的结构和链表一样,只不过是存储元素换成了pair。
在初始化的时候,我们把vector<Node*>开辟n个空间,并且每个空间都初始化成nullptr。
4.2、插入
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first))return false;Hash hs;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;
}
对于插入操作中的扩容操作,我们也可以像开放定址法那样写:
扩容
HashTable<K, V> newht(__stl_next_prime(_tables.size() + 1));// 遍历旧表,将旧表的数据全部重新映射到新表
for (size_t i = 0; i < _tables.size(); i++)
{Node* cur = _tables[i];while (cur){newht.Insert(cur->_kv);cur = cur->_next;}
}
_tables.swap(newht._tables);
这么写看似是复用了insert,但是效率更低了,为什么呢?因为我们如果这样写的话,会把每个节点都拷贝构造一次。这无疑浪费了许多时间和空间。所以说我们不这么写,而是把之前的节点拿过来,链上去。
4.3、查找
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;
}
4.4、删除
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;}--_n;delete cur;return true;}prev = cur;cur = cur->_next;}return false;}
这个删除操作不能复用find了,因为我们得记录这个节点的前一个节点。
4.5、析构
在开放地址发中,我们不需要写析构函数,因为当我们定义的这个哈希表生命周期结束时,会自动调用vector的析构函数。但是在这里由于我们申请了大量节点(自定义类型变量),需要手动释放。
~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;}
}
5、静态实现两种方式的哈希表
对于哈希表来说我们也可以静态实现。其实大部分数据结构我们都可以静态实现。而且,静态实现一般要更好写一些。但是在工程中,我们一般不用静态实现,因为静态实现会浪费很多内存,并且不够灵活。
静态实现和动态实现的对比:
特性 | 静态实现 | 动态实现 |
---|---|---|
定义 | 编译时确定资源分配和代码结构 | 运行时确定资源分配和代码行为 |
内存分配 | 编译时固定,无法调整 | 运行时动态分配和释放 |
数据结构大小 | 固定大小(如静态数组) | 可变大小(如动态数组、链表) |
函数/方法绑定 | 编译时绑定(静态链接) | 运行时绑定(如虚函数、多态、反射) |
执行效率 | 更高(无运行时解析开销) | 较低(有运行时动态解析开销) |
灵活性 | 低(无法适应运行时变化) | 高(支持插件、热更新等) |
内存管理 | 更简单(无动态分配风险) | 更复杂(可能内存泄漏、碎片化) |
适用场景 | 高性能计算、嵌入式系统、固定行为程序 | 可扩展系统、动态配置、插件架构 |
典型示例 | C 静态数组、静态函数、模板元编程(编译时展开) | Java/C# 反射、Python 动态类型、C++ 虚函数 |
错误检查 | 编译时检查严格,错误更早发现 | 部分错误只能在运行时发现 |
调试难度 | 较容易(行为可预测) | 较难(行为可能动态变化) |
线性探测哈希表:
#include<iostream>
using namespace std;
const int INF = 0x3f3f3f3f;
const int N = 23;//质数
int h[N];
void init()
{memset(h, 0x3f, sizeof(h));
}
int f(int x)
{//找对应存放位置 哈希函数//线性探测法 处理哈希冲突int idx = (x % N + N) % N;//经典操作 模加模while (h[idx] != INF && h[idx] != x)//如果这个位置有值并且不是x的话{idx++;if (idx == N) idx = 0;}return idx;
}
void insert(int x)
{int idx = f(x);h[idx] = x;
}
bool find(int x)
{//查找这个元素是否出现过int idx = f(x);return h[idx] == x;
}int main()
{//模拟哈希表(hash)init();for (int i = 0;i < 5;i++){insert(i);}cout << find(4) << endl;cout << find(10) << endl;
}
链地址法哈希表:
#include<iostream>
using namespace std;
const int N = 23;//质数
int h[N];
int e[N], ne[N], id;
int f(int x)
{int idx = (x % N + N) % N;//经典操作 模加模return idx;
}
void insert(int x)
{int idx = f(x);//元素是从下标为1开始存储的id++;e[id] = x;//为x分配地方//头插ne[id] = h[idx];h[idx] = id;
}
bool find(int x)
{//注意一点,链地址法没有把h所有值初始为INFint idx = f(x);for (int i = h[idx];i;i = ne[i]){if (e[i] == x){return true;}}return false;
}
int main()
{//模拟哈希 链地址法//类似链式前向星实现模拟数for (int i = 1;i < 10;i++){insert(i);}cout << find(5) << " " << find(20) << endl;return 0;
}
好了,今天的内容就分享到这,我们下期再见!