哈希表理论基础
哈希表有两大要点:
存储结构
开放寻址法
拉链法
字符串哈希
哈希表的最主要作用是将一个比较大的范围(如10^9)映射到一个比较小的空间(0 ~ N,其中N一般为10^5 - 10^6),通过一个哈希函数h(x)完成上述操作。
一般情况下哈希函数的写法,以上述为例:
h(x) = x % 1e5; // x mod 10^5
// 一般来说哈希函数中模的那个数要取成一个质数且离2的整次幂尽可能的远
// 数学上证明这样取的概率造成哈希冲突的概率是最低的
这里很容易产生哈希冲突,可能会把若干不同的数映射为同一个数。处理冲突的方法有两种:开放寻址法和拉链法。
拉链法
开辟一个10^5大小的空间,每个空间相当于一个槽,每次映射到某个位置就在下面连接一条链。
其实就是单链表的存储方式。
拉链法是把所有的同义词用单链表链接起来的方法。在这种方法中,哈希表的每个单元存储的不再是元素本身,而是相应同义词单链表的头指针(注意是头指针而不是头节点)。
对于单链表,我们可以采用数组的方式进行实现。此外,使用拉链法时,m的大小通常和 n差不多。例如,若 n ≤ 10^5 ,我们可以寻找大于等于10^5 的第一个质数,即 m = 100003 。
// 求符合要求的数:
#include <iostream>
using namespace std;
int main(){
for(int i = 100000; ; i++){
bool flag = true;
for(int j = 2; j * j <= i; j++){
if(i % j == 0){
flag = false;
break;
}
}
if(flag){
cout << i << endl;
break;
}
}
return 0;
}
// 输出100003,说明大于100000的最小的质数为100003,于是用这个数作为哈希函数的模的数
平均情况下我们可以将每条链看作“很短”,因此一般情况下哈希表的时间复杂度可以看成O(1)。一般算法题中不需要我们对哈希表进行删除操作,基本都是添加和查找操作。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 100003;
int n;
int h[N], e[N], ne[N], idx;
void insert(int x){
// 防止k为负数
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
// h[k]看成head头指针。h[k]=idx++的原因是由于头插法导致头指针在不断改变
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;
}
int main(){
scanf("%d", &n);
// 初始化链表(链表中-1代表NULL)
memset(h, -1, sizeof h);
while(n--){
char op[2];
int x;
scanf("%s %d", op, &x);
if(*op == 'I'){
insert(x);
}
else{
if(find(x)) puts("Yes");
else puts("No");
}
}
}
看似有很多链表其实只开了两个数组构造了一个链表,通过把hk改成各个链表起始指针的方式吧各个链表拼接起来。
过程中开了next[N]想用这个表示链表元素的下一个元素,但是由于next是c++中的关键字:
a.cpp: In function 'void insert(int)':
a.cpp:13:5: error: reference to 'next' is ambiguous
13 | next[idx] = h[k];
| ^~~~
更改next[N]为ne[N]后问题得到解决。
开放寻址法
开放寻址法就是在插入一个关键字为 k 的元素时,若发生哈希冲突,则通过某种哈希冲突解决函数(也称为再哈希)得到一个新空闲地址再插入该元素的方法。
线性探测法是从发生冲突的地址开始,依次探测下一个地址,直到找到一个空闲单元为止。当到达下标为 m − 1的哈希表表尾时,下一个探测地址是表首地址0。当 m ≥ n 时一定能找到一个空闲单元。
使用开放寻址法时,m 通常取 n 的 2 ∼ 3 倍左右。(经验值)
那么这里取n的2倍(200000),我们要先找到一个大于200000的最小的质数:
#include <iostream>
using namespace std;
int main(){
for(int i = 200000; ; i++){
bool flag = true;
for(int j = 2; j * j <= i; j++){
if(i % j == 0){
flag = false;
break;
}
}
if(flag){
cout << i << endl;
break;
}
}
return 0;
}
// 输出200003,则我们取N = 200003
开放寻址法的find函数为int类型,用于寻找x所在位置,如果x存在,那么就返回其存在的位置,如果x不存在,就返回其应该存在的位置。
#include <iostream>
#include <cstring>
using namespace std;
// 约定一个null值作为标准,如果h数组上的某个位置等于这个标准的话,就说明这个位置是空的
// 要求这个数不在 -10^9 <= x <= 10^9 范围内即可
const int N = 200003, null = 0x3f3f3f3f;
int n;
int h[N];
int find(int x){
int k = (x % N + N) % N;
// 该位置有人且值不为x,就要向后看
while(h[k] != null && h[k] != x){
k++;
if(k == N) k = 0;
}
return k;
}
int main(){
scanf("%d", &n);
memset(h, 0x3f, sizeof h);
while(n--){
char op[2];
int x;
scanf("%s %d", op, &x);
if(*op == 'I') h[find(x)] = x;
else{
if(h[find(x)] == null) puts("No");
else puts("Yes");
}
}
return 0;
}
为什么使用null = 0x3f3f3f3f和memset(h, 0x3f, sizeof h)可以看这里
字符串哈希
当key为字符串时,h(key)称为字符串哈希。
将字符串看成一个p进制的数,将它转化为十进制数再对Q取模,即可得到一个映射到0-Q范围内的数
str : "ABCD"
令A = 1, B = 2, C = 3, D = 4
h["ABCD"] = (1*p^3 + 2*p^2 + 3*p^1 + 4*p^0) % Q
注意:
不能将字符映射成0:例如将"A"映射成0,则"AA","AAA"的值均为零。所以应当从1开始。
经验值:
当p = 131 或 13331
Q = 2^64时
几乎不存在冲突
那么这样做的好处是什么呢?我们可以通过求前缀哈希,通过某一个公式求出任意子串的哈希值:
前缀哈希:例如"ABCDE"
h[0] = 0;
h[1] = h["A"];
h[2] = h["AB"];
h[3] = h["ABC"];
...
类似于前缀和的思路:
string: _______|__________|__________
高位 L R 低位
现在已知h[R]和h[L - 1]
我们有:
h[R] = p^R-1 * "_" + p^R-2 * "_" + ... + p^0 * "_"
h[L - 1] = p^L-2 * "_" + p^L-3 * "_" + ... + p^0 * "_"
容易知道只需:h[R] - h[L - 1]*p^(R - L + 1)
就能求得L-R间子串的哈希值
同时由上面的字符串哈希的定义,有:
h[i] = h[i - 1]*p + str[i];
AcWing 841.字符串哈希
#include <iostream>
using namespace std;
typedef unsigned long long ULL;
const int N = 100010, P = 131;
int n, m;
char str[N];
ULL p[N], h[N];
ULL find(int l, int r){
return h[r] - h[l - 1] * p[r - l + 1];
}
int main(){
scanf("%d %d", &n, &m);
// str从str[1]开始填入数据,因为h[i] = h[i - 1]*p + str[i]、h[0] = 0
scanf("%s", str + 1);
p[0] = 1;
for(int i = 1; i <= n; i++){
p[i] = p[i - 1] * P;
h[i] = h[i - 1] * P + str[i];
}
while(m--){
int l1, r1, l2, r2;
scanf("%d %d %d %d", &l1, &r1, &l2, &r2);
if(find(l1, r1) == find(l2, r2)) puts("Yes");
else puts("No");
}
return 0;
}
因为上面提到:当p = 131、Q = 2^64时几乎不存在冲突,所以这里有一个小技巧,使用unsighed long long的数据类型,它的大小就是2^64,这样我们就无需进行取模的操作了,因为它溢出后就相当于帮我们取模了。