Hash表
1、概述
主要为以下两部分:
- 哈希表的存储结构
- 字符串的哈希方式
1.1 Hash表的作用
把一堆复杂的结构映射到0~N一个小一点的范围内
如把0 ~ \(10^9\)但是其中有很多没意义的数字,把其中有意义的数字映射到一个小范围内,如0 ~ \(10^5\)。
如下面模拟散列表的例子。
1.2 如何写Hash函数
x mod \(10^5\)就可以把值域比较大的数映射到\(10^5\)这个范围内了
- 有可能产生冲突:把两个不一样的数映射成了同一个数,如hash(1000) = 9,hash(20) = 9
- 处理冲突的方法:开放寻址法和拉链法
这个取模的数要取成质数,因为质数会离2的整数次幂尽可能远,数学上可以证明,这么取冲突概率最小
1.3 哈希表的存储结构
1.3.1 开放寻址法
处理冲突的思路:只开一个一维数组没有去开一个链表,这个一维数组的长度一般要开到给定范围的2~3倍。
如下题中范围为\(10^5\),那么数组要开到\(2\times 10^5\)到\(3\times 10^5\)的范围,经验来讲,这个范围内冲突的概率会更小。
假设h(x) = k
添加:
那么我们先看k的位置有没有人,有人的话就去下一个位置,直到找到一个没有人的位置。插入进去。
查找:
从第k个位置开始,从前往后找,每一次先看当前位置有无人,有人且是x说明我们找到了,有人不是x就看下一个位置,当前位置没人就说明x不存在。
删除:
按照查找的方法找到x,打一个标记标明x被删除了

1.3.2 拉链法
开一个\(10^5\)的数组,把每一个位置当作一个槽,这个槽上拉出一个链,用这个链来存储当前槽上已经有的所有数

hash表作为一个期望算法,虽然每个槽都会拉一个链,但是在平均情况下,每一条链的长度可以看作常数。所以在一半情况下Hash表的时间复杂度为O(1)。
在算法题中,Hash一般都只有添加和查找两种操作,不会有删除操作。
算法题如果涉及到删除,一般不会真的删除,可以开一个bool数组,如果删除了就打一个标记
拉链寻址法的拉链实现:

2、模拟散列表题目

3、模拟散列表答案
3.1 拉链法
#include<iostream>
#include<cstring>
using namespace std;
// 找出质数
// 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;
// }
// }
const int N = 100003;
int h[N]; // 存储hash表
int e[N],ne[N]; // 存储链表,e是值,ne存储下一个数组在什么位置
int idx; // 当前指针位置,数组下标
void insert(int x)
{
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] == k) return true;
}
return false;
}
int main()
{
int n;
cin >> n;
memset(h,-1,sizeof h); // 空指针用-1表示
while(n--)
{
char op[2];
int x;
cin >> op >> x;
if(op[0] == 'I') insert(x);
else
{
if(find(x)) puts("Yes");
else puts("No");
}
}
return 0;
}
3.2 开放寻址法
#include<iostream>
#include<cstring>
using namespace std;
const int N = 200003; // 大于20万的最小质数
const int null = 0x3f3f3f3f; // 一个大于题目中10^9的数
int h[N]; // 存储hash表
int find(int x)
{
/**x在Hash表存在返回其位置,
* 不存在返回其应该存储的位置
*/
int k = (x%N+N)%N;
while(h[k] != null && h[k] != x) // 这个位置上有数,且这个数不等于x
{
k++;
if(k == N) k = 0; // 看完最后一个位置后需要循环看第一个位置
}
return k;
}
int main()
{
int n;
cin >> n;
// memset是按字节来memset的,h是int型数组,有四个字节,每个字节都是0x3f
memset(h,0x3f,sizeof h);
while(n--)
{
char op[2];
int x;
cin >> op >> x;
int k = find(x);
if(*op == 'I') h[k] = x;
else
{
if(h[k] != null) puts("Yes");
else puts("No");
}
}
return 0;
}
4、字符串哈希
这里涉及的是字符串前缀哈希法:
str = "ABCABCDEAPSTAR",求hash时先预处理出所有前缀的hash
hash[1]表示前1个字符的hash,h[2]表示前2个字符的hash
hash[0] = 0
hash[1] = "A"的hash值
hash[2] = "AB"的hash值
hash[3] = "ABC"的hash值
hash[4] = "ABCA"的hash值
这里有两个问题:
- 如何来定义某一个前缀的hash值
方法:把字符串看成1个p进制数,每一位上的字母就表示p进制数的每一位数字,指它的ASCII码
如求aStr = "ABCD"的hash值,把他看成一个四位数,首位为A,末尾为D
把A当成1,B=2,C=3,D=4,则aStr = \((1234)_p\)。转换成10进制就是\(1 \times p^3 + 2 \times p^2 + 3 \times p + 4 \times p^0\)。
这个数字可能非常大,比如这个字符串有几十万个字符,我们就需要对他进行取模
\(1 \times p^3 + 2 \times p^2 + 3 \times p + 4 \times p^0\) mod N;就可以把他映射到0~N-1。
这样就可以把任意字符串映射。
一般不能映射成0,因为A = 0,那么AA也是0,那么AB和AAB就是同一个数了。
- 如何预处理前缀hash值
h[i] = h[i-1] \(\times\) P + str[i];
- 这是假设运气足够好,完全不考虑冲突的情况下
一般根据经验取:p = 131或13331,N = \(2^{64}\)。
4.1 好处
可以利用前缀hash算出任意字串的hash值。
例子:已经1L-1的hash值h[L-1],1R的hash值,h[R]。如何通过这两个hash值把L~R的hash值算出来
h[R]的位数是\(p^{R-1},...,p^0\),
h[L-1]的位数是\(p^{L-2},...,p^0\),
第一步需要将2者对齐,把h[L-1]往左移使2者对齐
h[L-1] \(\times p^{R-L+1}\),
第二步:h[R]-h[L-1] \(\times p^{R-L+1}\),
取模的N是\(2^{64}\),我们只需要用unsigned long long来存储所有的h,这样就不需要取模了,它溢出就相当于取模了。
4.2 字符串哈希题目

4.3 字符串哈希答案
#include<iostream>
using namespace std;
typedef unsigned long long ULL;
const int N = 100010,P=131;
int n,m;
char str[N];
ULL h[N];
ULL p[N]; // 存储p的多少次方的
ULL get(int l, int r)
{
return h[r] - h[l-1] * p[r-l+1];
}
int main()
{
cin >> n >> m >> 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]; // str[i]不是0就可以
}
//询问
while(m--)
{
int l1,r1,l2,r2;
cin >> l1 >> r1 >> l2 >> r2;
if(get(l1,r1) == get(l2,r2)) puts("Yes");
else puts("No");
}
return 0;
}