哈希表的模拟实现---C++
一 底层结构
unordered系列的容器为什么查找的效率会那么高呢?是因为其底层使用了哈希结构。
1.1 哈希概念
顺序结构以及平衡树中,其数据与它的储存位置没有任何对应关系,所以要找到数据,就得通过关键码一个个的去比较。
顺序地查找时间复杂度是O(N),平衡树由于其结构的特殊性,查找的次数是他的高度次,就是O(log n),时间复杂度与其查找的次数有关。
理想的搜索方式:可以 不经过任何的比较,一次性就可以找到其对应的值,如果构造一种 存储结构,通过某种 函数(hashfunc),可以通过该元素的存储位置和他的关键码能够建立---- 映射的关系,那么查找的时候很快就能找到对应的数据。当向该结构体中:插入元素 :根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
搜索元素 :对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
这个方法的就做到每个关键码就有唯一的数据,因此查找的也就是特别快。
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
答:会出现哈希值冲突的问题.(接下来就会介绍)
1.2 哈希冲突
对于两个数据元素的关键字k_i和 k_j(i != j),有k_i != k_j,但有:Hash(k_i) == Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?——你占了我的位置,我就去占别人的位置(在下面讲到实现的时候会说)
1.3 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间;
哈希函数计算出来的地址能均匀分布在整个空间中;
哈希函数应该比较简单。
常见哈希函数
1. 直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2. 除留余数法--(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key% p(p将关键码转换成哈希地址)
3. 平方取中法--(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
4. 折叠法--(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这 几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
5. 随机数法--(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中 random为随机数函数。
通常应用于关键字长度不等时采用此法
6. 数学分析法--(了解)
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定 相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只 有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散 列地址。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的 若干位分布较均匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
1.4 哈希冲突解决
解决哈希冲突我们使用开放定址法来解决(闭散列)。
1.4.1 开放定址法
当发生冲突的时候,如果哈希表内并没有装满,那么就一定有空余的位置,那么就可以在冲突的地方找它的下一个空位置,那么如何找它的下一个空位置呢?
1 线性探测
比如在上面那一张图的场景中,如果我要插入一个数44,那么经过上面的方法过后处理出来应该是放在4的位置,但是那个位置是被原先的4给占用了,那么我就要从冲突的那个地方线性探测找到空位置的地方为止。
通过哈希函数获取待插入元素在哈希表中的位置。
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突, 使用线性探测找到下一个空位置,插入新元素。
删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
enum State {EXIST,EMPTY,DELETE };
2 线性探测的实现
那么线性探测该怎么实现呢?
哈希数据结构体实现:
template<class K,class V> struct HashData {pair<K, V> _kv;State _state = EMPTY; };
我们的数据首先得是链表形式,所以我们先用类模板创建一个哈希数据的结构体,这次我们就用key-values的模型,然后我们还要设置它的状态,我们就给他赋个缺省值为空。
哈希函数
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 hashi= 0;for (auto ch : key){hashi *= 131;hashi += ch;}return hashi;} };
我们在这里设置的函数就直接用开放地址法直接对传过来的值强转成size_t类型,那如果传过来的是string类型,那么我们就特化一下,对每个字符提取出来,然后用字符的ASCII码值乘以131,然后用hashi来增加,这样能够很大程度上避免冲突的问题。
哈希表的定义
class HashTable {private:vector<HashData<K,V>> _tables;size_t _n;//表中存储数据个数 };
这里我们就用std库里的vector来充当我们的底层结构,我们的哈希链表就存放到这个顺序表里就好了,_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 };inline unsigned long __stl_next_prime(unsigned long n) {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(size_t size = __stl_next_prime(0)):_n(0),_tables(size) { }
构造函数里面hashtable上面的是用来取质数的数组和内联函数,因为取质数来作为表的大小对哈希表的冲突情况来说是可以有效减少的,通过给size的值来设置表的大小,_n是数据的个数保证tables是永远比_n要大的。
插入
bool Insert(const pair<K, V>& kv) {// 0.7负载因子就开始扩容if ((double)_n / (double)_tables.size() >= 0.7){//vector<HashData<K, V>> newtables(_tables.size()*2);遍历旧表,重新映射//for (size_t i = 0; i < _tables.size(); i++)//{// if (_tables[i]._state == EXIST)// {// //...// }//}//HashTable<K, V> newHT(_tables.size()*2);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);}_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) % _tables.size();++i;}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true; }
插入的时候,第一步我们得先看一下数据是否存在,存在直接返回false,倘若不存在,我们就继续下一步,我们将当前元素个数除于表的有效空间个数,如果其占比大于等于0.7,我们就要对表进行扩容。我们就按两倍来进行扩容,并将状态为EXIST的进行复用我们的插入操作,最后将两表进行交换,我们的扩容操作就完成了。到这里有的同学会问,为什么我们只插入状态为EXIST的呢?不是还有个DELETE吗,你不是刚说过DELETE会影响查找吗?——这是因为我们一旦扩容,我们的有效表空间大小就会变,我们的哈希计算出来的结果也是会变的,就相当于大家的一次集体搬家。所以我们就只要关注存在的数据就好了。
在搞定我们的扩容这一步之后,就到了我们的正式插入过程了。
我们首先要先创建一个仿函数对象,然后计算它的哈希值,如果当前位置有人了,我们就往后找,重复此操作就可以了。别忘了取余表的有效空间,不然哈希值很可能是大于表的空间的。找到空位后插入数据,设置状态就可以了。
查找
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]._kv.first == key && _tables[hashi]._state != DELETE){return &_tables[hashi];}hashi = (hash0 + i) % _tables.size();++i;}return nullptr; }
查找函数的编写就容易的多了,我们先来个仿函数对象,用来计算它的哈希值,如果当前位置不为空且key值对不上的话,就一直往后找,找到了就返回这个哈希数据就可以了。
删除
bool Erase(const K& key) {HashData<K, V>* ret = Find(key);if (ret) {ret->_state = DELETE;return true;}else{return false;} }
删除操作就更简单了,我们只需要找到要删除的那个数据就好了,所以我们可以复用查找的代码,如果找到了,我们就将它的状态码设置成DELETE删除状态,如果没有找到这个数据,我们就直接返回false就可以了。