雜湊和雜湊表
雜湊演算法是透過一個雜湊函式 \(\operatorname H\) ,將一種資料(包括字串、較大的數等)轉化為能夠用變數表示或是直接就可作為陣列下標的數,透過雜湊函式轉化得到的數值我們稱之為雜湊值。透過雜湊值可以實現快速查詢和匹配。雜湊演算法具體應用有:字串 \(\operatorname {Hash}\) 、雜湊表。
一、字串 \(\operatorname{Hash}\)
1.字串匹配問題
尋找長度為 \(n\) 的主串 \(S\) 中的匹配串 \(T\) (長度為 \(m\) )出現的位置或次數的問題屬於字串匹配問題。樸素的想法是列舉所有起始的位置,再直接檢查是否匹配。字串 \(\operatorname {Hash}\) 的原理是直接比較長度為 \(m\) 的主串 \(S\) 的字串的雜湊值與 \(T\) 的雜湊值是否相等。
2.具體流程
如果我們用 \(O(m)\) 的時間直接計算長度為 \(m\) 的字串的雜湊值,總的時間複雜度並沒有改觀。所以需要用到滾動雜湊的最佳化技巧。
所以我們選取兩個互質常數 \(b\) 和 \(h\) ( \(b<h\) ),假設字串 \(C=c_1c_2...c_m\) ,那麼我們定義雜湊函式 \(\operatorname H(C)=(c_1b^{m-1}+c_2b^{m-2}+...+c_mb^{0}) \bmod h\) 。仔細觀察這個式子發現很像 \(b\) 進位制下的某數字的展開。只不過這裡將字元 \(c_i\) 看作為一個數字。
這一個過程是遞推計算的,設 \(\operatorname H(C,k)\) 為前 \(k\) 個字元構成的字串的雜湊值,則: \(\operatorname H(C,k+1)=\operatorname H(C,k)\times b+c_{k+1}\)
舉個例子:現有字串 \(C=acda\) (為方便處理我們可以令 \(a\) 表示 \(1\) , \(b\) 表示 \(2\) ,以此類推),則:
\(\operatorname H(C,1)=1\)
\(\operatorname H(C,2)=H(C,1)\times b+c_2=1\times b+3\)
$\operatorname H(C,3)=H(C,2)\times b+c_3=1\times b^2+3\times b+4 $
$\operatorname H(C,4)=H(C,3)\times b+c_4=1\times b^3+3\times b^2+4\times b +1 $
通常題目要求的是判斷主串的一段字元與另一個匹配串是否匹配,即判斷字串 \(C=c_1c_2...c_m\) 從位置 \(k+1\) 開始的長度為 \(n\) 的字串 \(C'=c_{k+1}c_{k+2}...c_{k+n}\) 與另一匹配串 \(S=s_1s_2...s_m\) 的雜湊值是否相等,則等價於判斷 \(\operatorname H(C')=\operatorname H(C,k+n)-\operatorname H(C,k)\times b^n\) 是否與 \(\operatorname H(S)\) 相等。
所以只需要預處理 \(b^n\) 既可以在 \(O(1)\) 的複雜度內得到任意字串的子串雜湊值,從而完成字串匹配,那麼上述字串匹配問題的演算法複雜度就為 \(O(n+m)\)
藉助上面的例子,假設 \(S=cd\) ,那麼當 \(k=1,n=2\) 時,
\(\operatorname H(C')=H(C,1+2)-H(C,1)\times b^2=3\times b+4\)
\(\operatorname H(S)=3\times b+4=H(C')\) ,所以就可以認為 \(C'\) 與 \(S\) 是匹配的。
可以將 \(b\) 一個較大的定質數,這樣在使用 $\operatorname{unsigned} \operatorname{long} \operatorname{long} $ 型別計算時可以自然溢位,一定程度上減少了雜湊衝突的產生。(自己宣告一個 \(MOD\) 用來取餘也可以)
二、雜湊表
1.定義
雜湊表是一種高效的資料結構。它的優點是查詢的演算法時間複雜度是常數,缺點是會消耗較多的記憶體。但是以空間換時間,還是一種常用的策略。所以雜湊表還是有著極其不錯的應用價值。
2.實現
先從一個具體的例子來感受雜湊表的實現方法。假設有一個含 \(n\) 個數的數列,我們只需要開一個陣列,依次將陣列裡的數儲存到 \(a_1,a_2,...,a_n\) 中即可。但是這樣做的缺點是當我們需要查詢某一元素時,就需要遍歷全部陣列或者二分查詢。所以查詢複雜度為 \(O(1)\) 的演算法就是對於每一個 \(key\) 值,直接令 \(A_{key}=key\) ,下標就是對應的數值。但是這樣還有一個問題就是如果資料特別大,資料規模很廣,對空間的開銷又過於大了。於是我們想到字串 \(\operatorname{Hash}\) 中有一個操作就是自然溢位,換句話說就是取模。所以我們可以設計一個函式 \(\operatorname H(key)=key \mod 13\) 再令 \(A_{\operatorname H(key)}=key\)
可是這樣依舊不夠完美,因為取餘不能保證結果唯一,就比如 \(1\) 和 \(40\) 取餘 \(13\) 的結果都為 \(1\) 。但這個問題也好解決,只需要像連結串列一樣,將取餘後結果相同的資料連結在同一下標處即可。
3.雜湊函式的構造
大致瞭解了雜湊表的儲存方式以後,最後的問題就在於如何構造雜湊函式。雜湊函式沒有硬性的規定,只要儘可能減少雜湊衝突,就是好的函式,下面給出幾種例子。
(1)餘數法
就是上文提及到的。選擇一個適當正整數 \(b\) 構造 \(\operatorname{H}(x)=x\bmod b\) 。一般地說,如果 \(b\) 的約數越多,那麼衝突的機率就越大。
(2)乘積取整法
選擇一個實數 \(A\) ,(\(A\in(0,1)\))作為乘數(最好是無理數,例如 \(\dfrac{\sqrt{5}-1}{2}\) 的實際效果就很好),將目標值 \(k\) 乘 \(A\) ,得到一個 \((0,k)\) 間的實數,然後取其小數部分,乘以雜湊表的大小 \(M\) 再向下取整,即得 \(k\) 在雜湊表的位置。可以表達為 $\operatorname{H}(x)= \left\lfloor\ M(x\times A \bmod 1) \right\rfloor $
(3)基數轉化法
就是進位制轉化。將 \(k\) 看作 \(n\) 進位制下的一個數然後將 \(k\) 重新轉化為十進位制數,再用取餘。一般選用大於 \(10\) 的數作為轉換的基數( \(n>10\) ),且最好滿足 \(\gcd(n,10)=1\) 。
三、題單
1.【模板】字串雜湊
思路:
模板題。讀入所有字串後,對所有字串進行字串雜湊,然後將雜湊值儲存。最後看有多少個不同的雜湊值即可。因為有自然溢位,可以減少雜湊衝突。
程式碼:
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
#define N 10050
#define M 1550
int n,l[N],b=131,cnt;
char ch[N][M];
ull Hash[N];
bool cmp (ull a,ull b) { return a>b; }
int main()
{
cin>>n;
for(int i=1;i<=n;i++) { scanf("%s",ch[i]);l[i]=strlen(ch[i]); }
for(int i=1;i<=n;i++)
for(int j=1;j<=l[i];j++)
Hash[i]=Hash[i]*b+(ch[i][j]-'A'+1);
sort(Hash+1,Hash+1+n,cmp);
for(int i=1;i<=n;i++) if(Hash[i]!=Hash[i+1]) cnt++;
cout<<cnt<<endl;
return 0;
}
2.子串查詢
思路:
應用字首和的思想 \(O(n)\) 求出以 \(i\) 為終點的字串的雜湊值,再求出目標字串的雜湊值。列舉每一個起點, \(O(1)\) 判斷以 \(i\) 為起點長度為目標字串長度的子串是否與目標字串匹配。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 1000050
typedef unsigned long long ull;
char ch[N],s[N];
int n,m,cnt;
int b=131;
ull power[N],Hash[N],sum;
int main()
{
scanf("%s",ch+1);
scanf("%s",s+1);
n=strlen(ch+1);m=strlen(s+1);
power[0]=1;Hash[0]=0;sum=0;
for(int i=1;i<=n;i++) power[i]=power[i-1]*b;
for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
for(int i=1;i<=m;i++) sum=sum*b+(ull)(s[i]-'A'+1);
for(int i=0;i<=n-m;i++)
if(sum==Hash[i+m]-Hash[i]*power[m]) cnt++;
cout<<cnt<<endl;
return 0;
}
3.Seek the name,seek the fame
思路:
先預處理出以 \(i\) 為終點的字串的雜湊值。然後列舉每一個位置 \(i\) ,判斷開頭前 \(i\) 個字母與最後 \(i\) 個字母構成的字串的雜湊值是否相等即可。時間複雜度 \(O(n)\) 。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 400050
typedef unsigned long long ull;
ull power[N],Hash[N];
int b=131,n,cnt,ans[N];
string ch,s;
int main()
{
while(cin>>s)
{
cnt=0;power[1]=b;
ch=' '+s;
memset(ans,0,sizeof(ans));
memset(Hush,0,sizeof(Hash));
n=s.length();
for(int i=2;i<=n;i++) power[i]=power[i-1]*b;
for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
for(int i=1;i<=n;i++)
if(Hash[i]==(Hash[n]-Hash[n-i]*power[i])) cout<<i<<" ";
puts("");
}
return 0;
}
4.power string
思路:
一開始沒有想明白複雜度為什麼是對的。
暴力列舉分段點,寫一個 \(\operatorname{check}\) 函式檢驗分段點是否合理,因為題目中說分段數儘可能大,所以找到第一個分段點可以直接結束。
檢驗時算出第一段的雜湊值,後面幾段依次比對看看是否均相同,不相同就直接返回繼續列舉。
可能原因:估計複雜度是 \(O(n\sqrt{n})\) ,但是很可能跑不滿。所以就衝過去了?
程式碼:
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
#define N 1000050
char ch[N];
int n,b=131,ans;
ull power[N],Hash[N],sum;
inline void init() { power[0]=1;for(int i=1;i<=n;i++) power[i]=power[i-1]*b; }
int check(int x)
{
Hash[0]=0;
for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'A'+1);
sum=Hash[x];
for(int i=x;i<=n-x;i+=x)
if(Hash[i+x]-Hash[i]*power[x]!=sum) return 0;
return 1;
}
int main()
{
while(1)
{
scanf("%s",ch+1);
n=strlen(ch+1);
init();
if(n==1&&ch[1]=='.') break;
int book=0;
for(int i=1;i<=n;i++)
if(!(n%i))
if(check(i)) { ans=n/i;book=1;break; }
if(book==0) cout<<1<<endl;
else cout<<ans<<endl;
}
return 0;
}
5.Three Friends
思路:
先從簡單的地方思考。
首先 \(n\) 一定是奇數,並且如果最後有解,那麼將字串分成兩半,一定至少有一半就是正確答案(字串 \(S\) )。
所以我們大致有了一個思路就是將字串拆成兩半,對左邊一半列舉每一個位置的字元被刪除後的情況。做完後再對右邊進行相同的操作,每找到一個可以被刪除的字元 \(cnt\) 就統計一下答案,最後如果 \(cnt=1\) 就說明答案唯一, \(cnt=0\) 說明無解,否則有多組解。
這樣我們的時間複雜度是 \(O(n)\) ,面對 \(n\leq2\times 10^6\) 這種資料範圍,我們需要儘可能快的方法來檢驗該位置是否可以刪除字元。
於是我們想到字串 \(\operatorname{Hash}\) 是用字首和處理的,如果我們能夠直接計算出修改字元後雜湊值的變化,那每一次判斷時間複雜度就變為 \(O(1)\) 。
我們已知 \([l,r]\) 內的雜湊值可以表示為 \(\operatorname{Cal}(l,r)= Hash[r]-Hash[l-1]\times b^{r-l+1}\) ,如果現在 \([l,r]\) 內 \(x\) 位置的字元被刪除那麼雜湊值該怎麼表示呢?
我們知道雜湊值的表示可以看作是 \(b\) 進位制下的數,那麼我們可以將刪除後 \([l,r]\) 內的雜湊值表示為 \(\operatorname{Modify}(l,r)=\operatorname {Cal}(l,x-1)\times b^{r-x}+\operatorname{Cal}(x+1,r)\) 。
到此所有問題都解決,程式碼實現即可。注意部分細節。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 2000050
typedef unsigned long long ull;
ull power[N],Hash[N],sum1,sum2;
char ch[N];
string a,b,c,d;
int n,p=131,mid,cnt;
inline ull Cal(int l,int r) { return (Hash[r]-Hash[l-1]*power[r-l+1]); }
inline ull modify(int l,int r,int k) { return (Cal(l,k-1)*power[r-k]+Cal(k+1,r)); }
int main()
{
cin>>n;
scanf("%s",ch+1);
if(!(n&1)) { puts("NOT POSSIBLE");return 0; }
power[0]=1;
for(int i=1;i<=n;i++) power[i]=power[i-1]*p;
for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*p+(ull)(ch[i]-'A'+1);
mid=(1+n)>>1;
sum1=Cal(mid+1,n);
for(int i=mid+1;i<=n;i++) a.push_back(ch[i]);
for(int i=1;i<=mid;i++)
{
sum2=modify(1,mid,i);
if(sum1==sum2)
{
cnt++;
c=a;
break;
}
}
sum2=Cal(1,mid-1);
for(int i=1;i<mid;i++) b.push_back(ch[i]);
for(int i=mid;i<=n;i++)
{
sum1=modify(mid,n,i);
if(sum1==sum2)
{
cnt++;
d=b;
break;
}
}
if(!cnt) puts("NOT POSSIBLE");
else if(cnt==1||c==d)
{
if(c.size()) cout<<c<<endl;
else cout<<d<<endl;
}
else puts("NOT UNIQUE");
return 0;
}
6.可怕的詩
思路:
有點像前面做過的一道題,但是這個詢問次數有點嚇人,每次做一遍肯定超時。(是一道卡常題)
首先關於迴圈節我們需要知道:迴圈節是區間字串長度的約數;如果 \(n\) 是一個迴圈節,那麼 \(k\times n\) 也一定是一個迴圈節。
假設 \(\operatorname {Cal}(l,r)\) 表示字串區間 \([l,r]\) 內的雜湊值。考慮找最小的迴圈節就是找最小的 \(len\) 使得 \(\operatorname{Cal}(l,r-len)=\operatorname{Cal}(l+len,r)\) 。
所以我們的思路是令 \(len\) 一開始為區間長度,用 \(fac_{len}\) 儲存 \(len\) 的最小質因子。
如果當前 \(len\) 是迴圈節的長度則接著判斷 \(len/fac_{len}\) 是否可以,重複執行直到不可以為止。則最後成立的 \(len\) 就是最小迴圈節長度。
最小質因子可以用線性篩來實現。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 500050
typedef unsigned long long ull;
ull Hash[N],power[N];
char ch[N];
int n,q,b=131,ans,len,cnt,l,r;
int isPrime[N],Prime[N],fac[N];
inline int read()
{
int x=0,f=1;char c=getchar();
while(c<'0'||c>'9') { if(c=='-')f=-1;c=getchar(); }
while(c>='0'&&c<='9') { x=x*10+c-48;c=getchar(); }
return x*f;
}
inline void Getprime(int n)
{
for(int i=2;i<=n;i++) isPrime[i]=1;
for(int i=2;i<=n;i++)
{
if(isPrime[i]) { Prime[++cnt]=i;fac[i]=i; }
for(int j=1;j<=cnt&&i*Prime[j]<=n;j++)
{
isPrime[i*Prime[j]]=0;fac[i*Prime[j]]=Prime[j];
if(i%Prime[j]==0) break;
}
}
}
inline void init()
{
power[0]=1;
for(int i=1;i<=n;i++) power[i]=power[i-1]*b;
for(int i=1;i<=n;i++) Hash[i]=Hash[i-1]*b+(ull)(ch[i]-'a'+1);
Getprime(n);
}
inline int Cal(int l,int r) { return Hash[r]-Hash[l-1]*power[r-l+1]; }
int main()
{
n=read();
for(int i=1;i<=n;i++) cin>>ch[i];
q=read();
init();
while(q--)
{
l=read();r=read();
ans=len=r-l+1;
if(Cal(l,r-1)==Cal(l+1,r)) { puts("1");continue; }
while(len>1)
{
if(Cal(l,r-ans/fac[len])==Cal(l+ans/fac[len],r)) ans/=fac[len];
len/=fac[len];
}
printf("%d\n",ans);
}
return 0;
}
(你猜我為什麼用快讀?)
7.珍珠項鍊Beads
思路:
首先第一想法是列舉 \(k\) ,然後比較不同 \(k\) 下的段數並找出最優。
接下來我們來看看時間複雜度是否符合題目要求,列舉 \(k\) 時間複雜度為 \(O(n)\) ,對於每一個 \(k\) 共有 \(\left\lfloor\dfrac{n}{k}\right\rfloor\) 個字串。
所以總時間複雜度為 \(\sum\limits_{i=1}^n {\left\lfloor\dfrac{n}{k}\right\rfloor}\) ,
原式展開化簡可得, \(\sum\limits_{i=1}^n {\left\lfloor\dfrac{n}{k}\right\rfloor}=n\left(1+\dfrac{1}{2}+\dfrac{1}{3}+...+\dfrac{1}{n}\right)\approx n\ln n<n\log_2{n}\) ,
所以我們可以認為時間複雜度為 \(O(n\log_2{n})\) 。
資料範圍 \(n\leq 2\times 10^5\) ,這個做法顯然是符合題目要求的。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 200050
#define MOD 10000007
typedef unsigned long long ull;
int n,b=19260113;
int f[N],h[MOD];
int cnt,ans,sum;
ull a[N],power[N],Hash_front[N],Hash_back[N];
inline int read()
{
int x=0,f=1;char ch=getchar();
while(ch<'0'||ch>'9') { if(ch=='-')f=-1;ch=getchar(); }
while(ch>='0'&&ch<='9') { x=x*10+ch-48;ch=getchar(); }
return x*f;
}
int main()
{
n=read();
for(int i=1;i<=n;i++) cin>>a[i];
power[0]=1;
for(int i=1;i<=n;i++) power[i]=(power[i-1]*(ull)b)%MOD;
for(int i=1;i<=n;i++) Hash_front[i]=((Hash_front[i-1]*b)+a[i])%MOD;
for(int i=n;i>=1;i--) Hash_back[i]=((Hash_back[i+1]*b)+a[i])%MOD;
for(int k=1;n/k>=ans;k++)
{
cnt=0;
for(int i=k;i<=n;i+=k)
{
int p=(Hash_front[i]-Hash_front[i-k]*power[k])%MOD;
int q=(Hash_back[i-k+1]-Hash_back[i+1]*power[k])%MOD;
if(h[p]==k) continue;
cnt++;
h[p]=k;
h[q]=k;
}
if(cnt>ans)
{
ans=cnt;
sum=0;
}
if(cnt==ans) f[++sum]=k;
}
cout<<ans<<" "<<sum<<endl;
for(int i=1;i<=sum;i++) cout<<f[i]<<" ";
return 0;
}
8.反轉字串Antisymmetry
思路:
在這裡討論雜湊的做法。
看到資料範圍 \(n\leq5\times10^5\) ,想到複雜度可能是 \(O(n\log_2{n})\) 。
發現反轉字串長度一定是偶數,並且可以理解為異或條件下的迴文字串,即前面有一個 \(0\) 後面對應位置就應當是一個 \(1\) 。
所以我們可以列舉對稱軸的位置,然後用二分去找可以從對稱軸向外擴充套件的長度 \(l\) ,則該反轉字串長度為 \(2l\) 。
接下來考慮其對答案的貢獻,反轉字串的所有字串有相同的對稱軸,所以任意長度為 \(2l\) 的反轉字串對答案的貢獻是 \(l\) 。所以累加每一個位置做對稱軸時對應的 \(l\) 即可。
最後考慮對字串雜湊的處理,正向雜湊我們可以正常儲存,反向雜湊我們就把 \(0\) 看作 \(1\) 即可。
需要注意的細節:
① 對稱軸位於 \(1\) 或 \(0\) 這些數字之間,即對稱軸所處的位置不是陣列的下標,在寫陣列下標時要謹慎。
② 每一次二分長度時右端點初始值 \(r=\min(i,n-i)\) , \(i\) 為當前對稱軸所在的位置
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define N 500050
typedef unsigned long long ull;
typedef long long ll;
int n,b=131;
int l,r,mid,x;
ll ans;
char ch[N];
ull Hash_front[N],Hash_back[N],power[N];
ull Cal1(int l,int r) { return Hash_front[r]-Hash_front[l-1]*power[r-l+1]; }
ull Cal2(int l,int r) { return Hash_back[l]-Hash_back[r+1]*power[r+1-l]; }
bool check(int l,int r) { return Cal1(l,r)==Cal2(l,r); }
int main()
{
cin>>n;
scanf("%s",ch+1);
power[0]=1;
for(int i=1;i<=n;i++) power[i]=power[i-1]*(ull)b;
for(int i=1;i<=n;i++) Hash_front[i]=Hash_front[i-1]*b+(ch[i]=='1');
for(int i=n;i>=1;i--) Hash_back[i]=Hash_back[i+1]*b+(ch[i]=='0');
for(int i=1;i<n;i++)
{
l=0;r=min(i,n-i);x=0;
while(l<=r)
{
mid=(l+r)>>1;
if(check(i-mid+1,i+mid)) { l=mid+1;x=mid; }
else r=mid-1;
}
ans+=x;
}
cout<<ans<<endl;
return 0;
}
9.門票
思路:
這道題目有很多種做法,可以用 stl 裡的 map 或者 unordered_set (用 map 可能會tle ),
或者直接開布林陣列,因為布林陣列佔位元組數小,可以開的很大, \(1\times10^9\) 大小完全沒問題。
在這裡講一下手寫 \(\operatorname{Hash}\) 的做法,正好借這個機會學習一下自定義 \(namespace\) 的寫法。
手寫雜湊表,首先構造雜湊函式 \(\operatorname{H}\) ,這裡採用的是除餘法 $\operatorname{H}(key)=key\mod{ P } $ ,
雜湊表的原理就是將雜湊值相同的關鍵字以連結串列的形式儲存,
接下來就比較簡單了,將 \(a_0\) 先存入表中,接下來對於每一個 \(a_i\) 先檢查是否已經在鄰接表裡出現過,若出現過則直接找到了重複的項,沒有的話就將 \(a_i\) 加入鄰接表中,並且更新 \(a_i\) 。
程式碼:
#include<bits/stdc++.h>
using namespace std;
#define MOD 1000009
#define N 2000050
typedef long long ll;
ll A,B,C,num=1;
namespace Hash
{
int head[N],ver[N],Next[N],tot=-1;
inline void init() { memset(head,-1,sizeof(head)); }
inline int H(int x) { return x%MOD; }
inline void ADD(int x,int y)
{
ver[++tot]=y;
Next[tot]=head[x];
head[x]=tot;
}
inline bool check(int x,int y)
{
for(int i=head[x];~i;i=Next[i])
if(ver[i]==y) return true;
return false;
}
}
using namespace Hash;
int main()
{
init();
cin>>A>>B>>C;
for(int i=0;i<=2000000;i++)
{
if(check(H(num),num)) { cout<<i<<endl;return 0; }
ADD(H(num),num);
num=(num*A+num%B)%C;
}
puts("-1");
return 0;
}