一、哈希
1.1 哈希的概念
什么是哈希呢?
哈希又称散列,是一种组织数据的方式 。是一种通过哈希函数把关键字 key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 key 存储的位置,进行快速查找。
. 直接定址法
当关键字的范围比较集中时,就可以采用直接定址法。举个例子,一组关键字都在0,99之间,我们就可以开一个100个数的数组,每个关键字的值直接就是存储位置的下标。再比如一组关键字值都在a,z的小写字母,那么我们就可以开一个26个数的数组,每个关键字的 ascii码值就是存储位置的下标。
直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。
但是这种方式也有缺点,那就是关键字的范围比较分散时,就很浪费内存甚至内存不够用。所以这种方式也比较鸡肋。
利用哈希函数来计算哈希值的这种方式,有一个问题就是,两个不同的 key 可能会映射到同一个位置去,这个问题就叫做哈希冲突/哈希碰撞 。理想情况下是找一个好的哈希函数来避免哈希冲突,但哈希冲突是不可避免的。所以要尽可能的设计出优秀的哈希函数来减少冲突的次数。
. 负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M ,那么负载因子 = N / M,负载因子也可以叫做载荷因子/装载因子等 。负载因子越大,哈希冲突的概率越高,空间利用率越高,反之负载因子越小,哈希冲突的概率越低,空间利用率越低。
为什么呢?因为负载因子 = N / M ,负载因子越大没说明N越大,哈希表中存储的数据越多,所以空间利用率越高,哈希冲突概率越高。
通过上述对于哈希的基础了解,我们可以看到,我们将关键字映射到数组中位置,一般是整数好做映射计算,如果不是整数,就需要先将关键字转换为整数。
1.2 除法散列法/除留余数法
除法散列法也叫做除留余数法 ,顾名思义,假设哈希表的大小为M,那么通过 key 除以M 的余数作为映射位置的下标 ,也就是哈希函数为:h(key) = key % M。
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。为什么呢 ?因为如果是这种值的话,那么在利用上述哈希函数来计算的时候,就相当于保留了 key 的后 X位,那么后X位相同的值,计算出的哈希值是一样的,就容易冲突。因此我们要尽可能的让更多位参与运算,这样就可以降低哈希冲突的概率。
因此,在使用除法散列时,建议M取不太接近2的整数次幂的一个质数。
1.3 乘法散列法
乘法散列法对哈希表大小M没有要求,第一步用关键字 k 乘上常数A(0<A<1),并抽取 k * A 的小数部分。第二步再用M乘以 k * A的小数部分,再向下取整。
1.4 全域散列法
如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如让所有关键字全部落入同一个位置中,这种情况是存在的,只要散列函数是公开且确定的,就可以实现此攻击 。所以,为了解决这个问题,就可以给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数来使用,后续增删改查都固定使用这个散列函数,否则每次哈希都是随机选取一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了。
1.5 处理哈希冲突
实践中哈希表一般还是选取除法散列法来作为哈希函数 ,不管选用哪个哈希函数也避免不了哈希冲突,那么我们应该如何解决哈希冲突呢?
有两种方法 :开放定址法和链地址法。
. 开放定址法
开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置存储。这里的规则有三种 :线性探测,二次探测,双重探测。
线性探测 :从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。
因为负载因子小于1,所以最多探测M-1次,一定能找到一个存储 key 的位置。
线性探测比较简单,如果 hash0 位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积 。二次探测可以一定程度改善这个问题。
二次探测 :从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置,如果往左走到哈希表头,则回绕到哈希表尾的位置。
双重散列 :第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量的值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
. key 不能取模的问题
当 key 是 string/Date 等类型时,key 不能取模,那么我们需要给HashTable哈希表增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整形,如果 key 可以转换成整型并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个 key 不能转换成整型,我们就需要自己实现一个仿函数传给这个参数。
实现这个仿函数的要求就是尽量 key 的每个值都参与到计算中,让不同的 key 转换出的整型值不同。
. 链地址法
解决冲突的思路 :开放定址法中所有的元素都放到哈希表里 ,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面,链地址法也叫做拉链法或者哈希桶。

