当前位置: 首页 > news >正文

哈希表模拟实现

目录

1.哈希概念

2.直接定址法

2.1 概念

2.2 哈希冲突

2.3 负载因⼦

3.哈希函数

3.1除法散列法/除留余数法

3.2 乘法散列法

3.3 全域散列法

4.处理哈希冲突

4.1开放定址

4.2 链地址法


1.哈希概念

哈希(hash)⼜称散列,是⼀种组织数据的⽅式。本质就是通过哈希函数把关键字Key跟存储位置建⽴⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进⾏快速查找。

2.直接定址法

2.1 概念

当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法,⽐如⼀组关键字都在[0,99]之间,那么开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。 也就是说直接定址法本质就是⽤关键字计算出⼀个绝对位置或者相对位置。

2.2 哈希冲突

直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤。假设我 们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间。
两个不同的key可能会映射到同⼀个位置去,这种问题叫做哈希冲突,或者哈希碰撞。理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的⽅案。

2.3 负载因⼦

假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等,他的英⽂为load factor。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低

3.哈希函数

⼀个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是要尽量往这个⽅向去考量设计

3.1除法散列法/除留余数法

除法散列法也叫做除留余数法,顾名思义,假设哈希表的⼤⼩为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
当使⽤除法散列法时,建议M取不太接近2的整数次,幂的⼀个质数(素数)。
当使⽤除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是 ,那么key %2^X
本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:
{63 , 31}看起来没有关联的值,如果M是16,也就是 2^4,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 10^X,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 10^2,那么计算出的哈希值都是12。

3.2 乘法散列法

乘法散列法对哈希表⼤⼩M没有要求,他的思路第⼀步:⽤关键字 K 乘上常数 A (0<A<1),并抽取出 k*A 的⼩数部分。第⼆步:后再⽤M乘以k*A 的⼩数部分,再向下取整。
哈希函数:h(key) = floor(M × ((A × key)%1.0))
其中floor表⽰对表达式进⾏下取整,A∈(0,1),对于A的值取黄金分割点A =  0.6180339887....
对于这种方法了解即可,实践中使用最多的还是除法散列法,本文实现哈希,利用哈希函数也是除法散列法。

3.3 全域散列法

如果存在⼀个恶意的对⼿,他针对提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集,⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定
的,就可以实现此攻击。这时候就可以给散列函数增加随机性,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
哈希函数:hab (key) = ((a × key + b)%P )%M
P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。
假设P=17,M=6,a = 3, b = 4, 则 。 h34 (8) = ((3 × 8 + 4)%17)%6  = 5
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查
改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,
查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了,同样对于这种方法了解即可

4.处理哈希冲突

实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表⽆论选择什么哈希函数也避免不了冲突,那么插⼊数据时,如何解决冲突呢?主要有两种两种⽅法,开放定址法和链地址法。

4.1开放定址法

在开放定址法中所有的元素都放到哈希表⾥,当⼀个关键字key⽤哈希函数计算出的位置冲突了,则按照某种规则找到⼀个没有存储数据的位置进⾏存储,开放定址法中负载因⼦⼀定是⼩于1的。这⾥的规则有三种:线性探测、⼆次探测、双重探测。

4.1.1开放定址法哈希基本结构

要注意的是这⾥需要给每个存储值的位置加⼀个状态标识,否则删除⼀些值以后,会影响后⾯冲突的值的查找
如下图,假设哈希大小为11,删除30,会导致查找20失败,当我们给每个位置加⼀个状态标识
{EXIST,EMPTY,DELETE} ,删除30就可以不⽤删除值,⽽是把状态改为 DELETE ,那么查找20
时是遇到 EMPTY 才能,就可以找到20。
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 HashTable
{
private:vector<HashData<K, V>> _tables;size_t _n = 0; // 表中存储数据个数
};

这里还有一些问题,对于哈希表来说,存储的是利用哈希函数映射到数组,当key是string/Date等类型时,key不能取模不能利用哈希函数,此时需要给HashTable增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就⽤默认参数即可,如果这个Key不能转换为整形,我们就需要⾃⼰实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同。string做哈希表的key⾮常常⻅,所以可以考虑把string特化⼀下

#include<iostream>
#include<vector>
using namespace std;
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>
{// 字符串转换成整形,可以把字符ascii码相加即可// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的// 这⾥可以⽤上次的计算结果去乘以⼀个质数,这个质数⼀般去31, 131size_t operator()(const string & key){size_t hash = 0;for (auto e : key){hash *= 131;hash += e;}return hash;}
};
template < class K, class V,class Hash = HashFunc<K>>
class HashTable
{
private:vector<HashData<K, V>> _tables;size_t _n = 0; // 表中存储数据个数
};

4.1.2 开放定址法构造

