哈希的内容,主要是围绕哈希冲突来展开的,而解决哈希冲突主要是两种方法,所以以下的无论是开放定址法还是链式定址法都是围绕着解决这两种问题而来的;
在学习如何解决哈希冲突之前,先了解一下哈希的概念,如此才能更好的理解哈希冲突是怎么解决的;
哈希概念 (下面是哈希中的基本概念)
哈希(hash)又称散列,是⼀种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找
直接定址法
当关键字的范围比较集中时,直接定址法就是非常简单高效的方法,比如一组关键字都在[0,99]之间,那么我们开⼀个100个数的数组,每个关键字的值直接就是存储位置的下标。
什么是直接定址法,下面一道oj题就是利用直接定址法解决的可以先写写看。
- 一组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字acsii码-a ascii码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算出⼀个绝对位置或者相对位置。
- 计数排序也就是下面的题都是这种思想;
虽然很好用,直接定址法的缺点也非常明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。
直接定址法不用解决哈希冲突,这也是它为什么方便的原因。
哈希冲突
假设我们只有数据范围是[0, 9999]的N个值,我们要映射到⼀个M个空间的数组中(⼀般情况下M >= N),那么就要借助哈希函数(hash function)hf,关键字key被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0, M)之间。
这里存在的一个问题就是:
- 两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。
- 理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的。
- 所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,
- 同时也要去设计出解决冲突的方案
以下的无论是开放定址法还是链式定址法都是围绕着解决这两种问题而来的;
负载因子
假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 ,负载因子有些地方也翻译为载荷因子 / 装载因子等,他的英文为load factor。
- 负载因子越大,哈希冲突的概率越高,空间用率越高;
- 负载因子越小,哈希冲突的概率越低,空间利用率越低
将关键字转为整数
这也是映射的内容,有的关键字key无法强转位size_t 这种无符号的整形,因此就需要自己满足,自己写一个仿函数;这个过程也是映射一层;当然后面还是有映射的部分的,就是最主要的部分哈希函数,的那一层映射
在库里定义unorder_map(底层是哈希表)时,也是封装了 用于转化为整形的hash 和用于比较的 pre(举个例子,日期类data,就需要自己满足)
class Date {
public:
Date(int year = 1, int month = 1, int day = 1)
:_year(year)
,_month(month)
,_day(day)
{}
int _year;
int _month;
int _day;
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
};
struct DataHashFunc {
size_t operator()(const Date& date)
{
size_t num = 0;
num += date._year;
//BKDR
num *= 131;
num += date._month;
num *= 131;
num += date._day;
num *= 131;
return num;
}
};
int main()
{
//当然还有比较的模板, 因为这里设计时都没有加
//所有就直接在Data里 加了opeator== > <;
//这里也可以看出来,key必须满足 1.转换整形 2.支持比较
xryq::HashTable<Date, int, DataHashFunc> ht;
ht.Insert({ {2024, 10, 12}, 1 });
ht.Insert({ {2024, 12, 10}, 2 });
return 0;
}
BKDR
利用全特化,将其设置为默认模板
template<class K>
struct HashFunc {
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//如果不想每次实现都自己传,可以利用全特化,将其设置为默认模板
//以string 为例子
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t num = 0;
for (auto ch : s)
{
num += ch;
//为什么要乘 131
//这涉及到BKDR-哈希表算法
//这可以最大程度避免冲突的情况,具体如何实现可以上网搜索
num *= 131;
}
return num;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
……………………
……………………
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;
};
}
哈希函数
一个好的哈希函数应该让N个关键字被等概率的均匀的散列分布到哈希表的M个空间中,但是实际中却很难做到,但是我们要尽量往这个方向去考量设计。
除法散列法/除留余数法
- 除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过key除以M的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。
- 当使用除法散列法时,建议M取不太接近2的整数次冥的⼀个质数(素数)。
c++的哈希表的实现就是利用素数组(大佬用数学方法找到的最适合哈希表的M 素数)
inline unsigned long __stl_next_prime(unsigned long n) { // Note: assumes long is at least 32 bits. static const int __stl_num_primes = 28; 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 }; 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; }
- 当使用除法散列法时,要尽量避免M为某些值,如2的冥,10的冥等。如果是 2^x,那么key %2^x 本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。
如:
- {63 , 31}看起来没有关联的值,如果M是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。
- 如果是 10^X,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 10^2,那么计算出的哈希值都是12。
但要注意的是:
- 需要说明的是,实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是2的整数次冥做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效⼀些。但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key’=key>>16,然后把key和key' 异或的结果作为哈希值。
- 也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。(最终目的都是这)所以我们上面建议M取不太接近2的整数次冥的⼀个质数的理论是大多数数据结构书籍中写的理论吗,但是实践中,灵活运⽤,抓住本质,而不能死读书
java代码简单模拟实现(对上面的解释):
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
//自我实现
//HashTable()
// :_tables(11)
// , _n(0)
//{}
//c++ 的实现
//HashTable()
// :_tables(__stl_next_prime(0))
// ,_n(0)
//{}
//java 的简单模拟
HashTable()
:_tables(pow(2, _m))
, _n(0)
{}
size_t HashFunc_java(const K& key)
{
//size_t hash0 = key & (pow(2, _m) - 1);
size_t hash0 = key & (_tables.size() - 1);
hash0 ^= (key >>(32 - _m));
return hash0;
}
//线性探测
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
if (_n * 10 / _tables.size() >= 7)
{
//java实现
//方便的地方就是扩容直接是两倍
HashTable<K, V, Hash> newt;
newt._tables.resize(_tables.size() * 2);
for (auto e : _tables)
{
if (e._state == EXIST)
{
newt.Insert(e._kv);
}
}
_tables.swap(newt._tables);
}
//线性探测
//不是 capacity size才是哈希表的容积
Hash hash;
size_t hsah0 = HashFunc_java(hash(kv.first));
size_t hashi = hsah0;
//为什么没有显示
while (_tables[hashi]._state != EMPTY)
{
//线性探测;
hashi++;
hashi = hashi % _tables.size();
}
_tables[hashi]._state = EXIST;
_tables[hashi]._kv = kv;
++_n;
return true;
}
private:
vector<HashData<K, V>> _tables; // 指针数组
size_t _n = 0; // 表中存储数据个数
size_t _m = 16;
};
模拟实现的局限性 :
那Java是如何解决的呢?这里有一种思路,但不模拟实现了。了解一下就好
扩容
这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容了,我们还是按照2倍扩容,但是同时我们要保持哈希表大小是一个质数,第⼀个是质数,2倍后就不是质数了。那么如何解决呢?
- 一种方案就是上面 除法散列中我们讲的Java HashMap的使⽤2的整数冥,但是计算时不能直接取模的改进方法。(上面代码也简单的展示了。)
- 另外一种方案是SGI版本的哈希表使用的方法,给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。
相关应用:
bool Insert(const pair<K, V>& kv)
{
//我们哈希表负载因⼦控制在0.7
//if (_n * 10 / _tables.size() > 7)
//{
// //.....
// //重复线性探测的过程
// vector<HashData<K, V>> newtable(_tables.size() * 2);
// size_t hhash0 = kv.first % newtable.size();
// size_t hhashi = hhash0;
// //为什么没有显示
// while (newtable[hhashi]._state == EMPTY)
// {
// //线性探测;
// hhashi++;
// hhashi = hhashi % newtable.size();
// }
// newtable[hhashi]._state = EXIST;
// newtable[hhashi]._kv = kv;
// ++_n;
// _tables.swap(newtable);
//}
if (_n * 10 / _tables.size() >= 7)
{
//自我实现扩大的是两倍
//HashTable<K, V> newt;
//newt._tables.resize(_tables.size() * 2);
//c++实现
HashTable<K, V> newt;
//lower_bound(first, last, n); +1 正好能找到比他大的
newt._tables.resize(__stl_next_prime(_tables.size() + 1));
for (auto e : _tables)
{
if (e._state == EXIST)
{
newt.Insert(e._kv);
}
}
_tables.swap(newt._tables);
}
//线性探测
//不是 capacity size才是哈希表的容积
size_t hsah0 = kv.first % _tables.size();
size_t hashi = hsah0;
//为什么没有显示
while (_tables[hashi]._state != EMPTY)
{
//线性探测;
hashi++;
hashi = hashi % _tables.size();
}
_tables[hashi]._state = EXIST;
_tables[hashi]._kv = kv;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
size_t hash0 = key % _tables.size();
size_t hashi = hash0;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.fist == key)
{
return &_tables[hashi];
}
hashi++;
hashi = hashi % _tables.size();
}
return nullptr;
}