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

C++ 数据结构之哈希表及其相关容器

一.unordered_map/map和unordered_set/set

1.unordered_set与set

unordered系列是原set/map的改良,它们在不同的场景有其不同的应用。接下来我们从底层实现,参数,使用等方面简述它们的异同。

1.底层实现

unordered_set与set最直接的区别,我们从名称上其实就看的出来——前者是无序的,而后者是有序的。这一点最直接的差异就来源于它们的底层实现是不同的:unordered_set底层由哈希表实现,增删查改效率为O(1),但由于其哈希函数的特点所以会导致数据并不有序。而set底层是红黑树(一种二叉搜索树),迭代器中序遍历的结果是一个有序的序列。特别需要注意的是,set的迭代器是双向迭代器,而unordered系列的容器均是单向迭代器。

2.参数

对于unordered_set,它的参数明显比set更多。

• unordered_set的声明如下,Key就是unordered_set底层关键字的类型

• unordered_set默认要求Key⽀持转换为整形,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现⽀

持将Key转成整形的仿函数传给第⼆个模板参数

• unordered_set默认要求Key⽀持⽐较相等,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现⽀持

将Key⽐较相等的仿函数传给第三个模板参数

• unordered_set底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给

第四个参数。

• ⼀般情况下,我们都不需要传后三个模板参数

template < class Key, //
unordered_set::key_type/value_type
class Hash = hash<Key>, // unordered_set::hasher
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;

3.使用

• 查看⽂档我们会发现unordered_set的⽀持增删查且跟set的使⽤⼀模⼀样,关于使⽤我们这⾥就不再赘述和演⽰了。

• unordered_set和set的第⼀个差异是对key的要求不同,set要求Key⽀持⼩于⽐较,⽽

unordered_set要求Key⽀持转成整形且⽀持等于⽐较,要理解unordered_set的这个两点要求得

后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求。

• unordered_set和set的第⼆个差异是迭代器的差异,set的iterator是双向迭代器,unordered_set

是单向迭代器,其次set底层是红⿊树,红⿊树是⼆叉搜索树,⾛中序遍历是有序的,所以set迭代

器遍历是有序+去重。⽽unordered_set底层是哈希表,迭代器遍历是⽆序+去重。

具体的使用与性能差异可以用以下用例

