哈希、哈希表详解及应用

本小妞迷上赌 提交于 2020-04-06 07:02:42

前置概念

Key : 我们提供的一个要进行哈希的数字

\(f(x)\):即为哈希函数,将key扔到这个函数里面,可以得到Value,最核心的构造哈希表的东西

Hash地址:hash出来的值在哈希表中的存储位置

进入正题

字符串hash

例题1:【模板】KMP

现有T组数据,每次给定两个字符串\(s1\text{和}s2\),求\(s1\text{在}s2\)中出现了几次。

首先考虑的当然是KMP了(逃

但是由于我们讲的是字符串hash,那就考虑怎么用字符串hash求解;

考虑每次枚举每一个子串的hash值,但是复杂度.....\(O(nm)\)

所以介绍一个优化技巧:滚动hash

滚动hash

滚动hash的诞生就是为了避免在\(O(m)\)的时间复杂度内计算一个长度为m的字符串的hash值:

我们选取两个合适的互质常数(虽然不知道为什么互质)b和h,对于字符串c,我们搞一个hash函数:

\(hash(c)=(c_1b^{m-1}+c_2b^{m-2}+.....+c_mb^0)mod h\)

这个hash函数的构造过程是以递推实现的,设

\(hash(c,k)\)为前k个字符构成的子串的hash值,有

\(hash(c,k)=hash(c,k-1)\times b+c_{k}\)

为方便理解,设\(c="ABCD"\)\(A=1,B=2....\)

\(hash(c,2)=1\times b+2\)

\(hash(c,3)=1 \times b^2+2 \times b +3\)

\(hash(c,4)=1\times b^3+2 \times b^2+3\times b+4\)

对于c的子串\(c'=c_{k+1}c_{k+2}....c_{k+n}\),有:

\(hash(c')=hash(c,k+n)-hash(c,k)\times b^n\)

很像前缀和是不是?

也很像b进制转十进制是不是?

某位老师说过,探究新知的最好方法就是特值代入法,所以如果大家拿上面的那个例子来稍微做一下运算,就能很好地理解滚动hash这个优化方法了。

举个例子:

如果我们想求上面那个例子的子串\("CD"\)的hash值,那么根据这个公式,就是:

\(hash("CD")=hash(4)-hash(2)\times b^2\)

\(hash(2)\times b^2 = 1\times b^3+2\times b^2\)

所以,原式\(=3\times b+4\)

这很像我们有一个b进制数1234要转成十进制,而上面所做的就是把1234中的12给杀掉,只留下34,再转成十进制就OK了

所以,如果我们预处理出\(b^n\),就可以做到在\(O(1)\)的时间复杂度内get到任意子串的hash值,所以上面那道例题的时间复杂度就成功地降到了\(O(n+m)\)

但是有些细心的同学会发现,如果某两个子串的hash值撞车了怎么办呢?那么可以考虑double_hash,也就是将一个hash值取模两次,书本上说:可以将h分别取\(10^9+7\)\(10^9+9\),因为他们是一对“孪生质数”,虽然我也不知道这是什么意思

(提醒:要开成unsigned long long,据说是为了自然溢出,省去取模运算)

哈希表

大概就是这样子一个东西。

那这个东西有什么用呢?

假设我们要将中国每个人的身份证号映射到每个人的

如果有一个人的身份证号xxxxxx19621011XXXX

这是一个18位数!!!!(难道你要弄一个数组存??)

经过计算,\(1390000000/10^4=13900\),即至少有13900人的身份证后四位是一样的

所以我们可以将所有身份证后四位相同的人装到一个桶里面,这个桶的编号就是这个人身份证的后四位,这就是哈希表,主要目的就是为了解决哈希冲突,即F(key)的数值发生重复的情况。

如上面的那个身份证号,我们可以考虑:

故,哈希表就是将\(F(key)\)作为key的哈希地址的一种数据结构。

哈希的某些方法

直接定址法 :地址集合 和 关键字集合大小相同

数字分析法 :根据需要hash的 关键字的特点选择合适hash算法,尽量寻找每个关键字的 不同点

平方取中法:取关键字平方之后的中间极为作为哈希地址,一个数平方之后中间几位数字与数的每一位都相关,取得位数由表长决定。比如:表长为512,=2^9,可以取平方之后中间9位二进制数作为哈希地址。

折叠法:关键字位数很多,而且关键字中每一位上的数字分布大致均匀的时候,可以采用折叠法得到哈希地址,

除留取余法:除P取余,可以选P为质数,或者不含有小于20的质因子的合数

随机数法:通常关键字不等的时候采用此法构造哈希函数较恰当。

但是这些东西貌似都是形式上的,具体怎么操作还是得靠实现

哈希表的实现

听课的同学里面有多少人写过图/最短路等算法呢?

图的存储有两种方法:

  1. 邻接矩阵

  2. 邻接表

在这里我们用邻接表来实现。

void add(int a,int b,int c){
	dt[cnt].from=a;
    dt[cnt].to=b;
    dt[cnt].value=c;
    dt[cnt].next=head[a];
    head[a]=cnt++;
}

这是邻接表。

void add(int a,int b){
    dt[cnt].end=b;
	dt[cnt].next=head[a];
    head[a]=cnt++;
    
}

这是哈希表。

很像有木有???

在这里\(a,b\)是我们用double_hash取出来的,取两个不同的模数,两个\(F(key)\)决定一个字符串。

唯一不同的是head数组的下标是\(key1\)

其实要不要这么做随你。


如果我们要遍历一个哈希表?

同样,

for(int i=head[x];i;i=dt[i].next){
    .......
}

跟遍历邻接表一模一样。


hash表中hash函数的确定

如果是一个数的话,上面讲过。(好像用离散化就行了)

如果是一个字符串的话,用前面的滚动hash就可以了。

分两种情况:

如果你不想用double_hash:

那你也不需要把\(key1\)作为head的下标了。

那就直接unsigned ll乱搞吧,自然溢出

如果你要用double_hash:

那你需要把\(key1\)作为head的下标。

这时候你不能ull了,,那就弄那个什么孪生质数取模吧。

b记得开小一点,最好算一算。

例题2:图书管理

图书馆要搞一个系统出来,支持两种操作:

add(s):表示新加入一本书名为s的书。

find(s):表示查询是否存在一本书名为s的书。

对于每个find操作,输出一行yes或no。书名与指令之间有空格隔开,书名可能有一大堆空格,对于相同字母但大小写不同的书名,我们认为它是不同的。

【样例输入】

4

add Inside C#

find Effective Java

add Effective Java

fine Effective Java

【样例输出】

no

yes


【题目分析】

这题是哈希表的一个变式,判断一个字符串是否已经出现

可以用滚动hash搞哈希表,采用double_hash

伪代码(不知道算不算):

void add(int a,int b){
	.....
}
int find(int a,int b){
	for(int i=head[a];i;i=next[i]){
    	if(value[i]==b)true;
    }
    false;
}
int main(){
	while(n--){
    	cin>>order;
        gets(s);
        for(i=0;i<len;i++){
        	key1=(key1*b1+s[i])%mod1;
            key2=(key2*b2+s[i])%mod2;
		}
        if(add)add(key1,key2);
        else{
            if(find(key1,key2))yes;
        		else no;
		}
    }
}

这题还算简单。

例题3 [LuoguP3498&POI2010]Beads

Jbc买了一串车挂饰装扮自己,上有n个数字。它想要把挂饰扔进发动机里切成\(k\)串。如果有n mod k !=0,则最后一段小于k的可以直接舍去。而且如果有子串\((1,2,3)\)\((3,2,1)\),Jbc就会认为这两个子串是一样的。Jbc想要多样的挂饰,所以Jbc想要找到一个合适的\(k\),使得它能得到不同的子串最多。

例如:这一串挂饰是:\((1,1,1,2,2,2,3,3,3,1,2,3,3,1,2,2,1,3,3,2,1)\)
\(k=1\)的时候,我们得到3个不同的子串: $(1),(2),(3) $

\(k=2\)的时候,我们得到6个不同的子串: $(1,1),(1,2),(2,2),(3,3),(3,1),(2,3) $

\(k=3\)的时候,我们得到5个不同的子串: \((1,1,1),(2,2,2),(3,3,3),(1,2,3),(3,1,2)\)

\(k=4\)的时候,我们得到5个不同的子串: \((1,1,1,2),(2,2,3,3),(3,1,2,3),(3,1,2,2),(1,3,3,2)\)

【输入格式】

第一行一个整数n,第二行接n个数字。

【输出格式】

第一行2个正整数,表示能获得的最大不同子串个数以及能获得最大值的k的个数。第二行输出所有的k。

【数据范围】

\(n\le 200000\)

\(1\le a_i\le n\)

【样例输入】

21

1 1 1 2 2 2 3 3 3 1 2 3 3 1 2 2 1 3 3 2 1

【样例输出】

6 1

2


【题目分析】

考虑最暴力的方法:

枚举k,枚举每一个子串,从前往后、从后往前各扫一遍。

所以我们就碰到了和字符串hash一样的问题:

枚举每一个数复杂度有点高啊啊啊啊啊

为了避免在\(O(k)\)的复杂度内枚举每一个子串,我们采用滚动hash(好像跟前面引述滚动hash的时候有点像)

预处理出正着跑的hash值以及反着跑的hash值。

枚举每一个子串,将正的hash值和反的hash值乘起来。

然后再扔到set里,因为我们知道set的特性:如果set里面有两个相同的数就会自动删除。

最后再弄一个小根堆,如果当前k能够获得当前最大值,就扔进小根堆里,否则将这个小根堆清空,再扔k。

然后呢?

没有然后了。

#include<bits/stdc++.h>
#define ull unsigned long long
using namespace std;
ull n,a[1010101],power[1010101];
ull hash[1010101],hashback[1010101],ans=0;
set<ull>ba;
priority_queue<ull,vector<ull>,greater<ull> >gas;
const ull b=1926;
ull dash(ull i){
    ba.clear();
    for(ull j=1;j+i-1<=n;j+=i){
        ull cas1=hash[j+i-1]-hash[j-1]*power[i];
        ull cas2=hashback[j]-hashback[j+i]*power[i];
        ba.insert(cas1*cas2);
    }
    return (ull)ba.size();
}
int main(){
    cin>>n;
    for(ull i=1;i<=n;i++){
        cin>>a[i];
    }
    power[0]=1;
    for(ull i=1;i<1000000;i++)
        power[i]=power[i-1]*b;
    for(ull i=1;i<=n;i++)
        hash[i]=hash[i-1]*b+a[i];
    for(ull i=n;i>=1;i--)
    	hashback[i]=hashback[i+1]*b+a[i];
    /*
    for(ull i=1;i<=n;i++)
        cout<<hash[i]<<" ";
    cout<<endl;
    for(ull i=n;i;i--)
        cout<<hashback[i]<<" ";
    cout<<endl;
    cout<<hash[3]-hash[1]*power[2]<<" "<<b*b+b+1<<endl;
    cout<<hashback[n-2]-hashback[n+1]*power[3]<<endl;*/
    
    for(ull i=1;i<=n;i++){
        ull cnt=dash(i);
        if(cnt>ans){
            ans=cnt;
            while(!gas.empty())gas.pop();
        }
        if(cnt==ans)gas.push(i);
    }
    cout<<ans<<" "<<gas.size()<<endl;
    for(;!gas.empty();){
        cout<<gas.top()<<" ";
        gas.pop();
    }
    
}

讲完了

祝大家身体健康

参考:信息学奥赛一本通 提高篇

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!