当前位置:首页 » 《资源分享》 » 正文

C++笔记---哈希表

6 人参与  2024年11月05日 10:01  分类 : 《资源分享》  评论

点击全文阅读


1. 哈希的概念

哈希(hash)又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。

本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找

STL中的unordered_set和unordered_map的底层就是对哈希表的封装。

图书馆中的书就类似于使用哈希的方式进行存储的,当你在图书馆找书时,你通过想找的书的类型(key)判断出其所在的分区(哈希函数计算出地址),就可以很快地将搜索范围锁定到一块区域。

在图书馆找书本来是一件大海捞针似的事,但是由于书是按分区存放的(书的类型与存放地址产生了联系),我们就可以很迅速地找到想要的书。

在数据结构中,存储位置通常是指存储数据的数组的下标。

所以,支持存储到哈希表中的key一定是整形或可以发生隐式类型转换为整形。

如果需要存储的类型不支持隐式类型转换为整形,或者在转换时会丢失大量数据,我们就需要自己实现哈希函数。

下面介绍一点学习哈希的必备概念:

1.1 直接定址法

当关键字范围比较集中时,直接定址法是最简单高效的一种哈希方式,常常会在算法题中使用到这种思想。

比如一组关键字都在[0,99]之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。

也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。

在计数排序中,这一方法就得到了应用,在下面这道oj题中也有妙用:. - 力扣(LeetCode)

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

直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。