#define _CRT_SECURE_NO_WARNINGS 1#include<iostream>
#include<set>
#include<unordered_set>using namespace std;void test_set1()
{unordered_set<int> s = { 3,1,6,7,8,2,1,1,5,6,7,6 };unordered_set<int>::iterator it = s.begin();while (it != s.end()){cout << *it << " ";++it;}cout << endl;
}void test_set2()
{const size_t N = 10000000;unordered_set<int> us;set<int> s;vector<int> v;v.reserve(N);srand(time(0));for (size_t i = 0; i < N; ++i){//v.push_back(rand()); // N比较大时,重复值比较多//v.push_back(rand() + i); // 重复值相对少v.push_back(i); // 没有重复,有序}size_t begin1 = clock();for (auto e : v){s.insert(e);}size_t end1 = clock();cout << "set insert:" << end1 - begin1 << endl;size_t begin2 = clock();us.reserve(N);for (auto e : v){us.insert(e);}size_t end2 = clock();cout << "unordered_set insert:" << end2 - begin2 << endl;cout << "插入数据个数:" << s.size() << endl;cout << "插入数据个数:" << us.size() << endl << endl;int m1 = 0;size_t begin3 = clock();for (auto e : v){auto ret = s.find(e);if (ret != s.end()){++m1;}}size_t end3 = clock();cout << "set find:" << end3 - begin3 << "->" << m1 << endl;int m2 = 0;size_t begin4 = clock();for (auto e : v){auto ret = us.find(e);if (ret != us.end()){++m2;}}size_t end4 = clock();cout << "unorered_set find:" << end4 - begin4 << "->" << m2 << endl;size_t begin5 = clock();for (auto e : v){s.erase(e);}size_t end5 = clock();cout << "set erase:" << end5 - begin5 << endl;size_t begin6 = clock();for (auto e : v){us.erase(e);}size_t end6 = clock();cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
}

结果:

2.unordered_map与map

1.底层实现

map底层是红黑树实现,unordered_map是哈希表实现,他们带来的差异上面已经介绍过,这里不再赘述。

2.参数

• 查看⽂档我们会发现unordered_map的⽀持增删查改且跟map的使⽤⼀模⼀样,关于使⽤我们这

⾥就不再赘述和演⽰了。

• unordered_map和map的第⼀个差异是对key的要求不同,map要求Key⽀持⼩于⽐较,⽽

unordered_map要求Key⽀持转成整形且⽀持等于⽐较,要理解unordered_map的这个两点要求

得后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求

• unordered_map和map的第⼆个差异是迭代器的差异,map的iterator是双向迭代器,

unordered_map是单向迭代器,其次map底层是红⿊树,红⿊树是⼆叉搜索树,⾛中序遍历是有

序的,所以map迭代器遍历是Key有序+去重。⽽unordered_map底层是哈希表,迭代器遍历是

Key⽆序+去重。

template < class Key,                                    // unordered_map::key_typeclass T,                                      // unordered_map::mapped_typeclass Hash = hash<Key>,                       // unordered_map::hasherclass Pred = equal_to<Key>,                   // unordered_map::key_equalclass Alloc = allocator< pair<const Key,T> >  // unordered_map::allocator_type> class unordered_map;

3.使用

可以用以下用例测试两者性能

pair<iterator,bool> insert ( const value_type& val );
size_type erase ( const key_type& k );
iterator find ( const key_type& k );
mapped_type& operator[] ( const key_type& k );

3.map/set与unordered_map/unordered_set总结

1.使用与特性 

基于底层实现的不同,它们在用法和特性上表现出巨大差异。

特性set / mapunordered_set / unordered_map
元素顺序有序(按key排序)无序(顺序不确定,取决于哈希函数)
操作时间复杂度查找、插入、删除:O(log n)平均 O(1),最坏 O(n)
迭代器稳定性迭代器始终有效(除非指向元素被删除)Rehash 后迭代器会失效
内存占用通常较少(树节点开销)通常较高(需要维护桶数组,可能存在空闲桶)
所需键类型要求必须定义 < 操作符或提供自定义比较函数必须定义 == 操作符和可计算哈希值(或提供自定义函数)
使用场景需要元素有序;需要按顺序遍历;需要最小/最大元素需要极快的查找速度,且不关心顺序

关键点详解:

  1. 有序 vs 无序:

    • 遍历一个 set<int> s{5, 1, 4, 2, 3},输出顺序是 1, 2, 3, 4, 5

    • 遍历一个 unordered_set<int> us{5, 1, 4, 2, 3},输出顺序可能是任意顺序,例如 3, 5, 1, 4, 2,并且每次编译器的版本或者容器的状态不同都可能变化。

  2. 性能差异:

    • 在大多数情况下,unordered_* 的查找速度远快于 *。如果你的主要操作是查找,并且数据量很大(例如超过10万个元素),unordered_map 的性能优势会非常明显。

    • 但是,unordered_* 的 O(1) 是“平均常数时间”,其性能依赖于哈希函数的质量和负载因子(load factor)。如果哈希函数很差,导致冲突很多,性能会下降。而 map 的 O(log n) 是非常稳定的。

  3. 迭代器失效:

    • 对于 map,插入和删除操作通常不会使已有的迭代器失效(除了当前被删除元素的迭代器)。

    • 对于 unordered_map,插入操作可能导致 rehash(即扩容,重新分配桶数组并重新映射所有元素)。一旦发生 rehash,所有的迭代器都会失效。这是一个非常重要的区别,在遍历过程中插入元素的风险很高。

2.参数与使用选择

方面set / map (红黑树)unordered_set / unordered_map (哈希表)
底层结构红黑树哈希表
排序性有序无序
查找效率稳定,O(log n)平均极快,O(1),最坏O(n)
内存通常更紧凑通常有额外开销
迭代器失效更安全(除非删除,否则不失效)不安全(rehash 后全部失效)
关键要求键必须可比较 (<)键必须可哈希且可相等比较 (==)

如何选择:

  • 你需要元素的顺序吗?

    •  -> 选择 set 或 map

    •  -> 优先考虑 unordered_set 或 unordered_map,尤其当查找性能是关键时。

  • 你更关心最坏情况下的性能还是平均性能?

    • 最坏情况(如实时系统)-> 稳定的 set 或 map 更可靠。

    • 平均情况 -> unordered_set 或 unordered_map 通常更快。

  • 你的键类型是什么?

    • 如果是自定义类型,你需要为 set/map 提供比较函数(重载 < 或自定义仿函数),或者为 unordered_set/unordered_map 提供哈希函数和相等比较函数(重载 == 或自定义仿函数)。后者通常更麻烦一些。

二.哈希与哈希表

从上面的介绍我们得知,unordered系列的容器底层由哈希表实现,于是我们就深入了解以下哈希与哈希表。

1.哈希的概念

哈希(Hash)即散列,它可以看作一种设计理念,后续我们讲到的布隆过滤器也是用哈希表实现的。它的核心思想就是将存储的数据Key以某种映射关系映射到空间上便于存储。查找时根据Key即可查到相应正确的位置。

2.哈希表

哈希表的概念比较零碎,不过我们大致可以分为以下几点:以何种规则进行映射(哈希函数的选取),处理哈希冲突的方式(各显神通)。首先来讲一些专业术语吧。

1.直接定址法

当一些待处理的数据范围较为集中时,使用这种方法是最简单高效的。⽐如⼀组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。再⽐如⼀组关键字值都在[a,z]的⼩写字⺟,那么我们开⼀个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。也就是说直接定址法就是用关键字Key计算出一个绝对位置或者相对位置来进行数据的存储,其实这样的思想我们早在计数排序中用到过。

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

2.哈希冲突

直接定址法的缺点也⾮常明显,当关键字的范围⽐较分散时,就很浪费内存甚⾄内存不够⽤。假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这⾥要注意的是h(key)计算出的值必须在[0, M)之间。

这里存在一个很明显的现象,两个不同的key可能会被映射到同一个地方,这种现象叫做哈希冲突。这是哈希表中无法避免的一个现象,但我们需要尽可能地减少哈希冲突地产生(通过控制较好的哈希函数)以及发生了哈希冲突合理的解决办法。

3.负载因子

若一个哈希表中存储了N个数据,而开了M个空间,那么可以定义负载因子(load factor)为\frac{N}{M}。从感觉上可以意识到,负载因子越大,空间地利用率越高,但却越容易发生哈希冲突。我们后续需要根据负载因子这个重要地性质处理哈希表。

4.将关键字转为整数

其实上面讲直接定址法会发现一个比较鸡肋的事情,那就是这种结构只能用来处理整型的数据。。。不过这也侧面让我们理解到,若想要处理不同类型的数据,就都需要将关键字转换为整数去处理。因此unordered_set/map中有Hash这样的模板参数。

template < class Key, //
unordered_set::key_type/value_type
class Hash = hash<Key>, // unordered_set::hasher
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;

5.哈希函数

一个好的哈希函数应该将N个数据均匀分散地分布映射在M个空间中,然而现实中却很难实现这个目的,我们需要尽量的将哈希函数向理想化靠近。

1.除留余数法/除法散列法

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

在利用除法散列法进行处理时,应该尽量避免使用2的幂次方或者10的幂次方作为M。比如:

很显然这两个是不同的数,但他们的后16位是相同的。因此他们采用2^16及以下的M都会映射到同一个位置。。对于10的幂次方则更明显,11223和123%100均会得到23这个位置。因此建议在取M时取一个不太接近2的整数次幂的一个素数

Java的HashMap其实底层采用的就是2的整数次幂,但它不会单单只做这一步操作。例如:M是2^16次⽅,本质是取后16位,那么⽤key’ =key>>16,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。

在计算hashi时这样处理:通过hash&(2^n-1)-1取到后n位,为了使更多位参与运算,令前32-n位异或。

size_t HashFunc(const K& key)
{size_t hash = key & (_tables.size() - 1);hash ^= (hash >> (32_m));return hash;
}
2.乘法散列法

 乘法散列法对哈希表⼤⼩M没有要求,他的⼤思路第⼀步:⽤关键字 K 乘上常数 A (0<A<1),并抽

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

其中floor表⽰对表达式进⾏下取整,A∈(0,1),这⾥最重要的是A的值应该如何设定,Knuth认为 (⻩⾦分割点])⽐较好。h(key) = floor(M × ((A × key)%1.0))A = ( 5 - 1)/2 = 0.6180339887....

