序言
在这里有 n 个数值范围在 0 ~ 9 的数字,我希望你统计每个数字的出现次数,你可以在线性时间内做到吗❓
我们提供一个思路⭐️,我们开一个 10 个 int 大小的数组 int q[10]
,依次遍历 n 个数字,每遍历一个数字 num ,我们就执行一次 q[num]++
,这样就很快统计出了每一个数字的出现次数了。这就是 哈希表 的简单使用,将输入映射到数组的指定位置,该位置存储输入的出现次数。
那如果数据的范围在 0 ~ 10000000,你不可能开这么大个空间的数组吧?😢那么我们对每个数字取个 %,收缩数的范围,但是就比如 %10,你的 0 是不是和 10 位置冲突了呀?
有了问题就需要解决,请大家带着这些疑问进入本篇内容。😁
1. 哈希表的介绍
1.1 哈希表的概念⛄️
哈希表通过 哈希函数 将键(Key)映射到表中的一个位置(通常是数组的索引),从而实现对数据的快速查找、插入和删除。哈希函数是哈希表的核心,它决定了键如何被映射到哈希表的索引上。
1.2 哈希函数☀️
哈希函数是哈希表的关键组成部分,它接受一个输入(即键),并输出一个固定长度的哈希值。这个哈希值再经过映射,对应的就是数组的索引,一个好的哈希函数应该具备以下特性:
- 确定性:相同的输入必须产生相同的输出,类似于函数的特性,一个键值只能对应一个哈希值
- 高效性:计算哈希值的过程应该尽可能快
- 均匀性:理想的哈希函数应该能够将不同的输入映射到哈希表的不同位置,以减少哈希冲突的发生。然而,由于哈希表的长度是有限的,完全避免冲突是不可能的,但好的哈希函数应该能够尽量减少冲突的发生。
哈希函数主要有以下两个步骤:
哈希化
哈希化就是将任意类型的输入,通过某种算法转换成固定长度输出(通常是一个整数)的函数,这个整数就是输入的哈希值。上面的例子中,输入本身就是整数,所以不需要转换,但是在其他场景中,我们的键值可能会是 字符串,浮点数,以及其他自定义类型。
就拿字符串举例子,在这里将 abcd
转换为整数的方法有,取第一个字符的 ASCII
,这种方法极易与其他字符串的哈希值冲突,又或者是将所有字符的 ASCII
相加,这也不理想,也很容易冲突。现在字符串哈希化的方法已经相对成熟了,在这里我给大家介绍一种算法 — BKDR算法:
size_t hash = 0;
for (auto& ch : str) {
hash *= 131;
hash += ch;
}
return hash;
每次加字符串的 ASCII
前,该值先乘上 131.
映射
获取哈希值后,我们就需要将该值映射到我们的指定区间的某个位置上,一般采取的运算就是取模运算(模的大小就是哈希表的大小,这样才能让表的每一个位置被映射到)。
1.3 哈希表的优缺点
优点:
- 查找效率高:在平均情况下,哈希表的查找时间复杂度为O(1)。
- 插入和删除操作快:哈希表支持快速的插入和删除操作。
- 支持动态扩容:当哈希表中的元素数量超过一定阈值时,可以通过重新哈希和扩容来保持性能。
缺点:
- 空间利用率可能较低:哈希表需要预留一定的空间以处理哈希冲突,这可能导致空间利用率降低。
- 对哈希函数要求高:哈希函数的设计直接影响哈希表的性能。
- 不支持顺序遍历:哈希表不支持按照键值对的插入顺序进行遍历。
2. 哈希冲突
在哈希表的概念中我们了解到,由于哈希表的长度是有限的,完全避免冲突是不可能的。 当我们对哈希值进行映射(取模运算)时,不同的哈希值可能会映射到同一个位置,就比如,一个大小在 10 int 的哈希表,当哈希值是 0,10,20 的键值同时存在时,都会映射到下标为 0 的位置。
哈希表对这种情况有两种解决方案,分别是 开放定址法(闭散列) 和 链地址法(开散列)。
2.1 开放定址法
当冲突发生时,在哈希表中 寻找下一个空位置,直到找到空位置或者遍历完整个表。具体方法包括线性探测法、二次探测法和再哈希法等。在这里我们着重介绍线性探测法:
在这里我们初始化了一个大小为 6 的数组,下标的范围是 0 ~ 5,现在分别插入 6,13,4,这三个元素在数组的位置是:
现在需要插入 12 ,12 % 6 = 0,可以发现下标为 0 的位置已经被 6 所占有了,所以,12只能向后移动,寻找其他位置,下标为 1 的位置也被 13占有了,所以继续向后寻找,直到找到了下标为 2 的位置,所以 12 就放置在该位置:
现在又需要插入 8, 8 % 6 = 2,但是下标为 2 的位置已经被 12 占有了,所以向后探测,找到下标为 3 的位置:
这就是线性探测发,总结起来一句话,我的位置被霸占了,我就去霸占别人的位置😡。
当使用该方法时,插入的元素个数 / 哈希表的大小 > 0.75 时,就需要进行扩容操作,避免拥挤。
2.2 链地址法
这个方法比较有意思,我们的位置冲突了,那我们就凑活过,采用链表的形式连接起来,共享一个下标。
当寻找某个键值时,先映射到该下标,在到该下标对应的链表寻找该键值。
该方法,插入的元素个数 / 哈希表的大小 > 1 时,才进行扩容操作,比起上一种方法更宽容些,是因为理想情况下,一个链表只有一个元素,查找效率依然很高。
综上当数据规模较大时使用链地址法,这样就可以避免在查找时,不断地向后探测,时间花销大;相反,数据规模较小时,可以使用开放定址法。
3. 实现哈希表
我们在这里实现链地址法类的哈希表。
3.1 元素的结构
哈希表采取的是数组结构,因为我们实现采取链地址法的方式,所以数组的每一个元素应该是一个链表结构:
template<class T>
struct Elem {
Elem(const T& val = T())
: _val(val)
, _next(nullptr)
{}
T _val;
Elem* _next;
};
3.2 仿函数 — 键值哈希化
因为我们采取的是泛型编程,所以键值有可能是整数,有可能是字符串还可能是其他自定义类型,所以为了使对应类型的值哈希化,需要传入仿函数,该仿函数来进行相应的操作,默认可以处理整形以及字符串类型:
// 哈希函数
template<class T>
struct HashFunc {
size_t operator()(const T& key) {
return key;
}
};
// 哈希函数(string 特化)
template<>
struct HashFunc<string> {
size_t operator()(const string& key) {
size_t hash = 0;
for (auto ch : key) {
hash *= 131;
hash += ch;
}
return hash;
}
};
3.3 成员变量以及构造函数
我们的成员变量包括一个指针数组以及 _size,来记录插入元素的数量,便于后续扩容操作。
构造函数为:
HashBucket(size_t capacity = 10)
: _ht(capacity, nullptr)
, _size(0)
{}
3.4 Find 函数
之所以将该函数放到前面介绍,是因为后续在插入时,我们就必须该键值是否已经存在于哈希表中,如果不存在才允许插入操作。主要的操作是先通过哈希值映射到指定的下标,再遍历该下标对应的链表,查看是否存在该键值。
// 查找
bool Find(const Key& key) {
Hash _hs;
size_t hashi = _hs(key) % _ht.size();
Elem* cur = _ht[hashi];
while (cur != nullptr) {
if (cur->_val == key) {
return true;
}
cur = cur->_next;
}
return false;
}
3.5 Insert 函数
在继续插入操作前,首先需要判断该键值是否已经存在于哈希表中,若不存在才能够正常插入。
若存在还需要判断 元素数量 / 哈希表的大小 > 1 是否成立,若成立需要进行扩容操作。
扩容操作
遍历哈希表中每一个元素,重新映射到新的哈希表数组(容量为原来的两倍)中, 将该结点直接从原来的表在接到新的表上,避免重复的内存申请和释放,直到所有结点遍历完成。然后将新的哈希表和老的哈希表内容交换,析构老的表。
最后是插入操作,直接链表的头插方式,非常简单,别忘了 ++_size
// 插入
bool Insert(const T& val) {
if (it != end()) return false;
Hash _hs;
Elem* newnode = new Elem(val);
// 如果超过负载因子,则扩容
if (_size >= _ht.size()) {
vector<Elem*> _temp(_ht.size() * 2, nullptr);
for (size_t i = 0; i < _ht.size(); ++i) {
Elem* cur = _ht[i];
while (cur) {
Elem* next = cur->_next;
size_t hashi = _hs(cur->_val) % _temp.size();
cur->_next = _temp[hashi];
_temp[hashi] = cur;
cur = next;
}
}
_ht.swap(_temp);
_temp.~vector();
}
size_t hashi = _kt(val) % _ht.size();
// 头插法
newnode->_next = _ht[hashi];
_ht[hashi] = newnode;
_size++;
return true;
}
3.6 Erase 函数
删除操作先使用哈希函数映射到指定下标位置,之后直接遍历该链表,查看是否存在,若存在删除。因为是单链表结构,所以要使用两个指针,一个指向当前的,一个指向之前的,删除成功时 --_size
。
// 删除
bool Erase(const Key& key) {
Hash _hs;
size_t hashi = _hs(key) % _ht.size();
Elem* cur = _ht[hashi];
Elem* prev = nullptr;
while (cur) {
if (cur->_val == key) {
// 非头节点
if (prev) {
prev->_next = cur->_next;
}
// 头节点
else {
_ht[hashi] = cur->_next;
}
delete cur;
_size--;
return true;
}
else {
prev = cur;
cur = cur->_next;
}
}
return false;
}
4. 思考
在 C++ 的 STL 库中 unordered_map
和 unordered_set
都是由哈希表实现的,但是两者的存储的数据却不同,前者是一个 pair
,后者存储的键即是值,值即是键。同是哈希表实现的,表现的却不一致,那是底层上是如何实现的呢 ?答案是采用模板,通过使用模板,STL 能够创建出既灵活又强大的容器,这些容器可以存储几乎任何类型的数据,同时保持高效的数据访问和修改性能。
欲知后事如何,且看下回分解😎。