AcWing:哈希表

哈希表理论基础

哈希表有两大要点:

  1. 存储结构

  1. 开放寻址法

  1. 拉链法

  1. 字符串哈希

哈希表的最主要作用是将一个比较大的范围(如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

注意:

  1. 不能将字符映射成0:例如将"A"映射成0,则"AA","AAA"的值均为零。所以应当从1开始。

  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,这样我们就无需进行取模的操作了,因为它溢出后就相当于帮我们取模了。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值