乘法散列法对哈希表⼤⼩M是没有要求的,假设M为1024,key为1234,A = 0.6180339887, A*key

= 762.6539420558,取⼩数部分为0.6539420558, M×((A×key)%1.0) = 0.6539420558*1024 =

669.6366651392,那么h(1234) = 669。

3.全域散列法

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

,P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查

改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,

查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。

6.处理哈希冲突

在实践中一般还是选择除法散列法进行处理,但是无论选择什么哈希函数都无法完全根除冲突的问题,因此对哈希冲突采取合理的处理方式也十分重要。

1.开放地址法

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

从发生冲突的位置开始依次向后寻找,直到找到有空位的空间。若设初始映射的空间为hash0,则hash0=key%M,依次寻找的空间即为hashi=(hash0+i)%M(i=1,2,3...)。因为负载因子小于1,所以至多经过M-1次探测一定可以找到空闲位置。

线性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,

hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位

置,这种现象叫做群集/堆积。下⾯的⼆次探测可以⼀定程度改善这个问题。

下面是一个序列为{19,30,5,36,13,20,21,12},M=11的例子。

线性探测的实现

首先我们来定义数据类型。每个空间应该存储一个pair类型的数据,并且整体的哈希表用vector来表示就十分方便。

enum State
{EXIST,EMPTY,DELETE
};template<class K, class V>
struct HashData
{pair<K, V> _kv;State _state = EMPTY;
};

