【数据结构进阶】哈希表
🌟🌟作者主页:ephemerals__
🌟🌟所属专栏:数据结构
目录
前言
一、哈希表的概念
二、哈希函数的实现方法
1. 直接定址法
2. 除留余数法
三、哈希冲突
1. 开放定址法(闭散列)
线性探测
二次探测
双重探测
2. 链地址法(开散列)
四、装填因子
五、哈希表的实现
1. 线性探测法实现
整形转化的支持与字符串哈希
初始大小与扩容策略
哈希表结构定义
构造函数
析构函数
哈希表的插入
哈希表的查找
哈希表的删除
总代码
2. 链地址法实现
整形转化支持、大小设置
哈希表结构定义
构造函数
析构函数
哈希表的插入
哈希表的查找
哈希表的删除
总代码
总结
前言
之前我们学习了红黑树,其虽然通过自平衡机制提供了高效的查找、插入和删除操作,但其操作仍依赖于树的高度,最坏情况下的时间复杂度为O(log n)。当我们希望能够实现常数时间复杂度的查找操作时,哈希表便成为了一个更优的选择。哈希表虽然不再像红黑树的元素一样默认有序,但其通过 “ 哈希 ” 将数据进行映射,从而在理想情况下实现O(1)时间复杂度的操作,在某些需要高效查找的场景非常实用。本篇文章,我们将深入学习哈希表的相关知识,并尝试实现。
一、哈希表的概念
哈希表(Hash Table),也叫做散列表,是一种特殊的数据结构。其通过一定的规则将数据进行 “ 散乱排列 ” , 并通过该规则进行快速查找。这个规则叫做 “ 哈希函数 ” ,其可以将数据转化为一个固定的索引值,后续将该数据根据索引值存储在数组的某个位置。当进行查找时,直接通过 “ 哈希函数 ” 求出数据的索引值,理想状态下就能在常数时间内完成查找。
二、哈希函数的实现方法
在理想状态下,哈希函数会将数据均匀散列分布在哈希表当中。为了尽量逼近这个理想状态,先辈们发明了多种哈希函数的实现方法,如直接定址法、除留余数法、乘法散列法、全域散列法等。其中直接定址法和除留余数法最为常用,我们重点介绍一下。
1. 直接定址法
当数据的范围比较集中时,优先考虑使用直接定址法。例如有一组数据的范围在0~99之间,那么我们开一个大小为100的数组,然后将数据按照数组下标来存储。比如这组数据当中有3个“6”,那么数组下标为6处的值就是3。
那么当这组数据的下限非0呢?处理方法也很简单。例如这组数据的范围是26个小写英文字母,ascii值在97~122之间。我们开一个大小为26的数组,然后每一个字母对应存储位置的下标即为其ascii值减去'a'的ascii值,这样就可以进行存储了。
当然直接定址法的缺陷也很明显:当数据比较分散且范围很大时,会造成空间浪费。
2. 除留余数法
除留余数法(也叫做除法散列法),是哈希函数最常用的实现方式。它的核心思想是将数据对某个数(一般是表长)进行求余运算,将运算结果作为索引值,即表示该数据存放的下标位置。假设某个哈希表的大小为M,那么哈希函数为:hash(x) = x % M。
如图所示,各个元素通过除留余数法存储在哈希表当中:
注意以下两点:
1. 为了尽量减少哈希冲突出现的次数,建议M取不太接近2的整数次幂的一个素数。
2. 除留余数法中,为了方便数据转化为索引值,要求哈希表的数据元素支持转换为整数。
三、哈希冲突
当一个数据通过哈希函数产生了与另一个数据相同的哈希值,那么也就意味着它们需要存储在同一个位置,显然无法满足。此时的情况就叫做哈希冲突(哈希碰撞)。理想情况下,数据会均匀分布在哈希表中,但是实际场景当中,哈希冲突是无法避免的。所以我们要有适当的方案处理哈希冲突。
处理哈希冲突的方法主要有开放定址法、链地址法、再哈希法和位图法等,这里只介绍最常用的前两种。
1. 开放定址法(闭散列)
开放定址法是按照某种策略将冲突的数据放置在表中的另一个空位当中。常见的策略有三种:线性探测、二次探测和双重探测。
线性探测
线性探测指的是从冲突位置开始,依次向后一个一个查找,直到找到空位,再将冲突元素放置在当前空位。 当查找到表尾还没有找到空位,那么从表头位置开始重新查找。
线性探测的缺点是容易造成表中的冲突数据聚集,且会占用其他元素的原本位置,从而影响效率。
二次探测
二次探测对线性探测进行了优化。它的查找方式是依次左右按照二次方跳跃式探测。例如先探测冲突位置的右边 个位置,然后左边
个位置,右边
个位置,左边
个位置......依次类推。
二次探测有效地减少了数据聚集问题。
双重探测
双重探测指的是使用另一个哈希函数计算探测步长,使探测序列更加分散。
2. 链地址法(开散列)
链地址法,也叫做拉链法,它改变了传统的哈希表元素储存策略。在链地址法当中,所有数据不再直接存储在哈希表当中,而是将哈希表的每一个元素设置为指针,用于维护一个链表。此时哈希表的每一个元素就叫做哈希桶。当某个元素位置不存储元素时,该指针为空;若有元素需要存储在该位置,则在其维护的链表中添加该元素。因此,一条链表中存储的元素互相之间就是发生哈希冲突的。
链地址法在发生冲突时,就不会占用其他元素的原本位置,相比开放定址法效率较高。但当某个位置的冲突元素过多时,链表过长,会使查找效率接近O(N),此时可以考虑将链表换为红黑树。
Java8以及之后的HashMap实现当中,就采用了如上优化:当某个哈希桶维护的链表长度超过8时,将该链表换成红黑树,提升总体性能。
四、装填因子
装填因子(负载因子)是一个数值,表示已存储元素与哈希表容量的比值。当装填因子过大时,哈希表的空间利用率就越高,发生哈希冲突的概率就越高,哈希表的总体性能就越低。
在实际实现当中,为了保证哈希表的高效,我们将装填因子作为哈希表是否需要扩容的标准。当装填因子到达一定阈值时,哈希表就需要扩容。若哈希表使用开放定址法处理冲突,那么该阈值通常在0.7~0.8之间;若使用链地址法,则该阈值通常为1。
五、哈希表的实现
接下来我们将选用除留余数法的哈希函数,并用线性探测和链地址法两种解决冲突的策略分别实现一个哈希表。至于其他方法,这里就不再一一实现。
与之前实现AVL树和红黑树相同,我们的实现当中将键值对作为数据元素,其哈希值由键Key来决定。
1. 线性探测法实现
整形转化的支持与字符串哈希
之前提到哈希表的数据元素要支持转化为整形,所以在这里我们需要实现一个仿函数模板,用仿函数对象将内置类型进行转化,并且对于不能转化为整形的类型,可以用特化来支持。
代码如下:
//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围{return (size_t)(key);}
};
除了内置类型,字符串(string)作为键值存储在哈希表中的场景较多,我们实现一个特化版本支持字符串转换为整形。
传统的字符串转整形可以将所有字符的ASCII值相加,但这种方法转换出的整形值可能重复率较高(例如"abc"和"acb"),更容易导致冲突。所以这里我们使用BKDR哈希的思路:每一个字符累加之后,总累加值乘以一个素数(通常是131或31),这样就能对每个字符进行加权。最后的累加值即为转化后的整形值。
注意:当字符串过长时,多次相乘可能会导致溢出,但无需考虑溢出问题,因为我们只要确定能得到唯一的整形值即可。
代码如下:
//针对string的特化版本
template<>
class ToInt<string>
{
public:size_t operator()(const string& s){size_t ret = 0;for (auto& c : s){ret += c;ret *= 131;}return ret;}
};
初始大小与扩容策略
使用除留余数法时,为了降低哈希冲突的概率,哈希表的大小(也就是除数M)的选择十分重要,应取不太接近2的整数次幂的一个素数。那么应该如何设置哈希表的初始大小及其扩容后的大小呢?这里我们直接采用SGI STL哈希表源码中的大小设置策略:
inline size_t __stl_next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const size_t __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 size_t* first = __stl_prime_list;const size_t* last = __stl_prime_list + __stl_num_primes;const size_t* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}
可以看到,函数创建了一个大小为28的数组,其中的数据即是哈希表每次扩容的大小(53、97、193...以此类推)。函数接收一个参数n,并返回数组中第一个不小于n的元素。而第一个元素53就可以作为我们哈希表的初始大小,接下来实现构造函数时调用该函数即可。
哈希表结构定义
接下来是线性探测法哈希表及其数据元素的结构定义:
//元素的状态表示
enum State
{EMPTY,//空EXIST,//存在DELETE//已删除
};//数据元素
template<class K,class V>
struct HashData
{pair<K, V> _kv;//键值对State _state = EMPTY;//状态
};//哈希表
template<class K, class V, class ToInteger = ToInt<K>>
class HashTable
{
public:
private:vector<HashData<K, V>> _tables;//使用vector表示哈希表size_t _n = 0;//记录已有元素个数ToInteger _toi;//仿函数对象
};
结构定义当中,我们使用了vector用于存储数据,其中元素是一个HashData,HashData包含一个键值对和状态表示(空、存在和已删除)。另外成员n和toi分别用于计算装填因子和整形转化。
构造函数
构造函数当中,我们只需要设置哈希表的初始大小。代码如下:
//构造函数
HashTable()
{_tables.resize(__stl_next_prime(1));
}
传入参数1,则函数__stl_next_prime返回不小于1的第一个数组元素,也就是53,然后用它调整vector的大小。
析构函数
析构函数不需要我们显式实现,编译器默认生成的析构函数会调用vector的析构,进行数据销毁。
哈希表的插入
哈希表插入的大致过程如下:
1. 首先判断表中是否已有相同键值元素,若有则插入失败(不允许键值重复)。
2. 判断装填因子的值是否过大(这里我们判断是否超过0.7),若过大,哈希表需要扩容。
3. 根据哈希函数进行插入,并处理冲突。
需要注意的是:扩容之后,哈希表的大小(即除数M)会发生变化,之后进行插入或查找操作时,根据新的M进行插入/查找原有元素就会出现问题。所以当哈希表扩容之后,需要将其中的所有元素都拿出来重新根据哈希函数插入在哈希表当中,使其符合新哈希表的除数M。(数据重排)
代码实现:
//插入
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first))//如果表中已有相同键,则插入失败返回false{return false;}if ((double)_n / (double)_tables.size() >= 0.7)//当装填因子大于0.7,则需要扩容{size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后的大小//创建新的哈希表,并调整大小HashTable<K, V, ToInteger> newHT;newHT._tables.resize(newSize);//遍历原哈希表,将有效数据插入到新哈希表中for (size_t i = 0; i < _tables.size(); i++){if (_tables[i]._state == EXIST)//存在说明是有效数据{//直接调用Insert函数本身进行插入//此时已经扩容,装填因子一定不会超过阈值,不会再次走到这里newHT.Insert(_tables[i]._kv);}}//最后交换两个哈希表的tables_tables.swap(newHT._tables);}//开始插入//除留余数法计算哈希值size_t hash0 = _toi(kv.first) % _tables.size();size_t i = 1;size_t hashi = hash0;while(_tables[hashi]._state == EXIST)//该位置已有元素,说明发生了哈希冲突,循环向后寻找{hashi = (hash0 + i) % _tables.size();//相比直接++,这样设置能确保找到末尾还能从头开始寻找i++;}//找到了不出现冲突的位置,进行插入_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;_n++;return true;
}
可以看到,在进行扩容时,我们巧妙地创建了一个新的哈希表,并调用Insert方法本身进行数据的重新插入,减少了代码冗余。
哈希表的查找
查找时的逻辑与插入过程大体相同,同样使用哈希函数求出索引值,然后用线性探测法进行查找(注意按键查找)。代码如下:
HashData<K, V>* Find(const K& key)
{//除留余数法计算哈希值size_t hash0 = _toi(key) % _tables.size();size_t i = 1;size_t hashi = hash0;//没有走到空,则一直查找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;
}
这里需要注意:由于线性探测法一定会使得冲突的数据在表中连续排列,所以我们查找时,如果在线性探测的过程中出现了空位,说明一定查找失败,就无需遍历整个哈希表了。
哈希表的删除
删除的大致过程也很简单(注意按键删除):
如果表中不存在该元素,则删除失败。
如果存在该元素,直接将元素状态修改为DELETE,然后调整已有元素个数即可。
代码如下:
//删除
bool Erase(const K& key)
{//首先进行查找HashData<K, V>* ret = Find(key);//没找到,删除失败if (ret == nullptr){return false;}else//找到了,删除{ret->_state = DELETE;//修改元素状态_n--;//修改已有元素个数return true;}
}
总代码
哈希表线性探测版本的全部代码如下:
#include <iostream>
#include <utility>
#include <vector>
#include <string>
using namespace std;//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围{return (size_t)(key);}
};//针对string的特化版本
template<>
class ToInt<string>
{
public:size_t operator()(const string& s){size_t ret = 0;for (auto& c : s){ret += c;ret *= 131;}return ret;}
};//元素的状态表示
enum State
{EMPTY,//空EXIST,//存在DELETE//已删除
};//数据元素
template<class K, class V>
struct HashData
{pair<K, V> _kv;//键值对State _state = EMPTY;//状态
};//哈希表
template<class K, class V, class ToInteger = ToInt<K>>
class HashTable
{
public://构造函数HashTable(){_tables.resize(__stl_next_prime(1));}//插入bool Insert(const pair<K, V>& kv){if (Find(kv.first))//如果表中已有相同键,则插入失败返回false{return false;}if ((double)_n / (double)_tables.size() >= 0.7)//当装填因子大于0.7,则需要扩容{size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后的大小//创建新的哈希表,并调整大小HashTable<K, V, ToInteger> newHT;newHT._tables.resize(newSize);//遍历原哈希表,将有效数据插入到新哈希表中for (size_t i = 0; i < _tables.size(); i++){if (_tables[i]._state == EXIST)//存在说明是有效数据{//直接调用Insert函数本身进行插入//此时已经扩容,装填因子一定不会超过阈值,不会再次走到这里newHT.Insert(_tables[i]._kv);}}//最后交换两个哈希表的tables_tables.swap(newHT._tables);}//开始插入//除留余数法计算哈希值size_t hash0 = _toi(kv.first) % _tables.size();size_t i = 1;size_t hashi = hash0;while(_tables[hashi]._state == EXIST)//该位置已有元素,说明发生了哈希冲突,循环向后寻找{hashi = (hash0 + i) % _tables.size();//相比直接++,这样设置能确保找到末尾还能从头开始寻找i++;}//找到了不出现冲突的位置,进行插入_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;_n++;return true;}//查找HashData<K, V>* Find(const K& key){//除留余数法计算哈希值size_t hash0 = _toi(key) % _tables.size();size_t i = 1;size_t hashi = hash0;//没有走到空,则一直查找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 == nullptr){return false;}else//找到了,删除{ret->_state = DELETE;//修改元素状态_n--;//修改已有元素个数return true;}}
private:inline size_t __stl_next_prime(size_t n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const size_t __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 size_t* first = __stl_prime_list;const size_t* last = __stl_prime_list + __stl_num_primes;const size_t* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}vector<HashData<K, V>> _tables;//使用vector表示哈希表size_t _n = 0;//记录已有元素个数ToInteger _toi;//仿函数对象
};
2. 链地址法实现
整形转化支持、大小设置
对于进行整形转化的仿函数和哈希表的初始大小设置,我们继续沿用之前线性探测法的实现。
//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围{return (size_t)(key);}
};//针对string的特化版本
template<>
class ToInt<string>
{
public:size_t operator()(const string& s){size_t ret = 0;for (auto& c : s){ret += c;ret *= 131;}return ret;}
};
inline size_t __stl_next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const size_t __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 size_t* first = __stl_prime_list;const size_t* last = __stl_prime_list + __stl_num_primes;const size_t* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}
哈希表结构定义
接下来是链地址法哈希表的结构定义:
//链表节点
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 ToInteger = ToInt<K>>
class HashTable
{typedef HashNode<K, V> Node;
public:
private:vector<Node*> _tables;size_t _n = 0;//记录已有元素个数ToInteger _toi;//仿函数对象
};
这里我们创建了一个指针数组,每一个指针都用于维护一个链表。其次,成员n与toi的作用和线性探测实现中相同。由于链地址法中的数据都是通过链表存储,所以这里就无需对每个元素进行状态表示。
构造函数
构造函数的实现与线性探测法相同,注意初始化时将表中的所有指针制为空。
代码如下:
//构造函数
HashTable()
{_tables.resize(__stl_next_prime(1), nullptr);
}
析构函数
与线性探测法实现不同,由于每个哈希桶都维护一个单链表,所以析构时需要我们手动释放这些链表所占内存。 遍历哈希表,逐个销毁即可。代码如下:
//析构函数
~HashTable()
{//循环遍历所有哈希桶for (size_t i = 0; i < _tables.size(); i++){//使cur指向当前哈希桶Node* cur = _tables[i];//循环销毁当前链表中的所有节点while (cur){Node* next = cur->_next;delete cur;cur = next;}//将当前哈希桶制空_tables[i] = nullptr;}
}
哈希表的插入
链地址法哈希表的插入大致过程:
1. 与线性探测一样,首先判断是否已有相同键值。
2. 判断装填因子是否大于1,如果大于1就需要扩容。(扩容仍需注意数据重排)
3. 通过哈希函数找到对应的哈希桶,进行插入。
需要注意两点:
1. 这里的插入过程涉及单链表的插入,为了提高效率,我们将使用头插法。
2. 由于数据存储在链表节点当中,扩容之后,进行数据重排时释放节点再创建节点就过于麻烦,所以我们遍历所有链表,将节点连接到新的哈希表中对应位置即可。
代码实现:
//插入
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first))//已有相同键,插入失败{return false;}if (_n >= _tables.size())//装填因子的值超过1,需要扩容{size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后大小//创建新的vector,然后进行数据重排vector<Node*> newV(newSize, nullptr);for (size_t i = 0; i < _tables.size(); i++)//遍历所有哈希桶{Node* cur = _tables[i];//cur指向当前哈希桶while (cur){Node* next = cur->_next;//先记录下一个节点size_t hashi = _toi(cur->_kv.first) % newV.size();//计算当前节点在新表中的索引值//将当前节点连接到新桶中cur->_next = newV[hashi];newV[hashi] = cur;//继续操作下一个节点cur = next;}//一个哈希桶遍历结束后,及时制空_tables[i] = nullptr;}//重排结束,交换两个vector_tables.swap(newV);}//进行插入size_t hashi = _toi(kv.first) % _tables.size();//计算索引值Node* newnode = new Node(kv);//创建新节点//头插newnode->_next = _tables[hashi];_tables[hashi] = newnode;_n++;return true;
}
可以看到,与刚才的线性探测实现不同,这里我们在扩容时创建的是一个vector,相比直接创建HashTable再调用Insert方法,能够有效地控制数据重排时节点之间的连接。
对于不同的结构实现,同一种操作所使用的方法也会有所不同,因此在实际场景中,我们应抓住问题本质,灵活运用各种方法,而非死抓一种应对措施。
哈希表的查找
相比线性探测法,链地址法的查找中,我们只需要确定索引值,然后顺着链表直接查找即可,总体过程相对简洁。代码实现如下:
//查找
Node* Find(const K& key)
{size_t hashi = _toi(key) % _tables.size();//确定索引值Node* cur = _tables[hashi];//开始查找while (cur){if (cur->_kv.first == key)//找到了,返回当前节点{return cur;}cur = cur->_next;}return nullptr;//没找到,返回空
}
哈希表的删除
删除的逻辑也比较简单,确定哈希值之后,进行单链表的指定位置删除。代码如下:
//删除
bool Erase(const K& key)
{size_t hashi = _toi(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;}delete cur;_n--;return true;}prev = cur;cur = cur->_next;}return false;//没找到,删除失败
}
总代码
链地址法实现哈希表的全部代码如下:
#include <iostream>
#include <utility>
#include <vector>
#include <string>
using namespace std;//支持内置类型转化为整形的仿函数
template<class K>
class ToInt
{
public:size_t operator()(const K& key)//这里转换为size_t类型更符合数组下标范围{return (size_t)(key);}
};//针对string的特化版本
template<>
class ToInt<string>
{
public:size_t operator()(const string& s){size_t ret = 0;for (auto& c : s){ret += c;ret *= 131;}return ret;}
};//链表节点
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 ToInteger = ToInt<K>>
class HashTable
{typedef HashNode<K, V> Node;
public://构造函数HashTable(){_tables.resize(__stl_next_prime(1), nullptr);}//析构函数~HashTable(){//循环遍历所有哈希桶for (size_t i = 0; i < _tables.size(); i++){//使cur指向当前哈希桶Node* cur = _tables[i];//循环销毁当前链表中的所有节点while (cur){Node* next = cur->_next;delete cur;cur = next;}//将当前哈希桶制空_tables[i] = nullptr;}}//插入bool Insert(const pair<K, V>& kv){if (Find(kv.first))//已有相同键,插入失败{return false;}if (_n >= _tables.size())//装填因子的值超过1,需要扩容{size_t newSize = __stl_next_prime(_tables.size() + 1);//先确定扩容后大小//创建新的vector,然后进行数据重排vector<Node*> newV(newSize, nullptr);for (size_t i = 0; i < _tables.size(); i++)//遍历所有哈希桶{Node* cur = _tables[i];//cur指向当前哈希桶while (cur){Node* next = cur->_next;//先记录下一个节点size_t hashi = _toi(cur->_kv.first) % newV.size();//计算当前节点在新表中的索引值//将当前节点连接到新桶中cur->_next = newV[hashi];newV[hashi] = cur;//继续操作下一个节点cur = next;}//一个哈希桶遍历结束后,及时制空_tables[i] = nullptr;}//重排结束,交换两个vector_tables.swap(newV);}//进行插入size_t hashi = _toi(kv.first) % _tables.size();//计算索引值Node* newnode = new Node(kv);//创建新节点//头插newnode->_next = _tables[hashi];_tables[hashi] = newnode;_n++;return true;}//查找Node* Find(const K& key){size_t hashi = _toi(key) % _tables.size();//确定索引值Node* cur = _tables[hashi];//开始查找while (cur){if (cur->_kv.first == key)//找到了,返回当前节点{return cur;}cur = cur->_next;}return nullptr;//没找到,返回空}//删除bool Erase(const K& key){size_t hashi = _toi(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;}delete cur;_n--;return true;}prev = cur;cur = cur->_next;}return false;//没找到,删除失败}
private:inline size_t __stl_next_prime(size_t n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const size_t __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 size_t* first = __stl_prime_list;const size_t* last = __stl_prime_list + __stl_num_primes;const size_t* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}vector<Node*> _tables;size_t _n = 0;//记录已有元素个数ToInteger _toi;//仿函数对象
};
总结
哈希表是一种基于哈希函数的高效数据结构,其通过数据映射,支持 O(1) 级别的查找、插入和删除操作。哈希表有多种实现方法,我们实现了开放地址法(线性探测)和链地址法两种。开放地址法适用于低负载、节省空间,但处理冲突时可能导致探测成本增加;链地址法适用于高负载,扩展性更强,但需额外存储指针。合理选择哈希函数和冲突解决策略是优化哈希表性能的关键。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤
相关文章:

