目录
[4.1 闭散列](#4.1 闭散列)
[4.1.1 哈希表的扩容](#4.1.1 哈希表的扩容)
[4.1.2 二次探测](#4.1.2 二次探测)
[4.2 开散列(哈希桶)](#4.2 开散列(哈希桶))
[4.2.1 开散列的扩容](#4.2.1 开散列的扩容)
前言-哈希表概念:
哈希表也是一种存储信息的结构,他是通过映射数据本身从而得到一个"影子位置",该"影子位置"就是哈希表中存储该数据的位置,因此哈希表的优势在于查找数据时,并不会像其他顺序结构、树结构一样,将要查找的数据和载体中的数据直接比较,而是根据数据找到其在哈希表中的映射位置,然后直接用下标访问哈希表的映射位置,让数据与此位置的值进行对比即可。
1、哈希表的用法
哈希表的关键在于如何将数据和映射位置建立关系,通常会通过一个函数来实现他们的转换,并把这个函数叫做哈希函数。通过函数找到数据在哈希表中的映射位置,举例:现有数组{4,9,6,5,3,2},具体示意图如下:
因此,在没有哈希表时,查找一个数据需要跟数组中的数据挨个比较,直到对比相同时或者遍历完数组时才有结果(效率O(N)),而有了哈希表后,直接通过哈希函数求出该数据在哈希表中的映射位置,并且用下标访问哈希表,就可以得到一个结果(效率O(1))。
但是此哈希函数有一个缺陷,即上述数组中有一个元素为1000,则总共只有七个元素但是哈希表需要开1001个空间,浪费了很多不必要的空间。
2、哈希函数-除留余数法
上述哈希函数会导致空间浪费的问题,于是推出另一种方法:除留余数法。该方法是模上哈希表本身的大小,只是最开始哈希表的大小需要给定一个数n,比如上述数组加了元素1000后数组为:{4,9,6,5,3,2,1000},该数组的大小是7,则模上一个接近7的数(或者是7)即可(这里n举例为10)。得到哈希函数:映射位置=数据%n。并且n表示哈希表的大小。
示意图如下:
3、哈希冲突
除留余数法虽然解决了空间浪费的问题,但是以上还有一个问题就是如果数组中插入了一个元素19,那么19和9都会放到哈希表的映射位置9处,把这种映射位置相冲突的情况叫做哈希冲突。
具体示意图如下:
4、闭散列和开散列
解决哈希冲突的方法有两种: 闭散列和开散列。(ps:实际上闭散列不能有效解决哈希冲突,因此用的最多的方法还是开散列,但是这里还是提一下闭散列)。
4.1 闭散列
闭散列用的方法实际上就是发生冲突后,把后映射数据的位置挪到原本位置的下一个位置,若下一个位置也存在数据,则继续往下一个位置走,直到走到空位置处,因此可以把这个过程看作是不断+i的过程,并且把这种方法叫做线性探测:即从冲突位置开始,不断的向后探测直至探测到空位置。
用线性探测解决上述冲突具体示意图:
值得注意的是,若采用闭散列的方法,则不能随便删除哈希表中的数据,比如此时把哈希表中的元素9删除了,那么查找元素19时发现下标为9的位置没有任何数据,则会以为元素19不存在。
因此在闭散列下实现删除需要将删除和空这两个概念分开判断,比如空的时候是一个标识,存在的时候是一个标识,删除的时候是一个标识,总共3个标识,当遍历到空的标识说明要查找的数确实不存在,可以终止遍历,而遍历到删除标识说明还需要继续往下遍历。并且删除一个元素后要把该位置置为指定的标识符。
4.1.1 哈希表的扩容
最后一个问题就是哈希表的扩容了,因为哈希表肯定也会随着插入的数据增多而容量扩大,并且哈希表扩容后可能会让原先冲突的数据不冲突了(注意:扩容之后模的数不是原来的10了,而是20)具体示意图如下:
并且这里新加了一个概念:负载因子,负载因子=填入表中的元素总数/哈希表的长度,在闭散列中负载因子一般是0.7。负载因子的作用就是为了能够有效的降低冲突概率。负载因子最开始定义好后就当作一个基准点,后续填入表中的元素总数/哈希表的长度若大于该基准点则直接扩容。
如果负载因子越小那么发生冲突的概率就越低,但是空间利用率就低了。
如果负载因子越大那么发生冲突的概率就越高,则空间利用率也就高一些。
因此负载因子实际上就是把哈希表"拉长了",以至于达到了减少冲突的目的。
闭散列代码实现如下:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include <vector>
#include<iostream>
using namespace std;
namespace ZH
{
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 HashTable//哈希表
{
public:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子超过0.7就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht;
newht._tables.resize(newsize);
// 遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
newht.Insert(data._kv);
}
}
_tables.swap(newht._tables);
}
size_t hashi = kv.first % _tables.size();
// 线性探测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
HashData<K, V>* Find(const K& key)//查找
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
// 线性探测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXIST
&& _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
++i;
// 如果已经查找一圈,则没有该数据
if (index == hashi)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)//删除
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
vector<HashData<K, V>> _tables;//每个元素的类型是HashData
size_t _n = 0; // 存储元素的总数
};
}
int main()
{
int a[] = { 4,9,6,5,3,2,1000,19 };
ZH::HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(15, 15));
if (ht.Find(19))
{
cout << "19在" << endl;
}
else
{
cout << "19不在" << endl;
}
ht.Erase(19);
if (ht.Find(19))
{
cout << "19在" << endl;
}
else
{
cout << "19不在" << endl;
}
return 0;
}
运行结果:
4.1.2 二次探测
二次探测是线性探测的升级版本,因为线性探测一次只探测一个数据,很容易导致数据的堆积,堆积的坏处在本不处于该映射位置的数据由于堆积原因将本该处于该位置的数据给占领了,这样的情况一多起来就会影响搜索效率,因为查找的数据不能通过下标访问一次性到位,而要遍历多个位置才能找到。
而二次探测就是减少堆积的可能性,上文提到线性探测的过程是一个+i的过程,那么二次探测的过程就是一个+i^2的过程,增大了映射位置随机概率从而减小堆积概率,但是本质上还是不能够完全解决哈希冲突,因此推出另一个概念:开散列。
4.2 开散列(哈希桶)
开散列又称拉链法,他的特殊点在于哈希表中存储的是一个单链表的头节点指针,数据的映射位置依然是根据哈希函数计算出来的, 只不过是存储在链表中(把该链表也称为桶),并且后续发生冲突的数据就直接头插映射位置的链表即可,这样就可以避免哈希冲突了,并且也解决了堆积问题。只是查找数据时需要遍历链表。
开散列具体示意图如下:
开散列实现代码如下:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include <vector>
#include<iostream>
using namespace std;
namespace ZH
{
template<class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_next(nullptr)
, _kv(kv)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
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)
{
size_t hashi = 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;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))//去重
{
return false;
}
if (_n == _tables.size())//扩容,防止单链表过长影响效率
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)//把旧表的指针直接拿下来给到新表
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newtables.size();
// 头插
cur->_next = newtables[hashi];
newtables[hashi] = cur;
//这个表达式也有把cur置空的意思,否则析构旧表时会把cur指向的空间也析构了
cur = next;//体现引用的作用
}
}
_tables.swap(newtables);//交换vector内部指针的方法
}
size_t hashi = kv.first % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 存储有效数据个数
};
}
int main()
{
int a[] = { 4,9,6,5,3,2,1000,19 };
ZH::HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
return 0;
}
通过监视窗口可以观察最终结果:
4.2.1 开散列的扩容
虽然开散列可以解决冲突堆积问题,但是当一个位置的链表(桶)过长也会影响查找的效率,所以必要的时候还是要扩容。但是若按照旧表拷贝一份给到新表,再释放旧表的数据则过于麻烦,值得注意的是,开散列的扩容可以直接将旧表的节点"拿下来"给到新表,最后再把新表对象与旧表对象交换以下即可,这样就省去了大量的工作,具体操作如下图:
结语
以上就是关于哈希表的讲解,哈希表最常用的方法还是除留余数法,解决哈希冲突的方法都是用哈希桶(开散列)解决的,其中最重要的当属哈希函数是如何找到映射位置的(即模上哈希表的大小) 。最后希望本文可以给你带来更多的收获,如果本文对你起到了帮助,希望可以动动小指头帮忙点赞👍+关注😎+收藏👌!如果有遗漏或者有误的地方欢迎大家在评论区补充,谢谢大家!!