但是这里选择引入一个枚举类型State,并且给出了三种状态:EXIST,EMPTY,DELETE。这又是什么用意呢?假设我们不设置这个State值:

如果此时将下标为9处的30删除,那么这个位置就为空。但是20这个值是产生了哈希冲突之后被挤到下标为10的位置的(它原本应该在9这个位置,但被30霸占了),此时再去查询20这个数其实是查不到的,因为查找的结束条件肯定为遇到空结束否则会一直死循环。因此State状态值的作用就体现出来了:在遇到因DELETE当前位置为空时会继续查找,但是遇到EMPTY就不会再查找了。这样就解决了这个问题。

接着我们再来看插入,查找和删除的逻辑。最重要的其实是插入的逻辑:线性探测的过程不必多说,最需要注意的是这里的扩容问题。什么情况下需要扩容?由大佬们的研究发现当负载因子超过0.7的时候就极容易发生大量的哈希冲突。因此我们需要在负载因子=0.7左右进行扩容。需要注意的是,这里的newht是一个临时变量,为了防止出作用域销毁,我们使用swap函数交换资源。

template<class K, class V>
class HashTable
{
public:HashTable():_tables(__stl_next_prime(0)),_n(0){}//这一坨数据是为了扩容时找到一个最符合需求的数并且是素数//因为上面的讲解提到了,用除法散列法最好模一个离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;}bool Insert(const pair<K, V>& kv){    //这里的哈希表是不支持重复数据的if (Find(kv.first))return false;// 负载因子 >= 0.7扩容if (_n*10 / _tables.size() >= 7){HashTable<K, V> newht;//newht._tables.resize(_tables.size() * 2);//先预留空间newht._tables.resize(__stl_next_prime(_tables.size()+1));//遍历旧表,相当于做了一个深拷贝的工作//值得注意的是这里的逻辑复用了insert,为的是后续其他探测方法的实现   for (auto& data : _tables){// 旧表的数据映射到新表if (data._state == EXIST){newht.Insert(data._kv);}}//交换_tables.swap(newht._tables);}//先找到当前key的原始位置hash0size_t hash0 = kv.first % _tables.size();size_t hashi = hash0;size_t i = 1;int flag = 1;//这里就是哈希冲突的过程while (_tables[hashi]._state == EXIST){// 线性探测,取模是解决回绕问题hashi = (hash0 + i) % _tables.size();++i;}//插入数据_tables[hashi]._kv = kv;//修改状态_tables[hashi]._state = EXIST;//修改数据个数++_n;return true;}//查找的逻辑是返回结点信息,包括pair类型和状态值StateHashData<K, V>* Find(const K& key){size_t hash0 = 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){ret->_state = DELETE;return true;}else{return false;}}private:vector<HashData<K, V>> _tables;size_t _n;  // 记录数据个数
};

