
ok,在这些方法中,我们会着重学习除法散列法/除留余数法以及相应的开放地址法中的线性探测,链地址法
话不多说,直接开始~
哈希(hash)又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。
这里有个问题:哈希函数是怎么设计的?我们接着看~
当关键字的范围比较集中时,直接定址法就是非常简单高效的方法
比如一组关键字都是在[0,99]之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。
再比如一组关键字值都在[a,z]的小写字母,那么我们开一个26个数的数组,每个关键字的 acsii码-a的acsii码 就是存储位置的下标。
也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。这个方法我们在计数排序部分使用过,其次在string章节中也使用过——
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

除法散列法也叫做除留余数法,顾名思义——
假设哈希表的大小为M,那么同过key除以M的余数作为映射位置的下标,也就是哈希函数为:
当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。
如果是

,那么key%

的本质就相当于保留key的后x位,那么如果后x位是相同的值,计算出的哈希值都是一样的,这就冲突了。
比如:[63,31}看起来没有关联的值,如果M是16,也就是2^4,那么计算出的哈希值都是15,因为63的二进制后8位是00111111,31的二进制后8位是00011111。如果是10^x,就更明显了,保留的都是10进值的后X位,如:[112,12312},如果M是100(10^2),计算出的哈希值都是12。 注意:当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)
实践中也是八仙过海,各显神通,Java中,HashMap采用除法散列法时就是2的整数次幂做哈希表的M,这样一来我们可以直接位运算,相对取模会更加高效些。只是Java不是单纯地取模,比如说M是2^16,本质上是取后16位,那么用key' = key >> 16,然后把key和key'异或的结果作为哈希值,也就是说:我们可以映射的值在[0 , M]范围内,尽可能让key所有的位都参与计算,这样映射出的哈希值更加均匀一些

乘法散列法对哈希表大小M没有要求,这里介绍一下大思路:
h(key) = floor(M * ((A * key) % 1.0)),其中floor表示对表达式进行下取整,A(0 , 1),%1.0是为了取小数,这里最重要的是A的值应该如何设定,Knuth——这又是一位大佬——他认为A = (5 - 1) / 2 = 0.6180339887...(黄金分割点)比较好。
乘法散列法对哈希表大小M是没有要求的,假设M为1024,key为1234,A = 0.6180339887,A * key = 762.6539420558,取小数部分为0.6539420558,M * ((A * key) % 1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。
如果存在这样一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中——这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。
解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。
hab(key) = ((a * key + 6) % P) % M,P需要选一个足够大的质数,a可以随机选[1 , P - 1]之间的 任意整数,b可以随机选[0 , P - 1]之间的任意整数,这些函数构成了一个P * (P - 1)组全域散列函数组。假设P = 17,M = 6,a = 3,b = 4,则h34(8) = ((3 * 8 + 4) % 17) % 6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查 改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数, 查找又是另一个散列函数,就会导致找不到插入的key了。
直接定址法的缺点也是非常明显的,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。假设我们只有数据范围是[0,9999]的N个数,我们要映射到一个M个空间的数组中(一般情况下M>=N),那么就要借助哈希函数(hasn function),关键字key被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0,M)之间。
从直接定址法的优缺点来看,无论哈希函数设计得多么巧妙,都难以完全避免不同的键被映射到同一位置的情况,即 哈希冲突。冲突的发生会严重影响哈希表的性能。因此,在设计好哈希函数之后,我们还需要一套完善的 冲突解决策略 来处理这些‘碰撞’。尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要设计出解决冲突的方案。
实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?
主要有两种方法:
在开放地址法中所有的元素都被放到哈希表中,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储。这里的规则有三种:
从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如过在寻找的过程中走到了哈希表的尾部(或者是越界了),则回到哈希表的头的位置
h(key)=hash0=key%M(key为数据,M为哈希表的大小)(hash0位置为key本来要占的位置),hash0位置冲突了,则线性探测公式为:

所谓冲突:就是本来应该是我key要插入的位置,结果被别人占了,那我就需要继续向后找,找到一个没有被占的位置,然后我就去占这个位置

线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下面的二次探测可以⼀定程度改善这个问题。



开放定址法在实践中是不如下面会介绍的链地址法的,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题——正因如此,开放定址法我们简单选择线性探测实现即可。
通过上面的一点学习,我们不难发现其实哈希表本质上是一个数组,但不仅仅是一个普通数组。
既然是一个数组并且后面还有扩容的操作,那我们就可以直接使用vector,定义出下面的结构——

