开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第18天,点击查看活动详情
哈希表(散列表)
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
1.模板介绍
1)拉链法
通过哈希函数得到的哈希值可能会有冲突的,那么就将冲突的数形成一个链表,如下图。
//h 哈希数组
//e ne 当前位的拉链(前面静态数组模拟链表)
//idx 标识链表节点数 参考前面静态数组模拟链表
int h[N], e[N], ne[N], idx;
// 向哈希表中插入一个数
void insert(int x)
{
//N最好是质数,那么哈希函数取得值冲突就越小(数学证明)
//x%N 可能x为负数 +N %N 是为了让哈希结果为正数
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
h[k] = idx ++ ;
}
// 在哈希表中查询某个数是否存在
bool find(int x)
{
int k = (x % N + N) % N;
//循环当前数的哈希值的链表
for (int i = h[k]; i != -1; i = ne[i])
if (e[i] == x)
return true;
return false;
}
2)开放寻址法
就是哈希函数得到的值没位置了,就向前开个位置。(厕所蹲坑,没位置移旁边去)
N的大小要开到 数据范围的2-3倍
int h[N];
const int null=0x3f3f3f3f;
// 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
int find(int x)
{
int t = (x % N + N) % N;
while (h[t] != null && h[t] != x)
{
t ++ ;
if (t == N) t = 0;
}
return t;
}
3)实操演练
拉链法
维护一个集合,支持如下几种操作:
I x,插入一个数 xx;Q x,询问数 xx 是否在集合中出现过;现在要进行 NN 次操作,对于每个询问操作输出对应的结果。
#include<iostream>
#include<cstring>
using namespace std;
const int N=1e5+3;
int h[N],e[N],ne[N],idx;
void insert(int x){xx}
bool query(int x){xx}
int main()
{
int n;
cin>>n;
memset(h,-1,sizeof h);
while(n--)
{
string c;
int x;
cin>>c>>x;
if(c=="I")
{
insert(x);
}else
{
bool result=query(x);
if(result)
{
cout<<"Yes"<<endl;
}else
{
cout<<"No"<<endl;
}
}
}
}
开放寻址法
#include<iostream>
#include<cstring>
using namespace std;
const int N=2e5+3;
const int null=0x3f3f3f3f;
int h[N];
int query(int x){xxx}
int main()
{
int n;
cin>>n;
memset(h,0x3f,sizeof h);
while(n--)
{
char op;
int x;
cin>>op>>x;
if(op=='I')
{
h[query(x)]=x;
}else
{
if(h[query(x)]!=null)
{
cout<<"Yes"<<endl;
}else
{
cout<<"No"<<endl;
}
}
}
return 0;
}
4)解释问题
引用AcWing 840. 基础_哈希表_模拟散列表_开放寻址法细节 - AcWing
const int null = 0x3f3f3f3f 和 memset(h, 0x3f, sizeof h)之间的关系;
首先,必须要清楚memset函数到底是如何工作的
先考虑一个问题,为什么memset初始化比循环更快?
答案:memset更快,为什么?因为memset是直接对内存进行操作。memset是按字节(byte)进行复制的
void * memset(void *_Dst,int _Val,size_t _Size);
这是memset的函数声明
第一个参数为一个指针,即要进行初始化的首地址
第二个参数是初始化值,注意,并不是直接把这个值赋给一个数组单元(对int来说不是这样)
第三个参数是要初始化首地址后多少个字节
看到第二个参数和第三个参数,是不是又感觉了
h是int类型,其为个字节, 第二个参数0x3f八位为一个字节,所以0x3f * 4(从高到低复制4份) = 0x3f3f3f3f
这也说明了为什么在memset中不设置除了-1, 0以外常见的值
比如1, 字节表示为00000001,memset(h, 1, 4)则表示为0x01010101
为什么要取0x3f3f3f,为什么不直接定义无穷大INF = 0x7fffffff,即32个1来初始化呢?
3.1 首先,0x3f3f3f的体验感很好,0x3f3f3f3f的十进制是1061109567,也就是10^9级别的
(和0x7fffffff一个数量级),而一般场合下的数据都是小于10^9的,所以它可以作为无穷大
使用而不致出现数据大于无穷大的情形。
比如0x3f3f3f3f+0x3f3f3f3f=2122219134,这非常大但却没有超过32-bit,int的表示范围,
所以0x3f3f3f3f还满足了我们“无穷大加无穷大还是无穷大”的需求。
但是INF不同,一旦加上某个值,很容易上溢,数值有可能转成负数,有兴趣的小伙伴可以去试一试。
3.2 0x3f3f3f3f还能给我们带来一个意想不到的额外好处:如果我们想要将某个数组清零,
我们通常会使用memset(a,0,sizeof(a))这样的代码来实现(方便而高效),但是当我们想
将某个数组全部赋值为无穷大时(例如解决图论问题时邻接矩阵的初始化),就不能使用
memset函数而得自己写循环了(写这些不重要的代码真的很痛苦),我们知道这是因为memset
是按字节操作的,它能够对数组清零是因为0的每个字节都是0,
现在如果我们将无穷大设为0x3f3f3f3f,那么奇迹就发生了,0x3f3f3f3f的每个字节都是0x3f!所以
要把一段内存全部置为无穷大,我们只需要memset(a,0x3f,sizeof(a))。
memset(a,-1,sizeof h)
2.字符串的哈希
给定一个长度为 nn 的字符串,再给定 mm 个询问,每个询问包含四个整数 l1,r1,l2,r2l1,r1,l2,r2,请你判断 [l1,r1][l1,r1] 和 [l2,r2][l2,r2] 这两个区间所包含的字符串子串是否完全相同。
字符串中只包含大小写英文字母和数字。
核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果
//不能将字母映射为0,因为0的各个进制都是0,会导致一些不同的字段为相同哈希值了
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
区间和公式的理解: ABCDE 与 ABC 的前三个字符值是一样,只差两位,
乘上 P2P2 把 ABC 变为 ABC00,再用 ABCDE - ABC00 得到 DE 的哈希值。
ULL get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}