开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。为什么呢?
负载因子 = N / M,N代表哈希表中存储数据的个数,M代表哈希表的大小,在开放定址法中哈希表直接存储的就是数据,所以为了避免频繁的哈希冲突,我们不会将哈希表中全部填入数据,所以负载因子小于1 ,而链地址法中,因为哈希冲突时会将多个数据以链表的形式存储在哈希表中,所以哈希表中存储数据的个数是有可能大于哈希表的大小的,所以负载因子可以大于1。
如果极端场景下,某个桶特别长怎么办 ?可以考虑使用全域散列法,这样就不容易被针对了。但是偶然情况下,某个桶很长,查找效率很低怎么办 ?我们可以将链表转化为红黑树,这样就可以提高查找时的效率了。不过,在C++中,unordered_map,unordered_set在底层并没有采用红黑树来实现,是用链地址法来实现的。
二、链地址法代码实现
. HashTable.h
cpp
#pragma once
#include<iostream>
#include<vector>
#include<unordered_map>
using namespace std;
static const int __stl_num_primes = 28;
//数组
//哈希这里用的素数表的目的是为了让数的更多比特位参与运算,
//从而降低哈希冲突(因为素数无法被整除,所有比特位都会参与运算),
//如果是2的整数幂(较小)或者是2的倍数,参与运算的比特位就会很少,
//就会增加哈希冲突
static const unsigned long __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
};
inline unsigned long __stl_next_prime(unsigned long n)
{
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//特化
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hashi = 0;
for (const auto& ch : key)
{
hashi *= 131;
hashi += ch;
}
return hashi;
}
};
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
//构造函数
HashTable(size_t size = __stl_next_prime(0))
:_tables(size)
,_n(0)
{}
bool Insert(const pair<K, V>& kv)
{
//负载因子达到0.7就开始扩容
if ((double)_n / (double)_tables.size() >= 0.7)
{
//第一种方法
//申请一块新空间,遍历旧表,重新映射
//vector<HashData<K, V>> newtables(_tables.size() * 2);
//for (size_t i = 0; i < _tables.size(); ++i)
//{
// if (_tables[i]._state == EXIST)
// {
// size_t hash0 = _tables[i]._kv.first % newtables.size();
// //...
// }
//}
//第二种方法
//HashTable<K, V> newHT(_tables.size() * 2);
HashTable<K, V> newHT(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
//调用的是vector里面的swap函数
_tables.swap(newHT._tables);
//不需要
//扩容是为了减少哈希冲突,重新映射关系,并没有增添新的数据
//swap(_n, newHT._n);
}
Hash hs;
//查找插入位置
//取模操作符只能用于整数
size_t hash0 = hs(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
//线性探测
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)
{
Hash hs;
size_t hash0 = hs(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
//线性查找
//因为是线性探测,所以key取模的位置有可能就是要查找key的位置
//但也有可能因为该位置被其他元素所占据,从而线性探测到其他位置
//查找过程中,如果遇到了EMPTY说明未找到对应的key
while(_tables[hashi]._state == EXIST)
{
if (_tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ht = Find(key);
if (ht)
{
ht->_state = DELETE;
return true;
}
return false;
}
private:
vector<HashData<K, V>> _tables;
size_t _n;//表中存储数据的个数
};
. test.cpp
cpp
#define _CRT_SECURE_NO_WARNINGS
#include"HashTable.h"
void TestHT1()
{
int a[] = { 19, 30, 5, 36, 13, 20, 21, 12 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert({ e, e });
}
for (size_t i = 0; i < 50; i++)
{
ht.Insert({ rand(),1 });
}
cout << ht.Find(-20) << endl;
cout << ht.Find(9) << endl;
ht.Erase(30);
cout << ht.Find(20) << endl;
cout << ht.Find(9) << endl;
cout << ht.Find(30) << endl;
}
struct HashFuncString
{
size_t operator()(const string& key)
{
size_t hashi = 0;
for (auto ch : key)
{
hashi += ch;
}
return hashi;
}
};
struct Date
{
int _year;
int _month;
int _day;
};
void TestHT2()
{
//HashTable<string, string, HashFuncString> dict;
HashTable<string, string> dict;
dict.Insert({ "sort", "排序" });
dict.Insert({ "string", "字符串" });
HashTable<double, int> ht;
ht.Insert({ 1.23, 1 });
unordered_map<string, string> stddict;
stddict.insert({ "sort", "排序" });
stddict.insert({ "string", "字符串" });
//unordered_map<Date, string> m2;
}
int main()
{
//TestHT2();
return 0;
}