一篇文章让你学会什么是哈希
- 哈希概念
- 哈希冲突
- 哈希函数
- 1. 直接定址法
- 2. 除留余数法
- 3. 平方取中法
- 4. 折叠法
- 5. 随机数法
- 6. 数学分析法
- 哈希冲突解决
- 1. 闭散列
- 1.1 线性探测
- 1.2 二次探测
- 2. 开散列
- 开散列和闭散列对比
哈希概念
哈希在C++中有广泛的应用,它是一种用于快速查找和存储数据的数据结构和算法。以下是一些常见的哈希在C++中的应用:
- 哈希表(Hash Table):哈希表是一种高效的数据结构,用于存储键值对。在C++中,
std::unordered_map
和std::unordered_set
是标准库提供的哈希表实现。这些容器提供了常数时间复杂度的查找、插入和删除操作,使它们非常适用于快速查找和存储数据。
#include <unordered_map>
#include <unordered_set>
std::unordered_map<std::string, int> hashMap;
hashMap["apple"] = 3;
int value = hashMap["apple"];
std::unordered_set<int> hashSet;
hashSet.insert(42);
bool exists = hashSet.count(42) > 0;
- 哈希函数(Hash Function):哈希函数将输入数据映射到一个固定大小的哈希码(哈希值),通常是一个整数。C++标准库提供了多个哈希函数,也允许用户自定义哈希函数。
std::hash<int> intHash;
size_t hashCode = intHash(42);
- 哈希集合和哈希映射:除了标准库的哈希表,C++还提供了其他第三方库和实现,例如Google的
absl::flat_hash_map
和absl::flat_hash_set
,它们提供了类似的功能但具有更低的内存开销。 - 哈希集合用于去重:通过将元素插入哈希集合,可以快速去除重复项。
std::unordered_set<int> uniqueValues;
uniqueValues.insert(1);
uniqueValues.insert(2);
uniqueValues.insert(1); // 重复元素将被自动去重
- 哈希在密码学中的应用:哈希函数在密码学中用于存储和验证密码,常用的密码哈希函数包括SHA-256和bcrypt等。
- 缓存实现:哈希表可用于实现缓存,通过将键值对存储在哈希表中,可以在常数时间内查找缓存中的数据。
- 数据唯一性检查:哈希函数和哈希表可用于检查数据的唯一性,确保不会存储重复的数据。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N)
,平衡树中为树的高度,即O(log_2 N)
,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity
; capacity为存储元素底层空间总的大小
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,但随之而来的问题是,取余数时如果两个数余数相同该怎么办?
哈希冲突
在散列表(Hash Table)形式的存储中,哈希冲突指的是当不同的键(或数据)经过哈希函数计算后,它们得到了相同的哈希值,并且尝试存储到相同的散列表桶(或哈希表位置)时发生的情况。由于哈希函数将输入数据映射到有限数量的桶中,而输入数据的数量可能远远大于桶的数量,因此哈希冲突是在散列表中常见的问题。
哈希冲突可能会导致以下问题:
- 数据覆盖:如果两个不同的键映射到相同的桶位置,一个键的数据将覆盖另一个键的数据,导致数据丢失。
- 查找效率下降:当多个键映射到同一个桶位置时,查找特定键的效率可能会降低,因为必须在该桶内搜索以找到正确的键。
为了解决哈希冲突,散列表通常使用以下方法之一:
- 链地址法(Chaining):在这种方法中,每个桶都存储一个链表、数组或其他数据结构,用于存储映射到相同哈希值的多个元素。当哈希冲突发生时,新元素被添加到桶内的链表或数组中,而不会覆盖已存在的元素。
- 开放寻址法(Open Addressing):在这种方法中,当发生哈希冲突时,算法会寻找散列表中的下一个可用桶,并将数据插入到该桶中,直到找到一个空闲桶。这个过程通常涉及一系列的探测方法,如线性探测、二次探测等。
- 好的哈希函数:选择合适的哈希函数可以减少哈希冲突的发生。好的哈希函数应该尽量均匀地将数据映射到桶中,从而降低冲突的概率。
处理哈希冲突是设计和实现散列表时需要考虑的重要问题,不同的应用场景可能需要选择不同的冲突解决策略。合理的冲突处理方法可以提高散列表的性能和效率。
哈希函数
哈希函数设计原则:
哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
哈希函数计算出来的地址能均匀分布在整个空间中
哈希函数应该比较简单
1. 直接定址法
“直接定址法”(Direct Addressing)是一种简单且有效的散列(哈希)技术,通常用于解决键-值对的存储和检索问题。在直接定址法中,每个可能的键都对应着一个桶(或槽位),而这些桶按照键的范围来分配,通常作为一个数组来实现。
这种方法的核心思想是,每个键都被直接映射到一个特定的桶,因此在理想情况下,没有哈希冲突,因为每个键都有唯一的桶索引。
以下是直接定址法的主要特点和限制:
- 空间复杂度:这种方法需要分配足够大的存储空间以容纳所有可能的键,因此空间复杂度取决于键的范围大小。如果键的范围很大,将导致高内存消耗。
- 冲突解决:直接定址法通常不需要冲突解决策略,因为每个键都有唯一的桶索引。这使得存储和检索操作都是O(1)的常数时间复杂度,非常高效。
- 适用性:直接定址法通常在键的范围相对较小且连续的情况下有效。如果键的范围非常大或不连续,这种方法可能不太适用,因为需要分配大量的存储空间。
- 示例:一个常见的示例是使用直接定址法来实现一个包含整数键的数据结构,例如计数器。如果你有一个计数器,需要跟踪大量整数值的出现次数,你可以使用一个数组,其中数组的索引是整数值,数组的值是该整数值出现的次数。
// 使用直接定址法实现计数器
const int MAX_RANGE = 1000; // 假设整数范围在0到999之间
int counter[MAX_RANGE] = {0};
// 增加某个整数值的计数
int key = 42;
counter[key]++;
总之,直接定址法是一种简单但有效的散列方法,适用于键的范围相对较小且连续的情况。它提供了常数时间复杂度的存储和检索操作,但需要注意键的范围和内存消耗,适合查找比较小且连续的情况。
2. 除留余数法
“除留余数法”(Division Method)是一种常见的散列(哈希)技术,用于将键映射到散列表的桶(槽位)或数组索引中。它的基本思想是,通过对键除以一个合适的数并取余数来计算哈希值,然后将哈希值作为桶的索引。这个余数应该是一个较小的正整数,通常是质数,以确保较好的分布性。
除留余数法的步骤如下:
- 选择一个合适的除数(通常是质数),记作M。
- 对键K进行哈希计算:哈希值 = K % M。
- 使用哈希值作为桶的索引,将数据存储在对应的桶中。
这种方法的优点是简单且容易实现。然而,它也有一些限制和注意事项:
- 选择合适的除数:选择一个合适的除数M对于除留余数法至关重要。一个好的选择可以确保较好的哈希分布,避免哈希冲突。通常,选择质数可以帮助减少冲突的可能性。
- 均匀分布:为了获得良好的哈希分布,键应该均匀地分布在整个键空间。如果键分布不均匀,可能会导致某些桶过度拥挤,而其他桶几乎没有数据。
- 处理负数:除留余数法通常要求键是正整数。如果需要哈希负数,可以考虑采用一些调整方法,如将负数转换为正数。
- 处理哈希冲突:虽然除留余数法可以减少冲突的可能性,但仍然可能发生冲突。在实际应用中,通常需要使用冲突解决策略,如**链接法(Chaining)或开放寻址法(Open Addressing)**来处理冲突。
3. 平方取中法
“平方取中法”(Mid-Square Method)是一种简单的伪随机数生成方法,通常用于产生伪随机整数序列。它的基本思想是通过对一个整数进行平方运算,然后取中间的数字作为下一个整数,不断迭代生成伪随机数。尽管它简单,但它的质量通常不如更复杂的伪随机数生成算法。
以下是平方取中法的基本步骤:
- 选择一个初始种子(或起始数值),通常是一个正整数。
- 将种子平方,并得到一个更大的整数。
- 从这个平方的结果中,取出中间的若干位数作为下一个伪随机数。
- 将这个新的伪随机数作为下一轮的种子,然后重复上述步骤
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
这个方法的核心思想是通过反复平方和取中间数字的操作来生成伪随机数。然而,平方取中法的质量和均匀性通常不如更复杂的伪随机数生成算法,因此它主要用于教学或一些简单的模拟问题。
平方取中法的性能和均匀性取决于初始种子的选择和提取的位数。如果选择的种子不好或者提取的位数不适当,可能会导致周期性或不均匀的伪随机数序列。因此,在实际应用中,通常会使用更先进和更可靠的伪随机数生成器,如线性同余法(Linear Congruential Generator)或梅森旋转法(Mersenne Twister)等,以获得更高质量的伪随机数,平方取中法比较适合不知道关键字的分布,而位数又不是很大的情况 。
4. 折叠法
“折叠法”(Folding Method)是一种散列(哈希)技术,通常用于将大整数或长字符串键映射到较小的哈希值,以便将它们存储在散列表或哈希表中。折叠法的基本思想是将输入键分割成固定长度的部分,然后将这些部分相加或进行其他数学操作以生成哈希值。
以下是折叠法的一般步骤:
- 选择一个适当的分割大小,通常是一个正整数。这个大小可以根据应用的需要选择,通常是使输入键能够均匀分割的值。
- 将输入键分割成固定大小的部分(分割块),可以是连续的字符、数字或其他适当的单位。如果输入是数字,通常将它们分割成相等数量的部分。
- 对这些部分执行数学操作,通常是求和。可以选择不同的数学操作,如加法、位操作等,以根据具体情况来生成哈希值。
- 最终得到的结果就是哈希值,可以将其用作散列表或哈希表中的桶索引。
以下是一个简单的示例,说明如何使用折叠法将一个整数键映射到哈希值:
假设输入键是1234567890,我们选择分割块大小为3,然后将这个整数分割成 1, 234, 567, 和 890。接下来,我们对这些部分进行求和操作,得到哈希值:1 + 234 + 567 + 890 = 1692。这个哈希值可以用于存储和检索数据。
折叠法的性能和均匀性取决于分割大小和数学操作的选择。如果选择得当,它可以提供较好的哈希分布,但也需要谨慎选择参数以避免潜在的问题,如冲突或不均匀的哈希分布,折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况 。
5. 随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key)
,其中random为随机数函数。
通常应用于关键字长度不等时采用此法
6. 数学分析法
设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况
哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
1. 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去
1.1 线性探测
比如上面概念中提到的的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
删除
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
哈希表每个空间给个标记
EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
线性探测实现
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
定义模板结构体 HashData
,用于表示哈希表中的数据项。它包含两个主要成员:
_kv
:这是一个键值对(pair<K, V>
),用于存储键值对应的数据。_state
:这是一个枚举类型State
,表示数据项的状态,可能是以下三种之一:EMPTY
:表示槽位为空,即没有数据。EXIST
:表示槽位包含有效的数据。DELETE
:表示槽位包含已删除的数据。
这种结构的设计是为了在哈希表中存储键值对并跟踪每个槽位的状态。状态 _state
的存在使得哈希表可以处理删除操作,而不仅仅是插入和查找。
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
定义通用的哈希函数模板 HashFunc
,它可以用于任何类型的键 K
。该哈希函数的实现非常简单,它将输入键 key
直接转换为 size_t
类型并返回。
具体来说,这个哈希函数的操作步骤是:
- 接受一个类型为
K
的键key
作为输入参数。 - 将键
key
强制类型转换为size_t
,即将不同类型的键映射到一个无符号整数。 - 返回转换后的
size_t
值作为哈希结果。
需要注意的是,它可能不适用于所有类型的键,特别是对于自定义的数据类型,可能需要更复杂的哈希函数来确保良好的哈希性能和均匀性。
template<>
struct HashFunc<string>//对字符型特化给定值乘以固定质数131降低冲突
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
定义特化版本的哈希函数 HashFunc
,用于处理字符串类型(string
)的键。这个哈希函数将字符串的每个字符转换成对应的整数值,并将它们组合在一起生成一个哈希值。
以下是这个特化版本的哈希函数的工作原理:
- 遍历字符串中的每个字符。
- 对于每个字符,将当前哈希值乘以一个固定的质数(131),然后加上字符的整数值。
- 重复步骤1和步骤2,直到遍历完整个字符串。
- 返回最终的哈希值作为字符串的哈希结果。
这个哈希函数的特点是简单而有效,它将字符串中的每个字符都考虑到了哈希值中,并且使用质数131来进行混合(参考STL源码中的实现),以增加哈希的均匀性。这种方法在许多情况下可以产生良好的哈希结果。
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _size = 0; // 存储多少个有效数据
};
定义模板类 HashTable
,用于实现哈希表数据结构。这个哈希表可以存储键值对,其中键的类型为 K
,值的类型为 V
,并且可以选择性地指定哈希函数的类型,默认使用 HashFunc<K>
作为哈希函数。
以下是这个哈希表类的主要成员和特点:
_tables
:这是一个存储HashData<K, V>
数据项的向量(vector
),表示哈希表的存储空间。每个元素对应哈希表的一个槽位,可以存储一个键值对。哈希表的大小由向量的大小决定。_size
:这是一个记录有效数据项数量的计数器,表示当前哈希表中存储了多少个有效的键值对。在插入和删除操作时,会更新_size
。- 默认哈希函数:通过模板参数
Hash
,可以选择性地指定自定义的哈希函数。如果未提供自定义的哈希函数类型,将使用默认的HashFunc<K>
作为哈希函数。这允许在不同类型的键上使用不同的哈希函数。
以下成员函数均为public
插入函数
插入函数需要考虑扩容问题,在散列表存储形式的扩容中,我们需要考虑负载因子问题
散列表的负载因子定义为:α=填入表中的元素个数/散列表的长度
α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,α越小,表明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,负载因子是特别重要因素,应严格限制在0.7-0.8
以下。超过0.8
,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子到了就扩容
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7) // 扩容
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V, Hash> newHT;
newHT._tables.resize(newSize);
// 旧表的数据映射到新表
for (auto e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
Insert
方法的实现,用于向哈希表中插入新的键值对。以下是该方法的主要步骤和逻辑:
- 首先,通过调用
Find
方法检查是否已经存在具有相同键的数据项。如果存在相同的键,插入失败,直接返回false
。 - 接下来,检查哈希表的负载因子(load factor)。负载因子是当前存储的有效数据项数量
_size
与哈希表槽位数目_tables.size()
的比值。如果负载因子超过了指定的阈值(7/10),则表示哈希表负载过重,需要进行扩容。扩容的目的是保持哈希表的性能和均匀性。 - 如果需要扩容,创建一个新的哈希表
newHT
,其大小为当前哈希表的两倍(或初始化大小为10,如果当前哈希表为空)。然后,将旧哈希表_tables
中的数据映射到新哈希表newHT
中,通过调用newHT.Insert(e._kv)
将每个有效数据项插入到新哈希表中。 - 一旦数据映射完成,通过调用
swap
函数交换旧哈希表_tables
和新哈希表newHT
,以便使新的哈希表成为当前哈希表。 - 接下来,计算要插入的键的哈希值。通过调用哈希函数
hash
对键kv.first
进行哈希运算,然后取模运算来确定要插入的槽位索引hashi
。 - 使用线性探测法来查找可用的槽位。如果当前槽位
_tables[hashi]
的状态为EXIST
,则继续查找下一个槽位,直到找到一个空槽位。 - 一旦找到空槽位,将键值对
kv
存储在该槽位,并将状态标记为EXIST
,表示该槽位包含有效数据。然后,递增有效数据项数量_size
。 - 最后,返回
true
表示插入成功。
查找函数
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t start = hash(key) % _tables.size();
size_t hashi = start;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
if (hashi == start)
{
break;
}
}
return nullptr;
}
Find
方法的实现,用于查找指定键的数据项并返回对应的 HashData<K, V>
指针。以下是该方法的主要步骤和逻辑:
- 首先,检查当前哈希表的大小,如果哈希表为空(即
_tables.size()
为0),则无法进行查找,直接返回nullptr
表示未找到。 - 创建哈希函数对象
hash
,并使用哈希函数计算给定键key
的哈希值。哈希值通过取模运算% _tables.size()
得到槽位索引start
,表示开始查找的位置。 - 初始化一个哈希索引
hashi
,开始从槽位start
开始查找。进入循环。 - 在循环中,首先检查当前槽位
_tables[hashi]
的状态。如果状态是EMPTY
,表示当前槽位为空,说明没有找到指定键,继续查找下一个槽位。 - 如果状态不是
EMPTY
,则继续检查数据项的状态_tables[hashi]._state
。如果状态是DELETE
,表示当前槽位的数据已删除,也继续查找下一个槽位。 - 如果状态不是
EMPTY
且不是DELETE
,则表示当前槽位包含有效数据。继续检查数据项的键_tables[hashi]._kv.first
是否等于目标键key
。如果相等,表示找到了指定的键,返回指向该数据项的指针&_tables[hashi]
。 - 如果上述条件都不满足,表示当前槽位的数据不匹配目标键,继续查找下一个槽位。通过递增
hashi
并取模_tables.size()
来实现循环查找。 - 循环继续,直到再次回到起始位置
start
,表明已经遍历了整个哈希表一次,但未找到匹配的键。此时退出循环。 - 最后,如果整个循环结束而未找到匹配的键,返回
nullptr
表示未找到。
删除函数
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_size;
return true;
}
else
{
return false;
}
}
Erase
方法的实现,用于删除指定键对应的数据项。以下是该方法的主要步骤和逻辑:
- 首先,调用
Find
方法来查找指定键key
对应的数据项。如果找到了匹配的数据项,Find
方法会返回一个指向该数据项的指针,并将其存储在ret
中。如果未找到匹配的数据项,Find
方法返回nullptr
。 - 接下来,检查
ret
是否为非空指针。如果ret
不为空,表示找到了匹配的数据项,可以执行删除操作。 - 在删除操作中,将匹配数据项的状态
_state
设置为DELETE
,表示该数据项已删除。 - 同时,减少哈希表中有效数据项数量
_size
的计数,以反映删除操作。 - 最后,返回
true
表示删除成功。 - 如果
ret
为空(即未找到匹配的数据项),则返回false
表示删除失败。
这个 Erase
方法实现了哈希表中数据项的逻辑删除,通过将状态标记为 DELETE
来表示删除状态,而不是实际地从哈希表中移除数据。这种方法允许在查找时跳过已删除的数据,同时保留了哈希表的完整性。
全部代码
#pragma once
#include<iostream>
using namespace std;
enum State
{
EMPTY,
EXIST,
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>//对字符型特化给定值乘以固定质数131降低冲突
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val *= 131;
val += ch;
}
return val;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子到了就扩容
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7) // 扩容
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V, Hash> newHT;
newHT._tables.resize(newSize);
// 旧表的数据映射到新表
for (auto e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t start = hash(key) % _tables.size();
size_t hashi = start;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
if (hashi == start)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_size;
return true;
}
else
{
return false;
}
}
void Print()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
printf("[%d:%d] ", i, _tables[i]._kv.first);
}
else
{
printf("[%d:*] ", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables;
size_t _size = 0; // 存储多少个有效数据
};
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
1.2 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0 + i^2 )% m
, 或者:H_i = (H_0 - i^2)% m
。其中:i =1,2,3…, H_0
是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
修改线性探测中的插入函数:
Hash hash;
size_t start = hash(kv.first) % _tables.size();
size_t i = 0;
size_t hashi = start;
// 二次探测
while (_tables[hashi]._state == EXIST)
{
++i;
hashi = start + i*i;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
代码的主要步骤和逻辑:
- 创建一个哈希函数对象
hash
。 - 计算给定键
kv.first
的哈希值,并使用取模运算% _tables.size()
得到槽位索引start
,表示开始查找的位置。 - 初始化一个整数
i
,用于追踪尝试的次数,并初始化hashi
为start
,表示当前要查找的槽位。 - 进入循环,使用二次探测来查找可用的槽位。在每次迭代中,增加
i
,然后计算新的哈希索引hashi
,通过start + i*i
计算。接着,使用取模运算% _tables.size()
确保哈希索引不超出哈希表的大小。 - 检查当前槽位
_tables[hashi]
的状态。如果状态为EXIST
,表示该槽位已经被占用,继续下一个迭代以尝试下一个槽位。 - 如果找到一个空槽位
_tables[hashi]._state
不为EXIST
,表示该槽位可以存储数据。将键值对kv
存储在该槽位,并将状态标记为EXIST
,表示该槽位包含有效数据。 - 递增有效数据项数量
_size
,表示成功插入数据。
通过使用二次探测,可以更均匀地分布数据项,并减少了线性探测时的聚集效应。
当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。因此,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷
2. 开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
开散列中每个桶中放的都是发生哈希冲突的元素
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)
{}
};
定义结构体 HashNode
,用于表示哈希表中的节点。这个节点包含以下成员:
_kv
:这是一个键值对(pair<K, V>
),用于存储节点中的键值数据。_next
:这是一个指向下一个节点的指针,用于构建链表结构,处理哈希冲突。如果发生哈希冲突,多个节点可能被映射到同一个哈希桶(槽位),这时链表的_next
指针被用来连接具有相同哈希值的节点。
在链地址法中,每个哈希桶(槽位)维护一个链表,当多个键映射到同一个槽位时,它们会按顺序添加到链表中,通过 _next
指针连接。通过这种方式,可以在同一哈希桶中存储多个键值对,解决了哈希冲突的问题。当需要查找或删除键值对时,可以遍历链表来定位具体的节点。这种链地址法的实现使得哈希表可以有效地管理数据,并保持高效性能。
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _tables;
size_t _size = 0; // 存储有效数据个数
};
定义哈希表的模板类 HashTable
,用于存储键值对数据。以下是该类的主要成员和属性:
typedef HashNode<K, V> Node;
:这是一个类型别名声明,将HashNode<K, V>
简化为Node
,以提高代码可读性。private:
:这是一个私有访问标识符,表示以下成员变量和方法是类的私有成员,外部不可直接访问。vector<Node*> _tables;
:这是一个vector
容器,用于存储哈希表的哈希桶(槽位)。每个元素是一个指向HashNode<K, V>
类型的指针,即链表的头节点。这个容器存储了哈希表的所有数据。size_t _size = 0;
:这是一个计数器,用于记录哈希表中有效数据的个数。在哈希表的插入、删除等操作中,会更新这个计数器来维护数据的准确数量。
HashTable
类的作用是实现一个哈希表数据结构,支持存储键值对数据,并提供插入、查找、删除等基本操作。该哈希表采用链地址法解决哈希冲突,使用一个 vector
来存储哈希桶,每个桶对应一个链表用于存储数据。此外,_size
用于跟踪有效数据的数量,帮助管理哈希表的负载因子和自动扩容等操作。
插入函数
和闭散列一样,插入时我们需要考虑扩容问题,那么桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
{
return false;
}
// 负载因子到1就扩容
if (_size == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newTables;
newTables.resize(newSize, nullptr);
// 旧表中节点移动映射新表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = kv.first % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return true;
}
- 首先,检查是否已经存在具有相同键的节点,即通过调用
Find(kv.first)
来查找键kv.first
是否已存在于哈希表中。如果已存在,就返回false
,表示插入失败,因为不允许重复键。 - 接着,检查负载因子(load factor),负载因子是指已存储数据个数
_size
与哈希桶个数_tables.size()
的比值。如果负载因子达到1(表示每个哈希桶平均存储一个数据),则进行扩容操作。 - 如果需要扩容,首先计算新哈希表的大小
newSize
,如果当前哈希表为空(即_tables.size()
为0),则将新大小设置为10,否则将新大小设置为当前大小的两倍。然后,创建一个新的vector
容器newTables
,并将其大小设置为newSize
,同时初始化所有元素为nullptr
。 - 遍历当前哈希表
_tables
中的每个槽位(每个槽位对应一个链表),将链表中的节点重新映射到新的哈希桶中。具体操作是遍历链表,将每个节点的键通过哈希函数计算新的槽位索引hashi
,然后将节点插入到新的哈希桶中(采用头插法),同时更新节点的_next
指针以构建链表。完成后,将旧的槽位设置为nullptr
。 - 最后,使用
swap
操作交换新旧哈希表,将新哈希表取代旧哈希表,以完成扩容操作。 - 如果不需要扩容(负载因子未达到1),则计算键的哈希值
hashi
,确定要插入的槽位,然后采用头插法将新节点插入到对应槽位的链表中。增加有效数据个数_size
并返回true
,表示插入成功。
这段代码的核心思想是维护哈希表的负载因子,当负载因子过高时触发扩容操作,以保持哈希表的性能。同时,它通过链表处理哈希冲突,支持多个键映射到同一个哈希桶的情况。需要注意的是,对于已存在的键,不会进行插入操作,以保证键的唯一性。
查找函数
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
- 首先,检查哈希表是否为空,即
_tables.size() == 0
。如果哈希表为空,表示没有数据,直接返回nullptr
,因为无法找到任何数据。 - 计算键的哈希值
hashi
,通过对键key
取模操作% _tables.size()
得到一个槽位索引,确定要在哪个哈希桶中查找。 - 初始化一个指针
cur
,将其指向选定的哈希桶_tables[hashi]
中的头节点,即链表的起始位置。 - 进入循环,遍历链表中的节点。在每次迭代中,检查当前节点
cur
是否为空。如果为空,表示已经遍历到链表末尾,仍然未找到匹配的键,此时返回nullptr
表示未找到。 - 如果节点
cur
不为空,继续检查当前节点的键值对中的键_kv.first
是否等于目标键key
。如果相等,表示找到了匹配的键值对,返回指向当前节点cur
的指针,以便访问或修改该数据。 - 如果当前节点不匹配,将
cur
指向下一个节点,即cur = cur->_next
,以继续搜索链表中的下一个节点。 - 循环直到找到匹配的节点或遍历完整个链表。
删除函数
bool Erase(const K& key)
{
if (_tables.size() == 0)
{
return false; // 哈希表为空,无法删除
}
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr; // 用于记录当前节点的前一个节点
// 遍历链表
while (cur)
{
if (cur->_kv.first == key)
{
// 找到匹配的节点,进行删除操作
if (prev)
{
prev->_next = cur->_next; // 从链表中移除当前节点
}
else
{
// 如果当前节点是链表的头节点,则更新哈希桶的头指针
_tables[hashi] = cur->_next;
}
delete cur; // 释放当前节点的内存
--_size; // 减少有效数据个数
return true; // 删除成功
}
prev = cur;
cur = cur->_next; // 移动到下一个节点
}
return false; // 未找到匹配的键,删除失败
}
- 如果当前节点不是链表的头节点,将前一个节点
prev
的_next
指针指向当前节点的下一个节点,从而将当前节点从链表中移除。 - 如果当前节点是链表的头节点,直接更新哈希桶的头指针
_tables[hashi]
为当前节点的下一个节点,以确保链表头的正确更新。 - 释放当前节点的内存,并减少有效数据个数
_size
。 - 返回
true
表示删除成功。 - 如果未找到匹配的键,最终返回
false
表示删除失败。
析构函数
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
free(cur);
cur = next;
}
_tables[i] = nullptr;
}
}
遍历哈希表的每个槽位,释放链表中的节点,然后将槽位设为 nullptr
,确保释放了所有分配的内存。以下是代码的主要逻辑:
- 使用一个
for
循环遍历_tables
容器,其中_tables
存储了哈希表的所有槽位。 - 在每次迭代中,获取当前槽位
_tables[i]
对应的链表头节点Node* cur
。 - 进入内部的
while
循环,遍历链表中的每个节点。在每次迭代中,首先将下一个节点指针Node* next
设置为当前节点的下一个节点。 - 使用
free
函数释放当前节点cur
占用的内存。注意,这里使用free
函数而不是delete
,因为cur
对象的内存分配可能是通过malloc
或类似的函数进行的,而不是new
。 - 将当前节点
cur
指向下一个节点next
,以继续遍历链表。 - 循环直到链表中没有更多节点,即
cur
变为nullptr
。 - 在每次迭代结束后,将当前槽位
_tables[i]
设置为nullptr
,确保该槽位不再包含任何节点。
全部代码
#pragma once
#include<iostream>
using namespace std;
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 HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
free(cur);
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
{
return false;
}
// 负载因子到1就扩容
if (_size == _tables.size())
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newTables;
newTables.resize(newSize, nullptr);
// 旧表中节点移动映射新表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = kv.first % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return true;
}
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = 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)
{
if (_tables.size() == 0)
{
return false; // 哈希表为空,无法删除
}
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr; // 用于记录当前节点的前一个节点
// 遍历链表
while (cur)
{
if (cur->_kv.first == key)
{
// 找到匹配的节点,进行删除操作
if (prev)
{
prev->_next = cur->_next; // 从链表中移除当前节点
}
else
{
// 如果当前节点是链表的头节点,则更新哈希桶的头指针
_tables[hashi] = cur->_next;
}
delete cur; // 释放当前节点的内存
--_size; // 减少有效数据个数
return true; // 删除成功
}
prev = cur;
cur = cur->_next; // 移动到下一个节点
}
return false; // 未找到匹配的键,删除失败
}
private:
vector<Node*> _tables;
size_t _size = 0; // 存储有效数据个数
};
开散列和闭散列对比
链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间