算法学习笔记(20)——哈希表(Hash)
哈希表(Hash)
Hash 表又称为散列表,一般由 Hash 函数(散列函数)与链表结构共同实现。与离散化思想类似,当我们要对若干复杂信息进行统计时,可以用 Hash 函致把这些复杂信息映射到一个容易维护的值城内。因为值域变简单、范围变小,有可能造成两个不同的原始信息被 Hash 函数映射为相同的值,所以我们需要处理这种冲突情况,一般有两种方法。
拉链法(开散列)
建立一个邻接表结构,以 Hash 函数的值域作为表头数组 head,映射后的值相同的原始信息被分到同一类,构成一个链表接在对应的表头之后,链表的节点上可以保存原始信息和一些统计数据。
Hash 表主要包括两个基本操作:
- 计算 Hash 函数的值;
- 定位到对应链表中依次遍历、比较。
无论是检查任意一个给定的原始信息在 Hash 表中是否存在,还是更新它在 Hash 表中的统计数据,都需要基于这两个基本操作进行。
当 Hash 函数设计较好时,原始信息会被比较均匀地分配到各个表头之后,从而使每次查找、统计的时间降低到“原始信息总数除以表头数组长度”。若原始信息总数与表头数组长度都是 \(O(N)\) 级别且 Hash 函数分散均匀,几乎不产生冲突,那么每次查找、统计的时间复杂度期望为 \(O(1)\) 。
例如,我们要在一个长度为 \(N\) 的随机整数序列 \(A\) 中统计每个数出现了多少次。当数列 \(A\) 中的值都比较小时,我们可以直接用一个数组计数(建立一个大小等于值域的数组进行统计和映射,其实就是最简单的 Hash 思想)。当数列 \(A\) 中的值很大时,我们可以把 \(A\) 排序后扫描统计。这里我们换一种思路,尝试一下 Hash 表的做法。
设计 Hash 函数为 \(H(x)=(x \bmod P) + 1\) ,其中 \(P\) 是一个比较大的质数,但不超过 \(N\) 。显然,这个 Hash 函数把数列 \(A\) 分成 \(P\) 类,我们可以依次考虑数列中的每个数 \(A[i]\) ,定位到 \(head[H(A[i])]\) 这个表头所指向的链表。如果该链表中不包含 \(A[i]\) ,我们就在表头后插入一个新节点 \(A[i]\) ,并在该节点上记录 \(A[i]\) 出现了 \(1\) 次,否则我们就直接找到已经存在的 \(A[i]\) 节点将其出现次数加 \(1\) 。因为整数序列 \(A\) 是随机的,所以最终所有的 \(A[i]\) 会比较均匀地分散在各个表头之后,整个算法的时间复杂度可以近似达到 \(O(N)\) 。
// 找出大于1e5的第一个质数
for (int i = 1e5; ; 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;
}
}
#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)
{
// 求出哈希值
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;
}
int main()
{
memset(h, -1, sizeof h);
cin >> n;
while (n -- ) {
char op;
int x;
cin >> op >> x;
if (op == 'I') insert(x);
else
if (find(x)) puts("Yes");
else puts("No");
}
return 0;
}
开放寻址法
俗称"厕所找坑位法",开一个题目所给范围两倍大小的数组,每次查找时计算对应的哈希值,如果对应的位置"有人",则继续看相邻的下一个位置,直到找到空位为止。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 200003; // 开两倍空间的数组,质数确保冲突概率最小
const int 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()
{
memset(h, 0x3f, sizeof h);
cin >> n;
while (n -- ) {
char op;
int x;
cin >> op >> x;
int k = find(x);
if (op == 'I') h[k] = x;
else
if (h[k] == x) puts("Yes");
else puts("No");
}
return 0;
}
字符串哈希
时间复杂度:\(O(n)+O(m)\)
全称字符串前缀哈希法,把字符串变成一个 \(P\) 进制数字(哈希值),实现不同的字符串映射到不同的数字。
对形如 \(X_1X_2X_3 \dots X_{n−1}X_n\) 的字符串,采用字符的 ascii 码乘上 \(P\) 的次方来计算哈希值。由于这个值可能会比较大,不便于存储,所以我们用一个较少的数对其取模,将其映射到对应的范围。
映射公式:
注意点:
- 任意字符不可以映射成 \(0\) ,否则会出现不同的字符串都映射成 \(0\) 的情况,比如 \(A,AA,AAA\) 皆为 \(0\)
- 冲突问题:通过巧妙设置(经验值) \(P\) (取 \(131\) 或 \(13331\) ) , \(Q\) (取 \(264\) )
的值,一般可以理解为不产生冲突。
问题是比较不同区间的子串是否相同,就转化为对应的哈希值是否相同。
求一个字符串的哈希值就相当于求前缀和,求一个字符串的子串哈希值就相当于求部分和。
前缀和公式:
\(h\) 为前缀和数组,\(s\) 为字符串数组
区间和公式:
区间和公式的理解: \(ABCDE\) 与 \(ABC\) 的前三个字符值是一样,只差两位,乘上 \(P_2\) 把 \(ABC\) 变为 \(ABC00\),再用 \(ABCDE - ABC00\) 得到 \(DE\) 的哈希值。
#include <iostream>
using namespace std;
typedef unsigned long long ULL;
const int N = 1e5 + 10;
const int P = 131;
int n, m;
char str[N];
ULL h[N], p[N]; // h[N]存储字符串的P进制值,p[N]预处理P的多少次方
int get(int l, int r)
{
return h[r] - h[l - 1] * p[r - l + 1];
}
int main()
{
cin >> n >> m >> str + 1;
// 字符串从1开始编号,h[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;
cin >> l1 >> r1 >> l2 >> r2;
if (get(l1, r1) == get(l2, r2)) puts("Yes");
else puts("No");
}
return 0;
}