假设我们只有数据范围是[0, 9999]的N个值,我们要映射到一个M个空间的数组中(一般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的hf(key)位置。

这里要注意的是hf(key)计算出的值必须在[0, M)之间。

1.2 哈希冲突

这里存在的一个问题就是,两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。

理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的。

所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。

1.3 负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么"负载因子 = N/M",负载因子在有些地方也翻译为载荷因子/装载因子等,他的英文为load factor。

负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

2. 哈希函数

一个好的哈希函数应该让样本空间中的关键字被等概率的均匀的散列分布到哈希表的M个空间中,以减少哈希冲突,实际中却很难做到,但我们要尽量往这个方向去考量设计。

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

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:hf(key) = key % M

当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等,而尽量取素数。

假如M为2^{n},在32位环境下对key进行取模运算之后,相当于key的二进制的前32-n位被舍弃了,只保留了零头。这使得原本不相同的数只需要零头相同就能计算出相同的哈希值,大大增加了冲突概率。

使用素数作为哈希表的数组空间长度是为了利用素数的数学特性来优化哈希函数的性能,减少碰撞,并提高整体的数据分布均匀性。

尽管在某些情况下,非素数的哈希表也可以表现良好,尤其是当使用高质量的哈希函数时,但素数仍然是一个经典和广泛推荐的做法。

说到使用高质量哈希函数的非素数哈希表,这里就不得不提到Java中的HashMap。

Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key' = key>>16,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,任何一位的不同都有可能导致哈希值的不同,这样映射出的哈希值就会更加多样,均匀一些。

2.2 乘法散列法

乘法散列法的思路就是:用一个和key相关的[0,1)之间的小数与M相乘,即可的到在[0,M)之间的与key相关的哈希值。

其中,与key相关的[0,1)之间的小数通过key与一个小数常数相乘取小数部分得到。

第⼀步:用关键字 key 乘上常数 A (0<A<1),并抽取出 k*A 的小数部分。

第⼆步:后再用M乘以k*A 的小数部分,再向下取整。

即,哈希函数为"hf(key) = floor(M × ((A × key)%1.0))"。

其中floor表示对表达式进行向下取整,A∈(0,1),这里最重要的是A的值应该如何设定,Knuth(算法界大佬)认为A = (\sqrt{5} - 1) / 2 = 0.6180339887... (黄金分割点)比较好。

乘法散列法对哈希表大小M是没有要求的。

2.3 全域散列法(简单了解)

• 如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。

• hf(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了。

3. 哈希冲突地解决方式

哈希冲突无法被避免,我们只能尝试去解决它,常见的解决方式有两种:开放定址法和链地址法。

3.1 开放定址法

开放定址法(Open Addressing)是一种处理哈希冲突的方法,当两个或更多的键通过哈希函数映射到同一个槽(哈希地址)时,开放定址法通过在哈希表中寻找下一个空槽来解决冲突。这种方法不使用额外的存储结构,而是在哈希表内部解决冲突。

开放定址法的基本思想是,如果一个键的哈希地址所在的槽已被占用,就按照预定的策略在哈希表中寻找下一个可用的槽。

这种策略通常包括线性探测、二次探测、双重散列等多种形式。在这些策略中,线性探测是最简单的一种,它按照固定的增量顺序检查后续的槽;二次探测则使用递增的平方数作为增量;双重散列使用两个或以上的散列函数来确定探测的步长。

开放定址法的关键在于处理删除操作时的特殊情况。由于开放定址法中的槽可能会被多次探测,简单地删除一个元素可能会导致后续插入或查找操作中断开,因此通常需要设置一个特殊的标志来指示一个槽虽然被标记为删除,但仍然参与探测过程。

就好比我们通过书的类型计算出的地址是某个分区的首位,但此处已经放上了书,那么我们就可以在其后的位置(该区的下一个空位)放下这本书。在找书时依然按照相同的策略,也可以很快找到书,且冲突被化解了。

但是在依次查找书时,不能因为遇到了空槽就认为要查找的书没有被存放,而不继续向下寻找,因为空槽的位置可能在存书时存在,而在取书之前先被别人取走了。 

3.1.1 线性探测

从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。

hf(key) = hash0 = key % M ,hash0位置冲突了,则线性探测公式为:hc(key, i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M - 1} ,因为负载因子小于1,则最多探测M-1次,一定能找到一个存储key的位置。

线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下面的二次探测可以一定程度改善这个问题。

下面演示 {19,30,5,36,13,20,21,12} 等这一组值映射到M=11的表中。

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1

3.1.2 二次探测 

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。

hf(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为:hc(key, i) = hashi = (hash0 ± i2) % M, i = {1, 2, 3, ..., M/2}

二次探测当 hashi = (hash0 - i2)%M 时,当hashi<0时,需要hashi += M

下面演示 {19,30,52,63,11,22} 等这一组值映射到M=11的表中。

 h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0

 3.1.3 双重散列

第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

h1(key) = hash0 = key % M, hash0位置冲突了,则双重探测公式为:

hc(key, i) = hashi = (hash0 + i ∗ h2(key)) % M, i = {1, 2, 3, ..., M}

要求h2(key) < M 且h2(key)和M互为质数,有两种简单的取值方法:

1、当M为2整数幂时,h2(key) 从[0,M-1]任选一个奇数;

2、当M为质数时, h2(key) = key % (M - 1) + 1

保证h2(key)与M互质是因为,假如偏移量刚好是散列表大小的因数,那么在每次寻址到末尾回到开头时都会是同一个位置,导致形成一个循环,只有固定的几个地址能被寻到。

举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为12/gcd(12, 3) = 4

但如果h2(key)与M互质的话,每次回到开头时都不会与上一轮重复。

例如将偏移量改为5,那么所能寻址的位置为{1, 6, 11, 4, 9, 2, 7, 0, 5, 10, 3, 8},每一个位置都有可能寻到。

下面演示 {19,30,52} 等这一组值映射到M=11的表中,设 h2(key) = key%10 + 1

3.1.4 线性探测哈希表代码实现示例
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;}// 哈希函数采用除留余数法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 e : key){hash *= 131;hash += e;}return hash;}};template<class K>struct Equal{bool operator()(const K& key1, const K& key2){return key1 == key2;}};// 以下采用开放定址法,即线性探测解决冲突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 Compare = Equal<K>>class HashTable{public:HashTable():_tables(__stl_next_prime(0)), _n(0){}HashTable(const HashTable<K, V, Hash, Compare>& hashtable): _tables(hashtable._tables), _n(hashtable._n){}bool Insert(const pair<K, V>& kv){// 扩容if (_n == _tables.size()){HashTable<K, V, Hash> newHashTable;newHashTable._tables.resize(__stl_next_prime(_n + 1));for (size_t i = 0; i < _n; i++){if (_tables[i]._state == EXIST)newHashTable.Insert(_tables[i]._kv);}swap(_tables, newHashTable._tables);}size_t pos = _hash(kv.first) % _tables.size();while (_tables[pos]._state == EXIST){if (_com(_tables[pos]._kv.first, kv.first))return false;pos = (pos + 1) % _tables.size();}_tables[pos]._kv = kv;_tables[pos]._state = EXIST;++_n;return true;}HashData<K, V>* Find(const K& key){size_t pos = _hash(key) % _tables.size();while (_tables[pos]._state != EMPTY){if (_com(_tables[pos]._kv.first, key) && _tables[pos]._state != DELETE)return &_tables[pos];pos = (pos + 1) % _tables.size();}return nullptr;}bool Erase(const K& key){if (HashData<K, V>* p = Find(key)){(*p)._state = DELETE;--_n;}return false;}private:vector<HashData<K, V>> _tables;size_t _n = 0;  // 表中存储数据个数Hash _hash;Compare _com;};}

在上面的代码中,M的取值采取了STL中的实现方式,即在一个素数表中取值。

注意:扩容必须要将原表的数据重新插入新表中,因为M发生了变化,哈希函数计算出的地址也会发生变化。 

 3.2 链地址法

开放定址法中所有的元素都放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。

下面演示 {19,30,5,36,13,20,21,12,24,96} 等这一组值映射到M=11的表中。

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1, h(24) = 2, h(96) = 88

相比于开放地址法,链地址法不仅更加直观而且更加高效,实际当中的应用更多。

哈希桶的叫法实际上十分形象,每一个链表就是一个桶,我只需要知道把数据存到那个桶和到那个桶中去寻找需要的数据即可。

这种解决冲突的方式就与图书馆的例子更加贴合了,一个分区就是一个桶,我们在图书馆中找书,能锁定的就只是一个分区而已,但这无疑大大提高了我们找书的效率。

3.2.1 扩容时机

开放定址法中,负载因子一定小于1,表满就扩容,这是必然的。

但是在链地址法中,链表中可以存储无数的数据,负载因子没有限制。

负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;stl中unordered_xxx的最大负载因子基本控制在1,大于1就扩容,我们下面实现也使用这个方式。

3.2.2 极端场景

如果极端场景下,某个桶特别长,查找效率很低怎么办?这里在Java8的HashMap中当桶的长度超过一定阀值(8)时就把链表转换成红黑树。一般情况下,不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下即可。

3.2.3 链地址哈希表代码实现示例
// 以下采用链地址法namespace linked_address{template<class K, class V>struct HashNode{pair<K, V> _kv;HashNode* _next;HashNode(const pair<K, V>& kv):_kv(kv), _next(nullptr){}};template<class K, class V, class Hash = HashFunc<K>, class Compare = Equal<K>>class HashTable{typedef HashNode<K, V> Node;typedef HashTable<K, V, Hash, Compare> Self;public:HashTable():_tables(__stl_next_prime(0)), _n(0){}HashTable(const Self& hashtable):HashTable(){for (auto cur : hashtable._tables){while (cur){Insert(cur->_kv);cur = cur->_next;}}}Self& operator=(Self hashtable){swap(_tables, hashtable._tables);swap(_n, hashtable._n);return *this;}~HashTable(){for (auto cur : _tables){while (cur){Node* next = cur->_next;delete cur;cur = next;}}}bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;// 负载因子等于1,扩容if (_n == _tables.size()){vector<Node*> newTables(__stl_next_prime(_n + 1));// 原结点依次插入新表for (size_t i = 0; i < _n; i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;size_t pos = _hash(cur->_kv.first) % _tables.size();cur->_next = newTables[pos];newTables[pos] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newTables);}size_t pos = _hash(kv.first) % _tables.size();Node* newnode = new Node(kv);newnode->_next = _tables[pos];_tables[pos] = newnode;++_n;return false;}Node* Find(const K& key){size_t pos = _hash(key) % _tables.size();Node* cur = _tables[pos];while (cur && !_com(cur->_kv.first, key)) { cur = cur->_next; }return cur;}bool Erase(const K& key){size_t pos = _hash(key) % _tables.size();if (_tables[pos] == nullptr)return false;Node* del = nullptr;if (_com(_tables[pos]->_kv.first, key)){del = _tables[pos];_tables[pos] = _tables[pos]->_next;}else{Node* parent = _tables[pos];while (parent->_next && !_com(parent->_next->_kv.first, key)){parent = parent->_next;}if (parent->_next == nullptr)return false;del = parent->_next;parent->_next = parent->_next->_next;}delete del;--_n;return true;}private:vector<Node*> _tables;size_t _n = 0;  // 表中存储数据个数Hash _hash;Compare _com;};}


点击全文阅读


本文链接:http://zhangshiyu.com/post/182603.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1