哈希
对于set和map来说底层所使用的是红黑树,其在搜索上面已经很厉害了,为什么还需要在搞一个unordered_set和unordered_map呢?
#include<iostream>
#include<vector>
#include<unordered_set>
#include<set>
#include<time.h>
using namespace std;
void test_time()
{
int n = 1000000;
vector<int> v;
srand(time(0)); //初始化随机数发生器
for (int i = 0; i < n; ++i)
{
v.push_back(rand()); //随机数发生器
}
set<int> s;
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set : " << end1 - begin1 << endl;
unordered_set<int> us;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set : " << end2 - begin2 << endl;
}
int main()
{
test_time();
}
通过对于很多个数的插入(数越多,插入之间所消耗的时间差距越大),其实也是能够看出来HashTable的优越之处。
1. 哈希概念
可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系
,那么在查找时通过该函数可以很快找到该元素。
哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
2. 哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0 到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
-
直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B优点:简单、均匀缺点:需要事先知道关键字的分布情况使用场景:适合查找比较小且连续的情况。 -
除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。 -
平方取中法
-
折叠法
-
随机数法
-
数学分析法
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
3. 哈希冲突
解决哈希冲突两种常见的方法是:闭散列和开散列
3.1 闭散列
闭散列
:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
线性探测
:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的
伪删除法
来删除一个元素。
载荷因子
- 为了能够保证一直有空位置,所以这里引入了载荷因子,对于载荷因子来说,经过大量的使用最好能够控制在0.7以下最好,一旦超过就会大大增加哈希冲突的概率。
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起
,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。(引发踩踏效应,就像我的位置被人占了,我就去占别人的位置,后来的人都效仿,会导致效率变低)
二次探测
:线性探测的缺陷是产生冲突的数据堆积在一块,这和找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,他不再是挨着找下一个空位置,而是平方式的跳跃找下一个空位置,这样冲突就不会堆积在一片,而是会相对散开一些。
比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
3.1.1 闭散列模拟实现
- 对于闭散列来说,每个哈希表对应的位置里面存储了数据和状态
- 其中数据的类型是由模板的第二个参数所控制的,模板参数T是Key或者pair<const K,V>,所以这也是一种更高维度的泛型。
- 其中对于删除来说,采用的是一种伪删除法。因为对于闭散列来说,最需要的就是找到下一个空位置,如果没有这个状态的话,原本有数据的位置删除了就会直接的变为空位置,那么如果删除的位置刚好在你的要查找的数据之前,那么就会误认为找不到,事实上,是存在的。
- 对于闭散列在增容的时候所采用的方法:重新开辟一个原先二倍大小的vector,然后把你原先的数据通过新的哈希函数计算位置,然后这个新的HashTable和你原先的进行交换。
namespace Close
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class T>
struct HashNode
{
State _state = EMPTY;
T _t;
};
template<class K>
struct Hash
{
size_t operator()(const K& key)
{
return key;
}
};
//模板特化
template<>
struct Hash < string >
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
//hash += ch;
hash = hash * 131 + ch; //
}
return hash;
}
};
//这里和使用红黑树来封装map和set是一样的,都是由第二个模板参数来控制存储的类型是K,还是pair<const K,V>
template<class K, class T, class HashFunc = Hash<K>>//需要把这里的K转换为整形
class HashTable
{
public:
bool Insert(const T& t)
{
//载荷因子= 填入表中的元素个数 / 散列表的长度
//负载因子>0.7(严格控制在0.7以下)
//只有在这种情况下:效率相对较高,并且空间利用率也较好
if (_tables.size() == 0 || _size * 10 / _tables.size() == 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
//但是这里是不对的,因为有可能原来冲突的,由于空间的扩容,而不再是冲突的了,
//所以我们应该开辟好一段空间,然后重新对上面的数据进行计算,然后放入这段空间内,在释放原来的空间
//vector<HashNode<T>> newtables;
//newtables.resize(newsize);
此时就是把原空间上的数据,拿来重新计算放到相对应的新空间上
//for (size_t i = 0; i < _tables.size(); ++i)
//{
// if (_tables[i]._state == EXIST)
// {
// //线性探测找在新表中的位置
// }
//}
//newtables.swap(_tables);
HashTable<K, T, HashFunc> newht;
newht._tables.resize(newsize);
for (auto& e : _tables)
{
if (e._state == EXIST)
{
//重新计算位置,然后放在这个新的表中
newht.Insert(e._t); //拿旧空间的数据重新计算我嫌弃太麻烦,我还不如直接重新来一遍插入呢
}
}
_tables.swap