以下默認字符串下標從1開始,用 \(s[l,r]\) 表示字符串 \(s\) 的第 \(l\) 到第 \(r\) 個字符組成的子串,記字符串 \(s\) 的長度為 \(len(s)\)。
概述
字符串 \(\text{Hash}\) 常用於各種字符串題目的部分分中。
字符串 \(\text{Hash}\) 可以在 \(O(1)\) 時間內完成判斷兩個字符串的子串是否相同。通常可以用這個性質來優化暴力以達到騙分的目的。
由於單純的字符串 \(\text{Hash}\) 已經快成為普及算法了,所以其實現原理不是本文重點。本文重點講述的是字符串 \(\text{Hash}\) 在 OI 中的常見應用(及各種騙分技巧)
假如你沒有聽說過字符串 \(\text{Hash}\) ,你可以先學習2年前的洛谷日報哈希基礎知識或熟讀第一章節。
假如你已經知道了字符串 \(\text{Hash}\) 的基本操作,你可以直接跳過第一章節。
假如你已經精通各種 \(\text{Hash}\) 操作並且隨便秒掉字符串的題目,這篇文章大概對您沒任何幫助,點個贊再走吧。
一、什么字符串 \(\text{Hash}\)
字符串 \(\text{Hash}\) 其實本質上是一個公式:
其中 \(b,m\) 是常量。
由於 \(s[i]\) 通常是一個字符,你可以直接把ASCII碼代入,或者如果是小寫字母代入 \(s[i]-'a'\),數字代入 \(s[i]-'0'\) 等等。
比如如果令 \(b=7,m=100,s="114514"\)(此處代入數值,即\(s[1]=1,s[2]=1,s[3]=4,\cdots\))。
那么 \(\text{Hash}(s)=36\),即\((4+1\times7+5\times7^2+4\times7^3+7^4+7^5)\%100\)。
可以發現,我們本質上是將 \(s\) 看成一個 \(b\) 進制數(比如上述例子就是把 \(s\) 看成7進制下的114514)然后 \(\bmod \ p\)。
那為什么要采用這樣一個 \(b\) 進制數的形式來處理 \(\text{Hash}\) 呢?
參照哈希表,我們知道如果兩個字符串的 \(\text{Hash}\) 值相同,那么這兩個串大概率是相同的。
但事實上我們常常需要截取一個字符串的子串。可以發現,對於\(s[l,r]\)這個子串的 \(\text{Hash}\) 值
考慮原串\(s\)的前綴和
於是可以推出:
即
於是對於原串記錄\(\text{Hash}\)前綴和,就可以完成 \(O(n)\) 預處理 \(O(1)\) 截取子串 \(\text{Hash}\) 值的優秀時間復雜度。
由於C++的 \(\text{unsigned long long}\) 自帶 \(2^{64}\) 的模數和極小的常數,所以一般的情況下,\(\text{Hash}\)運算通常會采用 \(\text{unsigned long long}\),這種寫法被稱為自然溢出。接下來的代碼中默認開頭添加:
#define ull unsigned long long
好了,你已經學會了字符串 \(\text{Hash}\) 的所有技巧,讓我們來試試吧(霧
二、字符串 \(\text{Hash}\) 的用處
字符串 \(\text{Hash}\) 是一種十分暴力的算法。但由於它能 \(O(1)\) 判斷字符串是否相同,所以可以騙取不少分甚至過掉一些字符串題。
接下來先介紹字符串 \(\text{Hash}\) 的一些騙分技巧。
1.字符串匹配(KMP)
這個其實不能說是騙分,畢竟枚舉起始點掃一遍 \(O(n)\) 解決,時間復雜度和KMP相同(甚至比KMP短)。
代碼略。
2.回文串
考慮以同一個字符為中心的回文串的子串一定是回文串,所以滿足可二分性。
將字符串正着和倒着 \(\text{Hash}\) 一遍,如果一個串正着和倒着的 \(\text{Hash}\) 值相等則這個串是回文串。枚舉每個節點為回文中心,二分即可。
時間復雜度相比較 \(\text{manacher}\) 較劣,為 \(O(n\log n)\)。發現過不了模板題。
關鍵代碼
ull num[22000000],num2[22000010];
ull find_hash(int l,int r)
{
if(l<=r)
return num[r]-num[l-1]*_base[r-l+1];//順序Hash
return num2[r]-num2[l+1]*_base[l-r+1];//倒序Hash
}
int l=0,r=min(i-1,len-i);
int len=0;
while(l<=r)
{
int mid=(l+r)>>1;
if(find_hash(i,i+mid)==find_hash(i,i-mid)) l=mid+1,len=mid;//要求順序Hash與倒序Hash匹配
else r=mid-1;
}
3.\(\text{lcp}\)(最長公共前綴)
\(\text{lcp}\) 也具有可二分性。對於兩個串 \(s,t\) 的兩個前綴 \(s_1,s_2\),假設 \(len(s_1)<len(s_2)\),若其中 \(s_2\) 是 \(s,t\) 的前綴,則 \(s_1\) 也是 \(s,t\) 的前綴(即公共前綴的前綴一定是公共前綴)。
所以可以在 \(O(\log n)\) 時間求出兩個串的前綴。
(這個性質對字符串 \(\text{Hash}\)來講十分重要,這其實也是字符串 \(\text{Hash}\) 雖然簡單但仍能在省選等地方看見的主要因素,具體在后文會講)
例:后綴排序
仿照上述求 \(\text{lcp}\) 的方式,因為決定兩個字符串的大小的是他們 \(\text{lcp}\) 的后一個字符,所以用快排加二分求 \(\text{lcp}\) 即可做到 \(O(n\log^2n)\) 的時間復雜度。比 \(\text{SA}\) 多了一個\(\log\)。
//當然這里是開了O2的,不開直接T飛
ull hashs[1000010];
char str[1000010];
int n;
inline ull get(int l,int r){return hashs[r]-hashs[l-1]*bases[r-l+1];}
bool cmp(int l1,int l2)//二分查找lcp,同時返回下一位的大小
{
int l=-1,r=min(n-l1,n-l2);
while(l<r)
{
int mid=(l+r+1)>>1;
if(get(l1,l1+mid)==get(l2,l2+mid)) l=mid;//判斷是否為公共前綴
else r=mid-1;
}
if(l>min(n-l1,n-l2)) return l1>l2;
else return str[l1+l+1]<str[l2+l+1];//返回下一位
}
int a[1000010];
int main()
{
scanf("%s",str+1);
n=strlen(str+1);
bases[0]=1;
for(int i=1;i<=n;i++)
{
bases[i]=bases[i-1]*base;
hashs[i]=hashs[i-1]*base+str[i];
a[i]=i;
}
stable_sort(a+1,a+n+1,cmp);//好像sort被卡常了,stable_sort跑過了
for(int i=1;i<=n;i++) printf("%d ",a[i]);
return 0;
}
除此之外,其實大部分 \(\text{SAM}\) 的題目中都有不少為 \(\text{SA}\) 和 \(\text{Hash}\) 設置的部分分。這里就不列舉了。
難道字符串 \(\text{Hash}\) 只能去騙其他算法的分嗎?不!其實字符串 \(\text{Hash}\)也有着很多其他算法沒有的優點。
1.求字符串的循環節
例:OKR-A Horrible Poem
題意:給定一個字符串,多次詢問其某個子串的最短循環節。
可以發現對於字符串串 \(s[l,r]\),如果長度為 \(x\) 的前綴是 \(s[l,r]\) 的一個循環節,則必有 \(len(s[l,r])|x\) 且 \(s[l,r-x]=s[l+x,r]\)
如果存在長度為 \(y\) 的前綴是 \(s\) 的循環節,\(y\) 是 \(x\) 的因數且 \(x\) 是串長的因數,則長度為 \(x\) 的前綴必然是 \(s\) 的循環節(感性理解一下)。
考慮篩出每個數的最大質因數,\(O(\log n)\) 分解質因數,然后從大到小試除,看余下的長度是否是循環節,如果是則更新答案。
char str[N];
int len;
ull hashs[N],bases[N];
void make_hash(void)
{
bases[0]=1;
for(int i=1;i<=len;i++)
{
hashs[i]=hashs[i-1]*base+str[i]-'a'+1;
bases[i]=bases[i-1]*base;
}
}//預處理Hash值
ull get_hash(int l,int r){return hashs[r]-hashs[l-1]*bases[r-l+1];}
int prime[N],nxt[N],cnt;
int num[N],tot;
int main()
{
scanf("%d",&len);
scanf("%s",str+1);
make_hash();
for(int i=2;i<=len;i++)
{
if(!nxt[i]) nxt[i]=prime[++cnt]=i;
for(int j=1;j<=cnt && i*prime[j]<=len;j++)
{
nxt[i*prime[j]]=prime[j];//篩出每個數的最大質因數
if(i%prime[j]==0) break;
}
}
int m;
scanf("%d",&m);
for(int i=1;i<=m;i++)
{
int l,r;
scanf("%d%d",&l,&r);
int lens=r-l+1;
int ans=0;
tot=0;
while(lens>1)
{
num[++tot]=nxt[lens];
lens/=nxt[lens];//質因數分解
}
lens=r-l+1;
for(int j=1;j<=tot;j++)
{
int len1=lens/num[j];//進行試除
if(get_hash(l,r-len1)==get_hash(l+len1,r)) lens=len1;//試除成立就取試除后的結果
}
printf("%d\n",lens);
}
return 0;
}
2.動態字符串查詢
現在的大多數字符串算法都是靜態的查詢或者只允許在末尾/開頭添加。但如果要求在字符串中間插入或修改,很多算法就無能為力了。
而字符串 \(\text{Hash}\) 的式子是可以合並的,只要知道左區間的 \(\text{Hash}\) 值,右區間的 \(\text{Hash}\) 值,左右區間的大小,就可以 \(O(1)\) 求出總區間的 \(\text{Hash}\) 值。這就使得字符串 \(\text{Hash}\) 可以套上很多數據結構來維護。
一般來說,修改操作中帶 插入,可持久化,區間修改 這類字眼的字符串題大多就是 \(\text{Hash}\) 套上一些數據結構或是真·毒瘤題。
例1:火星人
題意:求兩個后綴的 \(\text{lcp}\),動態插入字符和改字符。
用平衡樹維護區間的 \(\text{Hash}\) 值。仿照上述求 \(\text{lcp}\) 的方法,將上述代碼中的 \(\text{get-hash}\) 改為平衡樹上查詢即可,時間復雜度 \(O(n\log^2n)\)。
關鍵代碼。
inline void update(int u)
{
siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;
sum[u]=sum[ch[u][0]]*bases[siz[ch[u][1]]+1]+val[u]*bases[siz[ch[u][1]]]+sum[ch[u][1]];//合並Hash值
}
/*
此處插入你喜歡的平衡樹
*/
int main()
{
srand(19260817);
scanf("%s",str+1);
int m;
scanf("%d",&m);
n=strlen(str+1);
bases[0]=1;
for(int i=1;i<=100000;i++) bases[i]=bases[i-1]*base;
for(int i=1;i<=n;i++) root=t.merge(root,t.new_node(str[i]-'a'+1));//平衡樹的初始化
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%s%d",opt,&x);
if(opt[0]=='Q')
{
scanf("%d",&y);
printf("%d\n",lcp(x,y));//查詢
}
else if(opt[0]=='R')
{
scanf("%s",opt);
t.erase(root,x);
t.insert(root,x-1,opt[0]-'a'+1);//往平衡樹中刪除
}
else if(opt[0]=='I')
{
scanf("%s",opt);
t.insert(root,x,opt[0]-'a'+1);//往平衡樹中插入
n++;
}
}
return 0;
}
例2:The Classic Problem
題目大意:求最短路,其中每條邊邊權為 \(2^x\),\(n,m,x\leq 10^5\)。
假如不考慮時間復雜度,可以直接高精度加最短路得到 \(O(m\log n\times x)\) 的復雜度。
我們發現這個 \(O(x)\) 是用於 \(+2^x\) 和比較大小的。前者可以用線段樹維護,這里不詳細說明,可以參考本題題解或是我的博客。
對於后者,我們發現這個可以看成是一個字符串求字典序大小的題。而線段樹又恰好可以支持這樣的 \(\text{Hash}\) 查詢。復雜度 \(O(m\log n\log^2 x)\)。
考慮 \(\text{Hash}\) 求 \(\text{lcp}\) 的本質是二分前綴是否相同,而線段樹恰好就滿足二分的性質,所以可以從根節點開始,如果兩樹的右子樹的 \(\text{Hash}\) 值不同,可以直接跳右子樹繼續查詢。否則說明右子樹相同,跳左子樹查詢即可。
還有,由於直接復制顯然會T,這里的線段樹需要可持久化,就是每個節點繼承轉移節點(最短路樹上的父親)的信息同時加上一次修改。
時間復雜度 \(O(m\log n\log x)\)。
關鍵代碼:
void update(int u,int l,int r)
{
int mid=(l+r)>>1;
hs[u]=hs[ls[u]]+hs[rs[u]]*bs[mid-l+1];//合並Hash值
val[u]=val[ls[u]]==(mid-l+1)?val[ls[u]]+val[rs[u]]:val[ls[u]];//從l開始1的個數
}
int cmp(int u,int v)//比較u,v兩樹的大小
{
int fu=u,fv=v;
int l=0,r=n;
while(1)
{
if(!u || tag[u]) return fv;//如果有一方全是0,直接跳出
if(!v || tag[v]) return fu;
if(l==r) return hs[u]<=hs[v]?fv:fu;//如果到了葉節點,直接比較
int mid=(l+r)>>1;
if(hs[rs[u]]==hs[rs[v]]) u=ls[u],v=ls[v],r=mid;//如果右子樹相同,就比較左子樹
else u=rs[u],v=rs[v],l=mid+1;//否則比較右子樹
}
}
完整代碼詳見我的博客。
例三:一道口胡的題(暫時沒有找到出處)
題意:維護一個字符串的子串的集合,一開始字符串和集合均為空。
要求完成:在集合中插入一個子串,在字符串末尾加一個字符,求集合中與當前詢問的子串 \(\text{lcp}\) 最大值。
比如字符串為 \(abbcbba\)
集合中的子串為 \(s[1,4],s[3,6],s[5,7]\)。
此時查詢與子串 \(s[2,5]\),答案為2(\(bbcb\) 和 \(bba\) 的 \(\text{lcp}\) 為2)。
\(m\leq 10^5\),強制在線(為了防止 \(\text{SA}\) 過而特意加的,原題應該沒有)。
(假如存在SAM/后綴平衡樹的做法可以私信或在下方評論)
首先,考慮一些暴力的做法:
- 暴力將一個子串和集合中的串用上述方法求lcp,時間復雜度 \(O(m^2\log m)\)
- 暴力建\(\text{trie}\),將子串掛到\(\text{trie}\)上,時間復雜度 \(O(m^2)\),空間 \(O(m^2)\)
顯然上述的方法都不可行。
考慮使用 \(\text{SA}\) 的想法,與一個串lcp最大的串一定是字典序最靠近它的串,也就是比它字典序大中最小的,和比它小中最大的。
仿照這個思路,使用上述比較兩個串字典序大小的方法,考慮使用平衡樹來維護子串集合中字典序的順序,查詢時只需查詢前驅后繼中的 \(\text{lcp}\) 最大值即可。