【数据结构进阶】哈希表
🌟🌟作者主页:ephemerals__ 🌟🌟所属专栏:数据结构 目录 前言 一、哈希表的概念 二、哈希函数的实现方法 1. 直接定址法 2. 除留余数法 三、哈希冲突 1. 开放定址法(闭散列࿰…...

STM32内存五区及堆栈空间大小设置(启动文件浅析)
前言 嘿,朋友们!今天咱们来聊聊STM32的内存五区和堆栈空间大小设置。这可是嵌入式开发里的“必修课”,要是没整明白,程序说不定就“翻车”了。别担心,我这就带你一步步搞懂这事儿,让你轻松上手,…...

微信小程序调用火山方舟(字节跳动火山引擎)中的DeepSeek大模型
微信小程序的轻量化特性与DeepSeek大模型的AI能力结合,可快速构建智能问答、内容生成等场景化服务。通过火山方舟平台提供的标准化接口,开发者无需深入算法细节即可调用模型能力。 一、注册火山引擎账号,创建API Key和model(接入…...

(八)Java-Collection
一、Collection接口 1.特点 Collection实现子类可以存放多个元素,每个元素可以是Object; 有些Collection的实现类,可以存放重复的元素,有些不可以; 有些Collection的实现类,有些是有序的(Li…...

从单片机的启动说起一个单片机到点灯发生了什么下——使用GPIO点一个灯
目录 前言 HAL库对GPIO的抽象 核心分析:HAL_GPIO_Init 前言 我们终于到达了熟悉的地方,对GPIO的初始化。经过漫长的铺垫,我们终于历经千辛万苦,来到了这里。关于GPIO的八种模式等更加详细的细节,由于只是点个灯&am…...

C++ | 哈希表
前言 💓 个人主页:普通young man-CSDN博客 ⏩ 文章专栏:C_普通young man的博客-CSDN博客 ⏩ 本人giee: 普通小青年 (pu-tong-young-man) - Gitee.com 若有问题 评论区见📝 🎉欢迎大家点赞👍收藏⭐文章 —…...
leetcode_动态规划/递归 70. 爬楼梯
70. 爬楼梯 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢? 思路: 考虑: 假设现在已经爬到了某一阶台阶,那是如何到达这里的呢?可能是从前一阶台阶爬上来的&am…...

基于Rook的Ceph云原生存储部署与实践指南(上)
#作者:任少近 文章目录 1 Ceph环境准备2 rook部署ceph群集2.1 Rook 帮助地址2.2 安装ceph2.3 获取csi镜像2.4 Master参加到osd2.5 设置默认存储 3 Rook部署云原生RBD块存储3.1 部署storageclass资源3.2 部署WordPress使用RBD3.3 WordPress访问 4 Rook部署云原生RGW…...
C++ Qt常见面试题(4):Qt事件过滤器
在 Qt 中,事件过滤器(Event Filter)提供了一种机制,可以拦截并处理对象的事件(如鼠标事件、键盘事件等),在事件到达目标对象之前对其进行预处理。事件过滤器通常用于以下场景: 捕获和处理特定的事件(如鼠标点击、按键等);对事件进行筛选或修改;实现全局的事件监听功…...
regionserver实例僵住问题分析
问题现象: 应用提交超时,发现regionserver实例异常。hbase原生页面这个实例dead,业务连接到这个rs的进程超时8个regionserver实例。 D08在18:30分后显示warning,应用提交任务到这个rs节点超时,hbase控制台不显示d08的rs信息了。19:30在页面停止rs实例失败,然后kill进程…...

服务器离线部署DeepSeek
目标 本次部署的目标是在本地服务器上部署DeepSeek。但是该服务不能连接外网,因此只能使用离线部署的方式。为了一次完成部署。现在云服务器上进行尝试。 云服务器部署尝试 云服务器配置 CentOS72080Ti 11GB 安装准备 1、上传iso并配置为本地yum源 安装前先将…...
QT mac系统下qml实现的菜单栏,标准快捷键Delete无作用或失灵的处理
一.在mac系统下,QT官方提供的删除快捷键无作用。 1.下面这一段代码,最后一个menuItem采用的是QT自带的标准快捷键,但是在使用的过程中,快捷键无响应。大家可以将代码带入简单的demo尝试一下: MenuBar {id: rootMenu {id: editMenutitle: TransText.titleBar_EditMenuItem…...
redis序列化设置
redis序列化设置 redis序列化设置序列化对象里有org.joda.time.DateTime1)、报错内容如下2)、解决方案:分别自定义时间的序列化和反序列化,以对象形式关联到redisTemplate redis序列化设置 redis序列化设置,通过自定义…...

浅谈C++/C命名冲突
前言 在这里我会简要地介绍产生命名冲突的原因,和C中处理命名冲突的方法,同时和C语言的解决办法进行比较。 相信你在阅读完之后一定会有收获。对于我们来说,了解编译器的编译链接过程才能更好的理解编译器是如何报错的,更能让我们…...

【语音编解码】常用的基于神经网络的语音编解码方案对比
引言 随着实时通信与多媒体应用的爆炸式增长,传统语音编解码技术正面临带宽效率与音质保真的双重挑战。近年来,基于深度学习的神经编解码器突破性地将端到端架构、动态码率控制与可解释信号处理相结合,在3kbps以下超低码率场景仍能保持自然语…...
PVE 配置显卡直通
博客地址:PVE 配置显卡直通 配置 Device: Dell PowerEdge T630CPU: Intel Xeon E5-2696 v4 x2GPU 1: Matrox Electronics Systems Ltd. G200eR2GPU 2: NVIDIA GeForce GTX 1060 3GBOS: Proxmox VE bookworm 8.3.1 x86_64 注意事项 硬件需支持并在 BIOS 中开启 I…...
Kronecker分解(K-FAC):让自然梯度在深度学习中飞起来
Kronecker分解(K-FAC):让自然梯度在深度学习中飞起来 在深度学习的优化中,自然梯度下降(Natural Gradient Descent)是一个强大的工具,它利用Fisher信息矩阵(FIM)调整梯度…...

ArcGIS Pro技巧实战:高效矢量化天地图地表覆盖图
在地理信息系统(GIS)领域,地表覆盖图的矢量化是一项至关重要的任务。天地图作为中国国家级的地理信息服务平台,提供了丰富且详尽的地表覆盖数据。然而,这些数据通常以栅格格式存在,不利于进行空间分析和数据…...
React + TypeScript 数据模型驱动数据字典生成示例
React TypeScript 数据模型驱动数据字典生成示例 引言:数据字典的工程价值 在现代化全栈开发中,数据字典作为业务实体与数据存储的映射桥梁,直接影响系统可维护性与团队协作效率。传统手动维护字典的方式存在同步成本高和版本管理混乱两大痛…...
道可云人工智能每日资讯|深圳将设立人工智能和机器人产业基金
道可云元宇宙每日简报(2025年2月26日)讯,今日元宇宙新鲜事有: 上海青浦发布国际产业协作元宇宙平台 近日,“2025出海企业与跨境专业服务论坛”在上海青浦区徐泾镇举行。论坛上重磅发布三大全球化服务平台,…...

Vue3 + Element Plus + TypeScript中el-transfer穿梭框组件使用详解及示例
使用详解 Element Plus 的 el-transfer 组件是一个强大的穿梭框组件,常用于在两个集合之间进行数据转移,如权限分配、数据选择等场景。下面我将详细介绍其用法并提供一个完整示例。 核心特性与用法 基本属性 v-model:绑定右侧列表的值&…...

大数据零基础学习day1之环境准备和大数据初步理解
学习大数据会使用到多台Linux服务器。 一、环境准备 1、VMware 基于VMware构建Linux虚拟机 是大数据从业者或者IT从业者的必备技能之一也是成本低廉的方案 所以VMware虚拟机方案是必须要学习的。 (1)设置网关 打开VMware虚拟机,点击编辑…...

最新SpringBoot+SpringCloud+Nacos微服务框架分享
文章目录 前言一、服务规划二、架构核心1.cloud的pom2.gateway的异常handler3.gateway的filter4、admin的pom5、admin的登录核心 三、code-helper分享总结 前言 最近有个活蛮赶的,根据Excel列的需求预估的工时直接打骨折,不要问我为什么,主要…...

【快手拥抱开源】通过快手团队开源的 KwaiCoder-AutoThink-preview 解锁大语言模型的潜力
引言: 在人工智能快速发展的浪潮中,快手Kwaipilot团队推出的 KwaiCoder-AutoThink-preview 具有里程碑意义——这是首个公开的AutoThink大语言模型(LLM)。该模型代表着该领域的重大突破,通过独特方式融合思考与非思考…...

【Java_EE】Spring MVC
目录 Spring Web MVC 编辑注解 RestController RequestMapping RequestParam RequestParam RequestBody PathVariable RequestPart 参数传递 注意事项 编辑参数重命名 RequestParam 编辑编辑传递集合 RequestParam 传递JSON数据 编辑RequestBody …...
在web-view 加载的本地及远程HTML中调用uniapp的API及网页和vue页面是如何通讯的?
uni-app 中 Web-view 与 Vue 页面的通讯机制详解 一、Web-view 简介 Web-view 是 uni-app 提供的一个重要组件,用于在原生应用中加载 HTML 页面: 支持加载本地 HTML 文件支持加载远程 HTML 页面实现 Web 与原生的双向通讯可用于嵌入第三方网页或 H5 应…...

以光量子为例,详解量子获取方式
光量子技术获取量子比特可在室温下进行。该方式有望通过与名为硅光子学(silicon photonics)的光波导(optical waveguide)芯片制造技术和光纤等光通信技术相结合来实现量子计算机。量子力学中,光既是波又是粒子。光子本…...

HarmonyOS运动开发:如何用mpchart绘制运动配速图表
##鸿蒙核心技术##运动开发##Sensor Service Kit(传感器服务)# 前言 在运动类应用中,运动数据的可视化是提升用户体验的重要环节。通过直观的图表展示运动过程中的关键数据,如配速、距离、卡路里消耗等,用户可以更清晰…...

基于 TAPD 进行项目管理
起因 自己写了个小工具,仓库用的Github。之前在用markdown进行需求管理,现在随着功能的增加,感觉有点难以管理了,所以用TAPD这个工具进行需求、Bug管理。 操作流程 注册 TAPD,需要提供一个企业名新建一个项目&#…...

免费PDF转图片工具
免费PDF转图片工具 一款简单易用的PDF转图片工具,可以将PDF文件快速转换为高质量PNG图片。无需安装复杂的软件,也不需要在线上传文件,保护您的隐私。 工具截图 主要特点 🚀 快速转换:本地转换,无需等待上…...