字符串哈希
字符串哈希一般可以認為是一種很方便的亂搞算法。
可以很快速的計算兩個串是否相等以及一系列問題。
然而弱爆的\(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\)做水題了嗚