这里的构造直接对_tables开个数组就行,这里就不用写析构,程序结束,Vector内的数据会自动销毁

HashTable():_tables(13), _n(0)
{}

4.1.3线性探测

从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M,hash0位置冲突了,则线性探测公式为:
hc(key,i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M − 1}
因为负载因⼦⼩于1,则最多探测M-1次,⼀定能找到⼀个存储key的位置。
线性探测代码实现
template < class K, class V,class Hash = HashFunc<K>>
class HashTable
{HashData<K, V>* Find(const K& key)//查找{Hash hs;size_t hash0 = hs(key) % _tables.size();size_t hash1 = hash0;size_t i = 1;while (_tables[hash0]._state != EMPTY){if (_tables[hash1]._state == EXIST && _tables[hash1]._kv.first == key){return &_tables[hashi];}// 线性探测hash1 = (hash0 + i) % _tables.size();//线性探测++i;}return nullptr;}
private:vector<HashData<K, V>> _tables;size_t _n = 0; // 表中存储数据个数
};

4.1.4⼆次探测

从发⽣冲突的位置开始,依次左右按⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置。
⼆次探测公式hc(key,i) = hashi = (hash0 ± i^2 ) % Mi = {1, 2, 3, ..., M/2}
⼆次探测当 hashi = (hash0 − i^2 )%M 时,当hashi<0时,需要hashi += M

 二次探测代码实现

	HashData<K, V>* Find(const K& key)//查找{Hash hs;size_t hash0 = hs(key) % _tables.size();size_t hash1 = hash0;size_t i = 1;int falg=1;while (_tables[hash0]._state != EMPTY){if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key){return &_tables[hashi];}// 线性探测hash1 = (hash0 + i*i*falg)% _tables.size();//二次探测if (hash1 < 0){hash1 += _tables.size();}if (falg == 1){++i;falg = -1;}else {falg = 1}}}

4.1.5双重散列

第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
hc(key,i) = hashi = (hash0 + i h2 (key)) % Mi = {1, 2, 3, ..., M}
要求 h2 (key) < Mh2 (key)和M互为质数,有两种简单的取值⽅法:
1、当M为2整数幂时, h2 (key) 从[0,M-1]任选⼀个奇数;
2、当M为质数时,h2 (key) = key % (M − 1) + 1

 这种探测方法了解即可,这里就不进行代码实现

4.1.6 开放定址法insert实现

首先插入,可以利用哈希函数线性探测实现,其次是扩容,扩容的条件就是负载因子太大,这里实现哈希表的负载因子控制在0.7,那扩容怎么扩,扩多大,在上文,对于除法散列法的哈希函数来说,哈希表的大小要控制在不太接近2的整数次,幂的⼀个质数(素数),这里的话就用库里面的质数表每次去质数表获取扩容后的⼤⼩,这里只要能够从质数表中取数据即可

质数表

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;
}

扩容时由于还要进行线性探测进行插入,此时可以复用insert

	bool Insert(const pair<K, V>& kv){if (Find(kv.first))//查找{return false;}if(_n*10/_tables.size()>7){//扩容HashTable<K, V, Hash> newht;newht._tables.resize(__stl_next_prime(_tables.size() + 1));//在质数表中取质数,这里记得+1,不然不会取到扩容后的质数for (size_t i = 0; i < _tables.size(); i++){if(_tables[i]._state==EXIST)//线性探测,复用Insert即可newht.Insert(_tables[i].kv);}_tables.swap(newht._tables);}Hash hs;size_t hash0 = hs(kv.first) % _tables.size();//哈希函数size_t hash1 = hash0;size_t i = 1;//线性探测while (_tables[hash0]._state == EXIST){hash1 = (hash0 + i) % _tables.size();++i;}//插入数据_tables[hash1].kv = kv;_tables[hash1]._state = EXIST;_n++;return true;}

4.1.7 开放定址法find,erase实现

find的实现在上文中线性探测已经实现过,这里就不再叙述,对于erase,由于每个结点有状态,此时利用查找,对查到的数据状态改变即可

bool Erase(const K& key)
{HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;//改变状态return true;}else{return false;}
}

4.2 链地址法

4.2.1 链地址法介绍

对于开放定址法,发现处理哈希冲突,有非常多的探测,而且这些探测有些非常复杂,此时就可以利用链地址法。

链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法或者哈希桶。

 这里的哈希函数,还是用的是除法散列法

4.2.2 链地址法哈希结构实现

这里的哈希结构就可以不需要节点的状态,直接利用一个指针来连接数据即可,由于链地址法也需要使哈希表的大小为质数,还有对于key值是string/Date等类型时,key不能取模不能利用哈希函数,此时需要给HashTable增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,这里和开放定址法一样,不用改变

结构代码实现

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>
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>
{// 字符串转换成整形,可以把字符ascii码相加即可// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的// 这⾥可以⽤上次的计算结果去乘以⼀个质数,这个质数⼀般去31, 131size_t operator()(const string& key){size_t hash = 0;for (auto e : key){hash *= 131;hash += e;}return hash;}
};
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:
private:vector<Node*> _tables; // 指针数组size_t _n = 0; // 表中存储数据个数
};