時間復雜度 \(O(m\log^2m)\)
題目都沒有,代碼肯定咕了
由此可以發現:凡是維護區間,支持維護區間合並(好像區間 \(split\) 也可以)並且支持在線查找的數據結構,諸如什么樹套樹,樹套分塊,在線帶修莫隊,大都可以套上 \(\text{Hash}\) 出道題。
upd:今年ZJOI2020喜聞樂見地出了一道可以用 \(\text{Hash}\) 判斷 \(\text{lcp}\) 過的題(雖然用\(\text{SA}\)其實也可以,而且標算其實是一些科技),ZJOI2017也有一道需要用\(\text{Hash}\)的題。
由於上述兩題也是利用\(\text{Hash}\)的性質求\(\text{lcp}\),而且主要難度不在於\(\text{Hash}\),所以這里不再闡述。(其實是我太菜了,以上兩題都沒有過。)
如果以上題目不能滿足你的需求,可以參考這份題單
三、字符串的\(\text{Hash}\)沖突與雙\(\text{Hash}\)
雖然字符串 \(\text{Hash}\) 在暴力方面有極大的優勢,但從它的名字中也可以看出它存在的缺點:\(\text{Hash}\) 沖突。
事實上,自然溢出的 \(\text{Hash}\) 會被定向叉,可以通過構造卡掉,具體可見 Hash Killer I(抱歉,bzoj掛了,鏈接不見了)
Hash Killer II
題意:卡單 \(\text{Hash}\) 的代碼,要求 \(n\leq 10^5\),代碼中\(\ mod=10^9+7\)。
upd: bzoj沒了,題目也不見了。
根據生日悖論,對於 \(n\) 個不同的字符串,有些 \(mod\) 可能看着比 \(n\) 大得多(比如\(10^9+7\)),但它還是極有可能把其中兩個不同的串判成相同。當 \(mod\) 與 \(n\) 相差較大時有沖突概率 \(P\approx 1-(1-\frac{1}{mod})^{\frac{n(n-1)}{2}}\)(具體推導詳見百度百科)
通過代入求值可以發現,對於該題,盡管 \(mod\) 是 \(n\) 的1000倍以上,可是這個沖突的概率仍然高得驚人(大概是 \(99.32\%\)),也就是說你隨便隨機一個字符串上去它大概率就會掛掉。
放一張度娘的圖(圖中的\(N\)對應題目中的\(mod\))。
upd: 圖可能掛了,如果不行請自行百度。