在vector中我们直接存储一个pair类型的数据。
但是,这里定义成这样的结构是不行的。
why?因为这个结构有一个极大的缺陷

#include<iostream>
#include<vector>
#include<unordered_map>
using namespace std;
enum State
{
EMPTY,//位置上状态为空
DELETE,//位置上状态为删除
EXIST//位置上状态为存在
};
template<class k,class v>
//破局
struct HashDate
{
State _state=EMPTY;//每个位置上都有这么一个状态
pair<k, v> _kv;//保存的数据
};
template<class k, class v>
class HashTable
{
private:
vector<HashDate<k,v>> _table;
size_t _n;//存储的数据个数
};
这就意味着,我真要删掉了30,我就把这个位置上的状态标记为DELETE(删除),再来find(20),30这个位置没有值,但是状态是删除,状态不是空,是删除还要继续往后找
查找是找到空值才能停止,通过状态标志,把空和删除分开,这样删除就不会影响查找,因为查找要查找到空才停止
插入插的是一个pair<k,v>
插入数据使用的哈希函数是线性探测

当发生哈希冲突时,顺序查找下一个空闲位置(通常是向后逐个探测),直到找到空位为止。
= key % table_size
ok,我们先来看当表不是满的时候,插入的代码是怎么写的————
//插入pair<k,v>
bool insert(const pair<k, v>& kv)
{
//先计算key第一个要映射的位置
size_t hash0 = key % _table.size();
size_t i = 1;
size_t hashi = hash0;
//如果第一个位置不是exist,则直接插入;
//若发生冲突则往后查找位置状态不是exist的位置,状态为empty或者是delete都可以插入
while (_table[hashi]._state == EXIST)
{
//% _table.size()的目的是为了防止越界
hashi = (hashi + i) % _table.size();
++i;
}
//跳出循环,找到位置
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
ok,这里有个问题:为什么size_t hash0 = key % _table.size();中的M是_table.size(),而不是capacity呢?
因为我们要将哈希值映射到一个有效的数组索引上,而 _table.size() 代表的是当前数组中实际用于存储哈希桶的容器大小,也就是索引的范围。
_table.size(): 返回的是当前容器中已经存在的元素个数。在 std::vector 实现的哈希表中,这通常就是底层数组的有效长度,即从 [0] 到 [size() - 1] 的位置都是可以安全访问的。
_table.capacity(): 返回的是底层数组实际分配的内存空间,能够容纳的元素个数的最大值。在 capacity() 范围内,从 [0] 到 [size() - 1] 是已构造/初始化的对象,而从 [size()] 到 [capacity() - 1] 是未初始化的内存,不能直接访问。
那当我们一直插入一直插入,表是不是会有满的时候,但是这里的满不是真的满,而是要看负载因子——

在线性探测中,当负载因子>=0.7的时候,我们就应该执行扩容操作
那我们是不是按照下面的写法,直接进行扩容操作呢?

ok,这样直接进行扩容是不行的。为什么?
如果我们直接进行扩容操作,原先的数据的位置没有改变,仅仅调整数组大小是不够的,因为元素的位置是通过 hash % size 计算的。当 size 改变后,所有元素都需要重新计算位置
举个例子:

破局法1——

这样做还是有点麻烦的
破局法2——

这个方法是直接复用我们的insert,更高效!!!
代码演示:
//插入pair<k,v>
bool insert(const pair<k, v>& kv)
{
//负载因子>=0.7是就要扩容
if ((double)_n / _table.size() >= 0.7)
{
////_table.resize(2 * _table.size());
////方法1:搞一个新的数组,拷贝旧数据,将旧表中的数据重新映射到新表中,然后交换
//veector<HashDate<k, v>> newTable(2 * _table.size());
//for (size_t i = 0; i < _table.size(); i++)
//{
// if (_table[i]._state == EXIST)
// {
// //重新映射
// //……
// }
//}
//_table.swap(newTable);
//方法2:创建一个新的哈希表
HashTable<k, v> newtable;
newtable._table.resize(2 * _table.size());
//遍历旧的表,将旧表中的pair数据插入到新表中
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST) {
newtable.insert(_table[i]._kv);
}
}
//交换新旧哈希表中的_table表
_table.swap(newtable._table);
}
//先计算key第一个要映射的位置
size_t hash0 = kv.first % _table.size();
size_t i = 1;
size_t hashi = hash0;
//如果第一个位置不是exist,则直接插入;
//若发生冲突则往后查找位置状态不是exist的位置,状态为empty或者是delete都可以插入
while (_table[hashi]._state == EXIST)
{
//% _table.size()的目的是为了防止越界
hashi = (hashi + i) % _table.size();
++i;
}
//跳出循环,找到位置
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}ok,其实上面的插入代码还有一个问题:当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)
对于这个问题,我们写出这样一个函数:
//使用下面的函数就可以实现了建议M取不太接近2的整数次幂的一个质数(素数)
//以保证key的每一位2进制都参与运算
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
};
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;
// >= n
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}#include<iostream>
#include<vector>
using namespace std;
//使用下面的函数就可以实现了建议M取不太接近2的整数次幂的一个质数(素数)
//以保证key的每一位2进制都参与运算
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
};
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;
// >= n
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
enum State
{
EMPTY,//位置上状态为空
DELETE,//位置上状态为删除
EXIST//位置上状态为存在
};
template<class k,class v>
struct HashDate
{
State _state;//每个位置上的状态
pair<k, v> _kv;//存储的数据
};
template<class k, class v>
class HashTable
{
public:
HashTable()
:_table(__stl_next_prime(1))
{}
//插入pair<k,v>
bool insert(const pair<k, v>& kv)
{
//不允许插入相同的key
if (find(kv.first))
{
return false;
}
//负载因子>=0.7是就要扩容
if ((double)_n / _table.size() >= 0.7)
{
////_table.resize(2 * _table.size());
////方法1:搞一个新的数组,拷贝旧数据,然后交换
//vector<HashDate<k, v>> newTable(2 * _table.size());
//for (size_t i = 0; i < _table.size(); i++)
//{
// if (_table[i]._state == EXIST)
// {
// //重新映射
// //……
// }
//}
//_table.swap(newTable);
//创建一个新的哈希表
HashTable<k, v> newtable;
newtable._table.resize(__stl_next_prime(_table.size()+1));
//遍历旧的表,将旧表中的pair数据插入到新表中
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST) {
newtable.insert(_table[i]._kv);
}
}
//交换新旧哈希表中的_table表
_table.swap(newtable._table);
}
//先计算key第一个要映射的位置
size_t hash0 = kv.first % _table.size();
size_t i = 1;
size_t hashi = hash0;
//查找位置状态不是exist的位置,状态为empty或者是delete都可以插入
while (_table[hashi]._state == EXIST)
{
//% _table.size()的目的是为了防止越界
hashi = (hashi + i) % _table.size();
++i;
}
//跳出循环,找到位置
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
private:
vector<HashDate<k,v>> _table;
size_t _n;//存储的数据个数
};ok,有了上面插入的代码思路后,对于查找来说就很简单了——
//返回值类型为HashDate<k, v>*,方便修改相应的数据
HashDate<k, v>* find(const k& key)
{
//计算key映射的位置
size_t hash0 = key % _table.size();
size_t i = 1;
size_t hashi = hash0;
//只要这个位置不是空就继续查找
while (_table[hashi]._state !=EMPTY)
{
//只有位置状态为exist的位置中的数据才进行key值比较
if (_table[hashi]._state!=DELETE&&_table[hashi]._kv.first == key)
{
return &_table[hashi];
}
hashi = (hashi + i) % _table.size();
++i;
}
return nullptr;
}删除的大致思路:传参传一个key,然后在表中查找key
bool erase(const k& key)
{
//删除前先进行查找,如果找到了,就让其状态为delete
HashDate<k, v>* ret = find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}上面所写的是关键字可以取模的(也就是关键字可以转成整型),那关键字不能取模呢(也就是关键字不能转成整型)?