二次探测

上面的线性探测很容易会出现堆积/群集的现象,原因就出在每次在进行探测是都查找相邻的位置,如果采用二次的探测方式,散列程度会越来越大,比起线性探测的争抢位置现象稍微有所缓解。因此我们需要修改的只有线性探测的部分,其他部分可以直接复用。

• 从发⽣冲突的位置开始,依次左右按⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置;

• , hash0位置冲突了,则⼆次探测公式为:

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

• 下⾯演⽰ {19,30,52,63,11,22} 等这⼀组值映射到M=11的表中。

二次探测的实现

size_t hash0 = kv.first % _tables.size();
size_t hashi = hash0;
size_t i = 1;
int flag = 1;
while (_tables[hashi]._state == EXIST)
{//这里的flag用于控制正负hashi = (hash0 + (i*i*flag)) % _tables.size();//因为flag会让hashi变负,变负会回绕//为了正确地回绕,加上当前地数据个数即可if (hashi < _tables.size())hashi += _tables.size();if (flag == 1){flag = -1;}else{++i;flag = 1;}
}
2.处理key不为整数的情况

由上面可知,不管是取模运算也好,右移操作也罢,这都是基于处理的key是一个整数的基础。但现实中我们需要处理很多key不为整数的情况,例如double,string甚至自定义类型,这时候又怎么办呢?现在再回看unordered系列容器的定义又会有新发现——使用仿函数。

template < class Key, //
unordered_set::key_type/value_type
class Hash = hash<Key>, // 将不为整数的key转为key
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;

既然要用仿函数处理,HashTable内部我们要做一些修改。也就是涉及到取模运算的位置(线性探测等操作)需要修改。例如这里kv.first用仿函数hash去处理。

Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
int flag = 1;

接着我们修改HashTable的参数,需要把hash这个仿函数传进来

template<class K, class V, class Hash = HashFunc<K>>
class HashTable{
.....
};

然后我们来写这个仿函数。在使用时我们可以自己显式传仿函数,但我们不妨用一下前面学到的知识——模板特化,这样针对string等特殊的场景可以特殊处理。