通過式子可以發現,如果想要 \(P\) 不變,\(mod\) 應當與 \(n^2\) 同階,而不是和 \(n\) 同階!!(可以理解成:如果我們認為當 \(n=100\) 時, $ mod=10^5$的沖突概率剛好可以接受,那么當 \(n=10^5\) 時就必須要 \(mod\geq10^{11}\)。)
這時候就要用到雙 \(\text{Hash}\) 了,其實就是用兩個不同的 \(base\) 或是 \(mod\) 對同一個串進行2遍 \(\text{Hash}\) 。
同樣,如果兩個串第一個 \(\text{Hash}\) 匹配而第二個不匹配,那么這兩個串還是不相等(因為相等的串無論怎么 \(\text{Hash}\) 都是相等的)。
所以假如用自然溢出可能會被卡的情況下(其實一般情況下是不會的),建議寫雙\(\text{Hash}\)。但是需要注意一點,雙 \(\text{Hash}\) 的常數十分之大。
我常用的雙 \(\text{Hash}\) 寫法:
#define ll long long
#define P pair<ll,ll>
#define MP make_pair
#define fi first
#define se second
#define mod 1000000007
P operator +(const P a,const P b){return MP((a.fi+b.fi)%mod,(a.se+b.se)%mod);}
P operator -(const P a,const P b){return MP((a.fi-b.fi+mod)%mod,(a.se-b.se+mod)%mod);}
P operator *(const P a,const P b){return MP(a.fi*b.fi%mod,a.se*b.se%mod);}
const P base=MP(233,2333);
//后續代碼同單Hash
不過,雙 \(\text{Hash}\) 其實很不常用,首先它的誤判率並不比自然溢出的寫法優秀,再加上其巨大的常數,更長的代碼量,\(pair\) 類型也並不如 \(ull\) 那么友好,所以絕大部分 \(\text{Hash}\) 用的都還是自然溢出。
當然,在codeforces上寫自然溢出的絕對是勇士(別問我怎么知道的。
四、總結
雖然字符串 \(\text{Hash}\) 相比較其他字符串算法而言存在一定概率出錯。這在計數題(尤其是數據打包的題目)中有時是十分致命的(雖然是極小的概率)。
但是字符串 \(\text{Hash}\) 的一些優秀性質也使得它有其他算法所沒有的一些優勢。在多數題目中,字符串 \(\text{Hash}\) 也未嘗不是一種簡易的騙分方式。
當然,大多數情況下 \(\text{Hash}\) 都是配合其他算法以及數據結構一起出現的,所以就算會了Hash我還是咸魚。