就比如说,字符串是不能直接转换成整型,我们要想个办法转换一下——
那我们就可以写个函数模板(作用是:将数据转换成size_t整型),将这个函数模版作为第三个参数——

但是上面的函数模板没有办法将string转成整型,ok,那我们就应该自己写个仿函数出来,将字符串的每一位的acsii码相加起来,用来取模——

但是上图中的仿函数还是有点问题——

用上面的仿函数将string中每一位相加的结果有可能是相同的,这样会导致冲突增加。ok,我们可以在加下一个字符的acsii码先*131,这样就可以避免冲突——

当我们看到库中的unordered_map的时候,好像不需要传这个第三个模板参数——

其实我们也可以这么做,可以将函数模板特化一下——

string是非常常见作为哈希表的Key的类型,特化一下,这样我们就不需要显示传第三个模板参数!!!
库中的unordered_map也不是可以将所有类型的key都转成整型——

不能将pair转成整型,就需要自己实现一个仿函数把这个pair类型的key转成整型,用来取模
大思路:若是结构体,取结构体中的一项想办法转成整型,用来取模
比如:日期,可以把年月日相加,(可能会出现相加结果是一样的情况:2025 1 12 和 2025 12 1 相加结果是一样的,可以把年月相加后*131,再加日)
#pragma once
#include<iostream>
#include<vector>
#include<unordered_map>
using namespace std;
//使用下面的函数就可以实现了建议M取不太接近2的整数次幂的一个质数(素数)
//以保证key的每一位2进制都参与运算
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
};
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;
// >= n
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
enum State
{
EMPTY,//位置上状态为空
DELETE,//位置上状态为删除
EXIST//位置上状态为存在
};
template<class k,class v>
//破局
struct HashDate
{
State _state=EMPTY;//每个位置上都有这么一个状态
pair<k, v> _kv;//保存的数据
};
template<class k>
struct HashFunc
{
//将不是整型的转换成整型
size_t operator()(const k& key)
{
return size_t(key);
}
};
//特化一下
template<>
struct HashFunc<string>
{
//我们可以将string中的每一位的asscal码值加起来去取模
//我们可以再加之前*131,避免大量冲突
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash += e;
//我们可以再加下一个之前 * 131
hash *= 131;
}
return hash;
}
};
template<class k, class v,class hash=HashFunc<k>>
class HashTable
{
public:
HashTable()
:_table(__stl_next_prime(1))
{}
//插入
hash hs;//仿函数,将数据变成整型,以便取模
bool insert(const pair<k, v> kv)
{
//不允许插入相同的key
if (find(kv.first))
{
return false;
}
//若空间不够,还插什么插
//这里看空间是否足够,看的是复杂因子
// 负载因⼦⼤于0.7就扩容
if ((double)_n / (double)_table.size() >= 0.7)
{
//_table.resize();
//直接进行扩容操作,原先的数据的位置没有改变,会有问题
//方法1:搞一个新的数组,拷贝旧数据,然后交换
// 有点麻烦
//vector<HashDate<k, v>> newtables(2 * _table.size());
//for (size_t i = 0; i < _table.size(); i++)
//{
// if (_table[i]._state == EXIST) {
// //重新映射
// }
//}
////交换
//_table.swap(newtables);
//方法2:直接重新搞一个哈希表
HashTable<k, v, hash> newht;
newht._table.resize(__stl_next_prime(_table.size()+1));
//遍历旧表,将旧表中的数据插入到新表中
for (size_t i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST) {
//旧表数据插入到新表中
newht.insert(_table[i]._kv);
}
}
//交换新旧哈希表中的_table表
_table.swap(newht._table);
}
//计算出第一个要映射的位置
size_t hash0 = hs(kv.first) % _table.size();
size_t i = 1;
size_t hashi = hash0;
//寻找可以插入的位置,只要位置不为exist就可以插入
while (_table[hashi]._state == EXIST)
{
hashi = (hashi + i) % _table.size();
//(hashi + i) % _table.size()是为了防止越界
++i;
}
//跳出循环说明找到位置
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
HashDate<k, v>* find(const k& key)
{
size_t hash0 = hs(key) % _table.size();
size_t i = 1;
size_t hashi = hash0;
//只要这个位置不是空就继续查找
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == key)
{
return &_table[hashi];
}
hashi = (hashi + i) % _table.size();
++i;
}
return nullptr;
}
bool erase(const k& key)
{
//删除前先进行查找,如果找到了,就让其状态为delete
HashDate<k, v>* ret = find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
private:
//vector<pair<k, v>> _table;
//这样写是不行的
//为什么?如果我删除某个位置上的数据,那这个位置上就是nullptr
// 我在进行查找的时候,比如查找删除数据的后一个位置上的数据
// 那就查找不到,因为那个删除数据位置上是nullptr
// 找到空就停止了,所以我们不能这么写
//破局
//直接复用vector,目的是为了方便扩容
vector<HashDate<k, v>> _table;
size_t _n = 0;//存储的数据个数
};请大佬不要忘记给博主来个赞哦!
૮₍ ˶ ˊ ᴥ ˋ˶₎ა