字符串哈希
字符串哈希
字符串哈希一般可以认为是一种很方便的乱搞算法。
可以很快速的计算两个串是否相等以及一系列问题。
然而弱爆的\(yyb\)哈希一直学的不好,所以今天来恶补一下。
几种方法
首先我们要明确哈希在干什么呢?
一般而言,对于一个字符串,我们把所有字符都当成数字来算。
这个可以类比\(16\)进制下用\(ABCDE\)来代替大数字。
那么,我们的这个串就可以看成是一个巨大的\(base\)进制的数,
这个\(base\)我们可以任取,稍微取大一点点就好了。
但是,当串很长的时候,我们的\(int\)或者\(longlong\)或者\(unsignedlonglong\)都是存不下这个数本身的。
如果使用高精度,那么其相等的比较就变成逐位比较,就和直接比较字符串没有任何意义。
所以,我们可以找一个方法,使得这个精确的值可以映射到我们能够存下的整数范围内。
这个映射的方法我们一般选择取模。
于是我们找来了洛谷上的模板题。洛谷3370【模板】字符串哈希
我取\(base=2333,Mod=19260817\)
一般而言,\(Mod\)取一个质数比较好,这样子还有很多其它有趣的操作也可以很方便的进行。
代码如下
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
#define ull unsigned long long
#define ll long long
const int base=2333;
const int MOD=19260817;
int n,tot;
char ch[2000];
bool vis[MOD];
int CalcHash(char *s,int len)
{
int hash=0;
for(int i=0;i<len;++i)
hash=(1ll*hash*base+s[i])%MOD;
return hash;
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;++i)
{
scanf("%s",ch);
int x=CalcHash(ch,strlen(ch));
if(!vis[x])++tot,vis[x]=true;
}
printf("%d\n",tot);
return 0;
}
然而此时在洛谷上只有\(90\)分。评测地址
为什么会出错呢?——哈希冲突。
什么意思?两个完全不同的串,在当前的模意义下的映射值相等,导致答案错误。
我们可以来算算概率,这个问题是一个生日悖论问题,可以百度。
我们的模数是\(19260817\),我们的串的个数根据数据范围发现是\(10000\)个左右。
因为我们的\(base\)和\(Mod\)都是比较随意选择的,
因此我们可以认为所有的串的\(hash\)值都在\([0,Mod-1]\)中随机均匀分布。
那么,每个串的\(hash\)值出现的概率都是\(\frac{1}{Mod}\)。
我们现在要考虑的是任意\(hash\)值都不相等。
那么,第一个串肯定不会不相等,概率为\(1\)
第二个串不和第一个串相等,所以概率为\(\frac{Mod-1}{Mod}\)
第三个串不和前面两个串相等,所以概率为\(\frac{Mod-2}{Mod}\)
串的个数是\(10000\),所以不相等的概率是\(\prod_{i=1}^{10000}\frac{Mod-i+1}{Mod}\)
我们可以算一下这个的概率是多少?
double p=1,MOD=19260817.0;
for(int i=1;i<=10000;++i)p*=(MOD-i+1)/MOD;
printf("%.15lf\n",p);
\(0.074561305771431\)
也就是说,如果我随便找\(10000\)个串,他们完全不冲突的概率非常小。
我们如何使得不冲突的概率增加呢?
观察上面的式子,发现这个值与\(Mod\)大小有关,当我们把\(Mod\)值增大的话,似乎效果会很好。
比如我们\(Mod\)取\(2333333333333333333\),大概是\(2.3333\times 10^{18}\)
double p=1,Mod=2333333333333333333.0;
for(int i=1;i<=10000;++i)p*=1.0*(Mod-i+1)/Mod;
printf("%.15lf\n",p);
这样子算出来的概率是多少呢?\(0.999999999978326\)
这个会冲突的概率微乎其微。
所以,我们做洛谷那道模板题的时候把\(Mod\)变大点再试试,比如说模\(2^{64}\),
也就是\(unsigned\ long\ long\)自然溢出。说白点就是随便它炸掉。
当然,这个时候就不能像上面那样开桶了,我们可以使用\(Trie\)树之类的东西来判断值是否相同。
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
#define ull unsigned long long
#define ll long long
const int base=2333;
int n,tot,cnt;
char ch[2000];
ull CalcHash(char *s,int len)
{
ull hash=0;
for(int i=0;i<len;++i)
hash=hash*base+s[i];
return hash;
}
struct TrieNode{int son[10];bool fl;}t[10001000];
void insert(ull x)
{
int u=0;
while(x)
{
int c=x%10;x/=10;
if(!t[u].son[c])t[u].son[c]=++cnt;
u=t[u].son[c];
}
if(!t[u].fl)++tot,t[u].fl=true;
}
int main()
{
scanf("%d",&n);
while(n--)
{
scanf("%s",ch);
insert(CalcHash(ch,strlen(ch)));
}
printf("%d\n",tot);
return 0;
}
当然默认大家都会\(Trie\)树。出来\(Trie\)树,还可以等所有都计算完之后用\(sort+unique\)等等等方法。
这样子就可以通过这道题目了。提交记录
当然,如果模数取到了\(10^{18}\)级别,我们做乘法也会特别的不舒服,
所以如果取大质数,就让\(Mod*base\)处于一个我们比较好运算的一个大小。
比如\(Mod\)取\(10^{15}\)级别,\(base\)取\(10^3\)级别。
那么既然可以在模\(10^{18}\)意义下做,我们也可以找两个\(10^9\)级别的数做,
只有当两个值都完全相等的时候我们才判定这个串出现过,这样就和模\(10^{18}\)意义下类似了。
这个方法我们称之为双哈希。这个我们就\(sort+unique\)解决吧。
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define ull unsigned long long
#define ll long long
const int base=2333;
const int Mod1=998244353;
const int Mod2=1000000007;
int n,tot;
char ch[2000];
int CalcHash(char *s,int len,int Mod)
{
int hash=0;
for(int i=0;i<len;++i)
hash=(1ll*hash*base+s[i])%Mod;
return hash;
}
pair<int,int> ele[10010];
int main()
{
scanf("%d",&n);
for(int i=1,l;i<=n;++i)
{
scanf("%s",ch);l=strlen(ch);
ele[i]=make_pair(CalcHash(ch,l,Mod1),CalcHash(ch,l,Mod2));
}
sort(&ele[1],&ele[n+1]);tot=unique(&ele[1],&ele[n+1])-ele-1;
printf("%d\n",tot);
return 0;
}
不用在意我用了一堆\(STL\),自己手写结构题什么的应该会比\(pair\)快很多。
好了,这样我们就解决了最基本的哈希问题了。
有关于哈希冲突
前面既然提到了生日悖论,也就是当元素个数很大时,\(hash\)出现冲突概率以很快的速度增加。
以此引申,我们有被称之为生日攻击的东西。也就是利用增加随机的元素个数来使得哈希冲突的概率大大增加。
举个很简单的例子,BZOJ3098 Hash Killer II
发现模数是\(1000000007\)
我们只需要增加需要的串的个数就行了,长度随便取,取个\(20\)到\(30\)差不多了。
太短了可能导致\(hash\)值都没超过\(Mod\),这样就不可能错啊。
根据上面的生日攻击的理论,只需要随便\(rand\)就可以了。很容易就\(AC\)了。
#include<cstdio>
#include<algorithm>
int main()
{
puts("100000 33");
for(int i=1;i<=100000;++i)putchar(rand()%26+97);
puts("");return 0;
}
代码就只有这么短。
我们可以很容易的认识到\(Hash\)如果不够优秀的话,是很容易被卡掉的。
也就是说,当串的个数在\(\sqrt{Mod}\)以上,哈希冲突的概率基本在\(0.5\)以上。
哈希可以用来干啥?
一般而言,\(Hash\)用的最多的就是快速判断两个串是否相等。
这也让它成为了很多比较复杂的东西的简单替代。
比如说下面这个。
如果做题做得比较多,应该知道这道题目是\(KMP\)算法的模板题。
我们可以用\(Hash\)来解决,这就是一个很简单的匹配问题了。
#include<cstdio>
#include<cstring>
using namespace std;
#define ull unsigned long long
const int base=2333;
ull hash,s[1000100],pw;
int n,m;
char W[10100],T[1000100];
int main()
{
int Case,ans;scanf("%d",&Case);
while(Case--)
{
scanf("%s",W+1);scanf("%s",T+1);
n=strlen(W+1);m=strlen(T+1);ans=0;pw=1;hash=0;
for(int i=1;i<=n;++i)hash=hash*base+W[i];
for(int i=1;i<=m;++i)s[i]=s[i-1]*base+T[i];
for(int i=1;i<=n;++i)pw*=base;
for(int i=n;i<=m;++i)
if(hash==s[i]-s[i-n]*pw)++ans;
printf("%d\n",ans);
}
return 0;
}
我们发现我们可以很容易的用\(Hash\)来代替\(KMP\)。
并且复杂度和\(KMP\)算法是一样的。虽然空间上可能没有那么优秀。
但是我们似乎不能够很好的代替\(AC\)自动机。
但是我们可以\(O(n^2)\)把串中所有的子串的\(hash\)值搞出来,每一个模板串都去算一下出现也是可以的。
至于后缀数组?我们似乎能够代替其中一些功能。
比如说
如果知道题目的话,就会清楚这题是\(SA\)的模板题。
\(SA\)的做法是求出后缀排名之后,二分一个长度,
检查它是否出现\(k\)次就是检查是否有连续的\(k\)个\(height\)值大于长度。
换成哈希也是一样,把所有长度为二分出来的值的子串的\(hash\)值算出来。
排序之后直接检查有没有一个子串出现了超过要求的次数,虽然复杂度是\(O(nlog^2n)\)的,
但实际上跑得非常快。
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define MAX 20200
#define ull unsigned long long
const int base=2333333;
inline int read()
{
int x=0;bool t=true;char ch=getchar();
while((ch<'0'||ch>'9')&&ch!='-')ch=getchar();
if(ch=='-')t=false,ch=getchar();
while(ch>='0'&&ch<='9')x=x*10+ch-48,ch=getchar();
return t?x:-x;
}
int n,K,a[MAX],tot;
ull s[MAX],pw[MAX],b[MAX];
bool check(int len)
{
tot=0;
for(int i=len;i<=n;++i)b[++tot]=s[i]-s[i-len]*pw[len];
sort(&b[1],&b[tot+1]);
for(int i=1,pos;i<=tot;i=pos+1)
{
pos=i;while(b[pos+1]==b[i])++pos;
if(pos-i+1>=K)return true;
}
return false;
}
int main()
{
n=read();K=read();pw[0]=1;
for(int i=1;i<=n;++i)a[i]=read(),s[i]=s[i-1]*base+a[i],pw[i]=pw[i-1]*base;
int l=1,r=n,ret=0;
while(l<=r)
{
int mid=(l+r)>>1;
if(check(mid))ret=mid,l=mid+1;
else r=mid-1;
}
printf("%d\n",ret);
return 0;
}
再比如说
求最长公共子串。
用后缀数组做就是丢在一起后缀排序,直接\(check\)就好。复杂度只有后缀排序的\(O(nlogn)\)。
用\(Hash\)求,我们先二分一下长度,把一个串的这个长度的所有子串拿出来离散,
把另外一个串的这样的所有子串拿出来检查一下在上面那个数组里面有没有就好了。
时间复杂度\(O(nlog^2n)\),被\(SA,SAM\)吊打了。
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<cmath>
#include<algorithm>
using namespace std;
#define MAX 100100
#define ull unsigned long long
const int base=233;
int n,m,tot;
char S[MAX],T[MAX];
ull s1[MAX],s2[MAX],pw[MAX],a[MAX];
bool check(int len)
{
tot=0;
for(int i=len;i<=n;++i)a[++tot]=s1[i]-s1[i-len]*pw[len];
sort(&a[1],&a[tot+1]);tot=unique(&a[1],&a[tot+1])-a-1;
for(int i=len;i<=m;++i)
{
ull x=s2[i]-s2[i-len]*pw[len];
int pos=lower_bound(&a[1],&a[tot+1],x)-a;
if(a[pos]==x)return true;
}
return false;
}
int main()
{
scanf("%s",S+1);scanf("%s",T+1);
n=strlen(S+1);m=strlen(T+1);pw[0]=1;
if(n<m)swap(S,T),swap(n,m);
for(int i=1;i<=m;++i)pw[i]=pw[i-1]*base;
for(int i=1;i<=n;++i)s1[i]=s1[i-1]*base+S[i];
for(int i=1;i<=m;++i)s2[i]=s2[i-1]*base+T[i];
int l=1,r=m,ret=0;
while(l<=r)
{
int mid=(l+r)>>1;
if(check(mid))ret=mid,l=mid+1;
else r=mid-1;
}
printf("%d\n",ret);
return 0;
}
应该还有很多题目都可以这样子做。二分+\(hash\)是一个好东西。
至于代替\(SAM\)???\(emmm....\)
你能够代替的操作似乎都是\(SA\)可以做的操作,并且在效率上似乎不会更优。
而\(SAM\)的操作似乎有点没法搞。。。
\(Manacher\)呢?这个可以做,但是复杂度不优秀。
具体的做法是,枚举回文中心,正反做两次\(Hash\),每次二分之后\(check\)左右是否相等就好了。
于是这里找来一道和回文串相关的题目。
对于在\(A,B\)两串中的回文串,直接\(Manacher\)就好了,或者二分+\(Hash\)也行。
考虑如果跨串拼接回文串,我们依次枚举每个回文中心
在自身串内的最长回文一定是最优的(为什么画画图就知道了),
所以在\(A\)串中二分左半边,\(B\)串中二分右半边,检查时候相等,
最后将当前回文中心的最大回文长度和二分出来的最大长度加起来就好了
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<cmath>
#include<algorithm>
using namespace std;
#define ull unsigned long long
#define MAX 222222
const int base=2333;
int n,ans;
char A[MAX],B[MAX],C[MAX];
ull Hash[2][MAX],pw[MAX<<1];
int f[2][MAX];
void Manacher(char *s,int *p)
{
int mx=0,id=0;
s[0]='>';
for(int i=1;i<=n;++i)
{
p[i]=mx>i?min(p[2*id-i],mx-i):0;
while(s[i-p[i]-1]==s[i+p[i]+1])++p[i];
if(i+p[i]>mx)mx=i+p[i],id=i;
}
}
ull Calc(int c,int l,int r)
{
if(!c)return Hash[0][r]-Hash[0][l-1]*pw[r-l+1];
return Hash[1][l]-Hash[1][r+1]*pw[r-l+1];
}
int Binary(int L,int R)
{
int l=1,r=min(L,n-R+1),ret=0;
while(l<=r)
{
int mid=(l+r)>>1;
if(Calc(0,L-mid+1,L)==Calc(1,R,R+mid-1))ret=mid,l=mid+1;
else r=mid-1;
}
return ret;
}
int main()
{
scanf("%d",&n);scanf("%s",A+1);scanf("%s",B+1);pw[0]=1;
for(int i=1;i<=n;++i)pw[i]=pw[i-1]*base;
for(int i=1;i<=n;++i)Hash[0][i]=Hash[0][i-1]*base+A[i];
for(int i=n;i>=1;--i)Hash[1][i]=Hash[1][i+1]*base+B[i];
for(int i=1;i<=n;++i)C[i]=A[i];
for(int i=1,j=0;i<=n;++i)A[++j]='*',A[++j]=C[i];A[n+n+1]='*';
for(int i=1;i<=n;++i)C[i]=B[i];
for(int i=1,j=0;i<=n;++i)B[++j]='*',B[++j]=C[i];B[n+n+1]='*';
n=n+n+1;Manacher(A,f[0]);Manacher(B,f[1]);
for(int i=1;i<=n;++i)
{
int L=(i-f[0][i]+1)/2,R=(i+f[0][i])/2;
ans=max(ans,f[0][i]+Binary(L-1,R)*2);
}
for(int i=1;i<=n;++i)
{
int L=(i-f[1][i]+1)/2,R=(i+f[1][i])/2;
ans=max(ans,f[1][i]+Binary(L,R+1)*2);
}
printf("%d\n",ans);
return 0;
}
用哈希来解决通配符匹配的问题,因为哈希可以做到\(O(1)\)快速匹配两个串是否相等,
所以可以很方便的来解决这一类的\(dp\)问题。题解戳这里
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<cmath>
#include<algorithm>
using namespace std;
#define MAX 222222
#define ull unsigned long long
const int base=2333;
ull h1[MAX],h2[MAX],pw[MAX];
char ch[MAX],s[MAX];
int P[50],tot,n,m;
bool f[20][MAX];
int main()
{
scanf("%s",s+1);n=strlen(s+1)+1;s[n]='?';
pw[0]=1;for(int i=1;i<MAX;++i)pw[i]=pw[i-1]*base;
for(int i=1;i<=n;++i)h1[i]=h1[i-1]*base+s[i];
for(int i=1;i<=n;++i)if(s[i]=='*'||s[i]=='?')P[++tot]=i;
int T;scanf("%d",&T);
while(T--)
{
memset(f,0,sizeof(f));f[0][0]=1;
scanf("%s",ch+1);m=strlen(ch+1);ch[++m]='#';
for(int i=1;i<=m;++i)h2[i]=h2[i-1]*base+ch[i];
for(int j=0;j<=tot;++j)
{
if(s[P[j]]=='*')
for(int i=1;i<=m;++i)f[j][i]|=f[j][i-1];
for(int i=0;i<=m;++i)
{
if(!f[j][i])continue;
int l1=i+1,r1=i+(P[j+1]-P[j])-1;
int l2=P[j]+1,r2=P[j+1]-1;
if(h2[r1]-h2[l1-1]*pw[r1-l1+1]==h1[r2]-h1[l2-1]*pw[r2-l2+1])
{
if(s[P[j+1]]=='?')f[j+1][r1+1]|=f[j][i];
else f[j+1][r1]|=f[j][i];
}
}
}
puts(f[tot][m]?"YES":"NO");
}
return 0;
}
。?
似乎就没了?
至少现在会用\(hash\)做水题了呜