算法学习笔记(20)——哈希表(Hash)

哈希表(Hash)

Hash 表又称为散列表,一般由 Hash 函数(散列函数)与链表结构共同实现。与离散化思想类似,当我们要对若干复杂信息进行统计时,可以用 Hash 函致把这些复杂信息映射到一个容易维护的值城内。因为值域变简单、范围变小,有可能造成两个不同的原始信息被 Hash 函数映射为相同的值,所以我们需要处理这种冲突情况,一般有两种方法。

拉链法(开散列)

建立一个邻接表结构,以 Hash 函数的值域作为表头数组 head,映射后的值相同的原始信息被分到同一类,构成一个链表接在对应的表头之后,链表的节点上可以保存原始信息和一些统计数据。
Hash 表主要包括两个基本操作:

  1. 计算 Hash 函数的值;
  2. 定位到对应链表中依次遍历、比较。

无论是检查任意一个给定的原始信息在 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;
        }
    }

题目链接:AcWing 840. 模拟散列表

#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;
}

开放寻址法

俗称"厕所找坑位法",开一个题目所给范围两倍大小的数组,每次查找时计算对应的哈希值,如果对应的位置"有人",则继续看相邻的下一个位置,直到找到空位为止。

题目链接:AcWing 840. 模拟散列表

#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\) 的次方来计算哈希值。由于这个值可能会比较大,不便于存储,所以我们用一个较少的数对其取模,将其映射到对应的范围。

映射公式:

\[(X_1 × P_{n−1} + X_2 × P_{n−2} + \dots +X_{n−1}×P_1+X_n×P_0) \bmod Q \]

注意点:

  1. 任意字符不可以映射成 \(0\) ,否则会出现不同的字符串都映射成 \(0\) 的情况,比如 \(A,AA,AAA\) 皆为 \(0\)
  2. 冲突问题:通过巧妙设置(经验值) \(P\) (取 \(131\)\(13331\) ) , \(Q\) (取 \(264\) )
    的值,一般可以理解为不产生冲突。

问题是比较不同区间的子串是否相同,就转化为对应的哈希值是否相同。
求一个字符串的哈希值就相当于求前缀和,求一个字符串的子串哈希值就相当于求部分和。

前缀和公式:

\[h[i+1]=h[i]×P+s[i], i \in [0,n−1] \]

\(h\) 为前缀和数组,\(s\) 为字符串数组

区间和公式:

\[h[l,r]=h[r]−h[l−1]×P_{r−l+1} \]

区间和公式的理解: \(ABCDE\)\(ABC\) 的前三个字符值是一样,只差两位,乘上 \(P_2\)\(ABC\) 变为 \(ABC00\),再用 \(ABCDE - ABC00\) 得到 \(DE\) 的哈希值。

题目链接:AcWing 841. 字符串哈希

#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;
}
posted @ 2022-12-09 22:07  S!no  阅读(123)  评论(0编辑  收藏  举报