4.2.3 链地址法构造及析构函数

这里由于vector内部存的是Node*,因此要对此进行析构,而默认构造和开放定址法一样,而这里的拷贝构造由于有指针指向的数据,因此要写一个深拷贝

HashTable():_tables(__stl_next_prime(0)), _n(0)
{}
~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;}
}
HashTable(const HashTable<K,V>& hs)
{_tables.resize(ht._tables.size());_n = ht._n;for (size_t i = 0; i < ht._tables.size(); ++i){Node* cur = ht._tables[i];Node* prev = nullptr;while (cur){Node* newNode = new Node(cur->_kv); // 拷贝kv对if (prev == nullptr){_tables[i] = newNode;}else{prev->_next = newNode;}prev = newNode;cur = cur->_next;}}
}

4.2.4 链地址法insert实现

 对于链地址法的插入数据,就和链表一样,这里利用指针头插就行,对于扩容和开发定址法有所区别,开放定址法负载因⼦必须⼩于1,链地址法的负载因⼦就没有限制了,可以⼤于1。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;这里链地址法的最⼤负载因⼦基本控制在1,⼤于1就扩容。

		bool insert(const pair<K,V>& kv){Hash hs;if (_n ==_tables.size() )//扩容{vector<Node*> newTatble(__stl_next_prime(_tables.size()+1));//直接把旧表的数据头插到新表for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;size_t hashi = cur->hs(_kv.first) % newTatble.size();cur->_next = newTatble[hashi];newTatble[hashi] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newTatble);//交换}size_t hash0 = hs(kv.first) % _tables.size();Node* newnode = new Node(kv);//头插newnode->_next = _tables[hash0];_tables[hash0] = newnode;++_n;return true;}

4.2.5 链地址法find,erase实现

这里的find首先利用哈希函数映射,利用链表向下寻找就行

		Node* Find(const K& key){Hash hs;size_t hash = hs(key) % _tables.size();Node* cur = _tables[hash];while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}reutrn nullptr;}

erase同样利用哈希函数映射,判断,当寻找的节点是头结点时,直接头删,若是中间节点和尾结点时,利用链表的删除就行

bool Erase(const K& key)
{Hash hs;size_t hash = hs(key) % _tables.size();Node* cur = _tables[hash];Node* prev = nullptr;while (cur){if (cur->_kv.first == key){if (prev == nullptr){_tables[hash] = cur->_next;}else{//中间节点prev->_next = cur->_next;}delete cur;--_n;}else{prev = cur;cur=cur->_next}}
}

http://www.lryc.cn/news/598790.html

相关文章:

  • JVM 垃圾收集器CMS和G1
  • HTTP性能优化实战:从协议到工具的全面加速指南
  • 服务端对接 HTTP 接口传输图片 采用base64还是 multipart/form-data
  • 排序初识(上)-- 讲解超详细
  • Android Studio历史版本快速下载(二次修改记录)
  • rna_seq_pipeline.py-python002
  • CloudComPy使用PyInstaller打包后报错解决方案
  • 如何使用 pdfMake 中文字体
  • 【Oracle APEX 】示例应用库无法访问
  • 对称密码算法详解:从DES到AES的加密演进
  • Lua协同程序(coroutine)
  • C11补充
  • 力扣20:有效的括号
  • VirtualBox安装Ubuntu 22.04后终端无法打开的解决方案
  • 在 Ubuntu 20.04 上轻松安装和使用中文输入法
  • 离线进行apt安装的过程(在只能本地传输的ubuntu主机上使用apt安装)
  • 秋叶sd-webui频繁出现生成后无反应的问题
  • 11-day08文本匹配
  • 0724 双向链表
  • Unity 进行 3D 游戏开发如何入门
  • iOS网络之异步加载
  • 医疗设备自动化升级:Modbus TCP与DeviceNet的协议协同实践
  • vue3使用异步加载腾讯地图
  • 低速信号设计之 JTAG 篇
  • Spring Bean生命周期七步曲:定义、实例化、初始化、使用、销毁
  • Datawhale AI夏令营学习笔记:大模型微调与数据处理实践
  • 01_FOC学习之先让电机转动起来
  • 长糖链皂苷的生物合成研究进展-文献精读149
  • FreeRTOS—计数型信号量
  • Unity UI的未来之路:从UGUI到UI Toolkit的架构演进与特性剖析(3)