template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};template<>
struct HashFunc<string>
{size_t operator()(const string& s){// BKDRsize_t hash = 0;for (auto ch : s){    //这里不难看出我们是把string每一个字符的ascii码相加//但这样很容易发生冲突,比如abcd和aadd得到的哈希值相同//于是采用大佬的处理方法,每相加一个字符,就乘一个数(这里选了131)//这样即使是相同字符不同顺序得到的哈希值也不同,很好的避免了冲突hash += ch;hash *= 131;}return hash;}
};

不难看出,我们这里的HashTable参数中的Hash做了一个第一层映射的工作,第二层工作才是把key映射到存储空间中。

3.处理值相等的问题

因为unordered系列的容器需要实现Find函数,因此我们可能需要去对相等逻辑进行复写。但是这个工作不方便直接对源代码进行修改,所以也跟上面处理key不为整数一样使用仿函数处理即可。于是就有了unordered系列中的模板参数。

template < class Key, //
unordered_set::key_type/value_type
class Hash = hash<Key>, // 将不为整数的key转为key
class Pred = equal_to<Key>, // 处理相等逻辑
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;

就拿Date类做例子

template<class K>
struct DateEqual {bool operator()(const K& rhs){return _year == rhs.year && _month == rhs.month && _day == rhs.day;}
};
4.开放地址的完整实现
#pragma once
#include<vector>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>
{size_t operator()(const string& s){// BKDRsize_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}
};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;
}namespace open_address
{template<class K, class V, class Hash = HashFunc<K>>class HashTable{public:HashTable():_tables(__stl_next_prime(0)), _n(0){}bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;// 负载因子 >= 0.7扩容if (_n * 10 / _tables.size() >= 7){//vector<HashData<K, V>> newtables(_tables.size()*2);//for (auto& data : _tables)//{//	// 旧表的数据映射到新表//	if (data._state == EXIST)//	{//		size_t hash0 = data._kv.first % newtables.size();//		// ...//	}//}//_tables.swap(newtables);HashTable<K, V, Hash> newht;//newht._tables.resize(_tables.size() * 2);newht._tables.resize(__stl_next_prime(_tables.size() + 1));for (auto& data : _tables){// 旧表的数据映射到新表if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;int flag = 1;while (_tables[hashi]._state == EXIST){// 线性探测hashi = (hash0 + i) % _tables.size();++i;/*hashi = (hash0 + (i*i*flag)) % _tables.size();if (hashi < _tables.size())hashi += _tables.size();if (flag == 1){flag = -1;}else{++i;flag = 1;}*/}_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){ret->_state = DELETE;return true;}else{return false;}}private:vector<HashData<K, V>> _tables;size_t _n;  // 记录数据个数};
}

3.哈希桶及其实现

哈希桶(HashBucket)又称链地址法,它与开放地址法最大的区别就在于他们处理哈希冲突的方式。不同于开放地址法的各种探测解决冲突,哈希桶更像"摆烂"一样,如果遇到key处理后发生冲突,直接链接在当前vector下。换句话说,这里哈希桶的哈希表是一个指针数组,它的下面链接一个个的链表。需要注意的是,库中实现的哈希桶会在链表过长时转换为红黑树,这里不做过多的解释,仅实现链表形式。

1.哈希桶的细节

1.哈希桶的哈希表是一个vector<Node*>类型,表中直接存储的是它下面挂着的链表的头结点地址。这里不采用vector<list<>>类型是考虑到上面说的转换为红黑树的情况,并且迭代器也不好处理,把代码写死是不太好的。

2.哈希桶在发生冲突时进行结点链接,头插法是个非常好的办法,效率是非常高的(O(1))。但需要注意的是,这里的链表实际上使用的是单链表,这也导致了unordered系列容器的迭代器是单向迭代器。

3.哈希桶的扩容也是个有趣的现象,我们会发现上面的开放地址法,数据会直接向表中存储,负载  因子的概念控制着的它的表长;而对于哈希桶,就算发生冲突也可以一直链接到当前位置,但是一条链太长会导致Find的效率极差(实际上这时就会转为红黑树),因此这里控制的负载因子=1时才需要扩容。

4.有一个实际写插入和扩容逻辑时才能说清楚,我们先基本实现一下哈希桶的框架。

template<class T>
struct HashNode
{T _data;HashNode<T>* _next;HashNode(const T& data):_data(data), _next(nullptr){}
};template<class K, class T, class KeyOfT, class Hash>
class HashTable
{// 友元声明template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>friend struct HTIterator;typedef HashNode<T> Node;
public:typedef HTIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;typedef HTIterator<K, T, const T&, const T*, KeyOfT, Hash> ConstIterator;pair<Iterator, bool> Insert(const T& data)
{KeyOfT kot;Iterator it = Find(kot(data));if (it != End())return { it, false};Hash hash;// 负载因子 == 1时扩容if (_n == _tables.size()){/*HashTable<K, V> newht;newht._tables.resize(__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);*/vector<Node*> newTable(__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 = hash(kot(cur->_data)) % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newTable);}size_t hashi = hash(kot(data)) % _tables.size();// 头插Node* newnode = new Node(data);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return { Iterator(newnode, this), false };
}
};

5.在写扩容时我们需要考虑:是否应该和开放地址法的扩容逻辑复用Insert一样?实际上可以是可以,但是并不好用。因为对于vector这个表来说它会调用自己的析构函数,但这里的Node*是一个自定义类型,析构了vector之后会剩下一堆没有析构的原链表结点,这反而更麻烦。于是这里扩容时,我们可以先新建一个表,然后把旧表的结点重新计算后链接在新表下即可。

扩容及插入详细逻辑:

a.创建新表

新表的大小是利用素数表确定的,便于key的处理

vector<Node*> newTable(__stl_next_prime(_tables.size() + 1));

b.遍历旧表的每一个桶

for (size_t i = 0; i < _tables.size(); i++)
{Node* cur = _tables[i];// ...
}

c.单个处理桶里的每一个结点

注意这里要存每个结点的下个结点地址,因为当前结点会被移动到新表中,不存next会导致找不到下一个的位置

while (cur)
{Node* next = cur->_next;  // 保存下一个结点的地址size_t hashi = hash(kot(cur->_data)) % newTable.size();  // 重新计算哈希位置// 头插到新表的对应桶中cur->_next = newTable[hashi];newTable[hashi] = cur;cur = next;  // 移动到下一个结点
}

d.清空旧表的桶

_tables[i] = nullptr;

e.交换新旧表资源,防止临时变量出作用域销毁

_tables.swap(newTable);

2.哈希桶的完整实现

#pragma once
#include<vector>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>
{size_t operator()(const string& s){// BKDRsize_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}
};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;
}namespace open_address
{template<class K, class V, class Hash = HashFunc<K>>class HashTable{public:HashTable():_tables(__stl_next_prime(0)), _n(0){}bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;// 负载因子 >= 0.7扩容if (_n * 10 / _tables.size() >= 7){//vector<HashData<K, V>> newtables(_tables.size()*2);//for (auto& data : _tables)//{//	// 旧表的数据映射到新表//	if (data._state == EXIST)//	{//		size_t hash0 = data._kv.first % newtables.size();//		// ...//	}//}//_tables.swap(newtables);HashTable<K, V, Hash> newht;//newht._tables.resize(_tables.size() * 2);newht._tables.resize(__stl_next_prime(_tables.size() + 1));for (auto& data : _tables){// 旧表的数据映射到新表if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;int flag = 1;while (_tables[hashi]._state == EXIST){// 线性探测hashi = (hash0 + i) % _tables.size();++i;/*hashi = (hash0 + (i*i*flag)) % _tables.size();if (hashi < _tables.size())hashi += _tables.size();if (flag == 1){flag = -1;}else{++i;flag = 1;}*/}_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){ret->_state = DELETE;return true;}else{return false;}}private:vector<HashData<K, V>> _tables;size_t _n;  // 记录数据个数};
}namespace hash_bucket
{template<class T>struct HashNode{T _data;HashNode<T>* _next;HashNode(const T& data):_data(data), _next(nullptr){}};// 前置声明template<class K, class T, class KeyOfT, class Hash>class HashTable;template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>struct HTIterator{typedef HashNode<T> Node;typedef HashTable<K, T, KeyOfT, Hash> HT;typedef HTIterator<K, T, Ref, Ptr, KeyOfT, Hash> Self;Node* _node;const HT* _ht;HTIterator(Node* node, const HT* ht):_node(node),_ht(ht){}Ref operator*(){return _node->_data;}Ptr operator->(){return &_node->_data;}bool operator!=(const Self& s){return _node != s._node;}// 16:46Self& operator++(){if (_node->_next){// 当前桶还有数据,走到当前桶下一个节点_node = _node->_next;}else{// 当前桶走完了,找下一个不为空的桶KeyOfT kot;Hash hash;size_t hashi = hash(kot(_node->_data)) % _ht->_tables.size();++hashi;while (hashi < _ht->_tables.size()){_node = _ht->_tables[hashi];if (_node)break;else++hashi;}// 所有桶都走完了,end()给的空标识的_nodeif (hashi == _ht->_tables.size()){_node = nullptr;}}return *this;}};template<class K, class T, class KeyOfT, class Hash>class HashTable{// 友元声明template<class K, class T, class Ref, class Ptr, class KeyOfT, class Hash>friend struct HTIterator;typedef HashNode<T> Node;public:typedef HTIterator<K, T, T&, T*, KeyOfT, Hash> Iterator;typedef HTIterator<K, T, const T&, const T*, KeyOfT, Hash> ConstIterator;Iterator Begin(){if (_n == 0)return End();for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];if (cur){return Iterator(cur, this);}}return End();}Iterator End(){return Iterator(nullptr, this);}ConstIterator Begin() const{if (_n == 0)return End();for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];if (cur){return ConstIterator(cur, this);}}return End();}ConstIterator End() const{return ConstIterator(nullptr, this);}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;}}pair<Iterator, bool> Insert(const T& data){KeyOfT kot;Iterator it = Find(kot(data));if (it != End())return { it, false};Hash hash;// 负载因子 == 1时扩容if (_n == _tables.size()){/*HashTable<K, V> newht;newht._tables.resize(__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);*/vector<Node*> newTable(__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 = hash(kot(cur->_data)) % newTable.size();cur->_next = newTable[hashi];newTable[hashi] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newTable);}size_t hashi = hash(kot(data)) % _tables.size();// 头插Node* newnode = new Node(data);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return { Iterator(newnode, this), false };}Iterator Find(const K& key){KeyOfT kot;Hash hash;size_t hashi = hash(key) % _tables.size();Node* cur = _tables[hashi];while (cur){if (kot(cur->_data) == key){return Iterator(cur, this);}cur = cur->_next;}return End();}bool Erase(const K& key){KeyOfT kot;size_t hashi = key % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (kot(cur->_data) == key){if (prev == nullptr){// 头结点_tables[hashi] = cur->_next;}else{// 中间节点prev->_next = cur->_next;}delete cur;--_n;return true;}else{prev = cur;cur = cur->_next;}}return false;}private:vector<Node*> _tables; // 指针数组size_t _n = 0;		   // 表中存储数据个数//struct Data//{//	ListNode* _head;//	RBTreeNode* _root;//	size_t _len;  // <= 8 存链表,>8 存红黑树 //};//vector<Data> _tables;//size_t _n = 0;		   // 表中存储数据个数//struct Data//{//	list<pair<K, V>> _list;//	map<K, V> _map;//	size_t _len;  // <= 8 存链表,>8 存红黑树 //};//vector<Data> _tables;//size_t _n = 0;		   // 表中存储数据个数};
}

http://www.dtcms.com/a/360619.html

相关文章:

  • LeetCode 3459.最长 V 形对角线段的长度:记忆化搜索——就一步步试
  • 【开题答辩全过程】以 家庭理财管理系统的设计与实现为例,包含答辩的问题和答案
  • mit6.031 2023spring 软件构造 笔记 Testing
  • 自定义创建Linux内核Tracepoint
  • git的子模块讲解
  • mmaction安装的详细说明帖
  • 【ArcGIS微课1000例】0150:如何根据地名获取经纬度坐标
  • 基于springboot的摄影器材租赁回收系统
  • 疯狂星期四文案网第56天运营日记
  • LeetCode 36. 有效的数独 - 解题思路与实现详解
  • arnold图像加密(猫脸变换)
  • AIGC应用与实践 - 实验3:使用豆包生成播客
  • 赵玉平《刘备谋略》读书笔记(上部)
  • zookeeper集群是什么技术, 有什么作用
  • 第三阶梯:变动感知——在流沙之上,建造你的灯塔
  • 在开发过程中经常遇到 OOM(内存溢出)问题,如何解决?
  • __getitem__()方法的神奇
  • 【LeetCode修行之路】算法的时间和空间复杂度分析
  • 2000w 的数据量,mysql要进行几次IO操作,为什么
  • GEE 实战:Landsat 5 月度 NDVI 数据插值填补(以 8 月为例)_后附完整代码
  • sting模拟实现
  • 前后端联合实现多个文件上传
  • FastAPI 教程:构建高性能异步 API 服务
  • 石化设备健康管理平台:工业智能化转型的关键使能技术​
  • std::thread详解
  • Spring Boot单体项目整合Nacos
  • C++17 折叠表达式(Fold Expressions)详解
  • ConcurrentHashMap在扩容的过程中又有新的数据写入是怎么处理的
  • 《Bishop PRML》10.1 (3) 理解VAE reconstruction loss
  • Redis 中的 Bitmap 与 Bitfield 及 Java 操作实践