這篇總結所有的字符串都是以 0 為下標起點
Z函數(ExKMP)
對於一個字符串 \(S\)
我們規定一個函數 \(Z[i]\) 表示 \(S\) 與 \(S[i...n-1]\) 的 LCP(最長公共前綴)的長度。
即 \(S[0.....Z[i]-1]\) 與 \(S[i...i+Z[i]-1]\) 相等
先說構造 \(Z\) 函數,再說 \(Z\) 函數的應用
首先考慮暴力的構造 時間復雜度 \(O(n^2)\)
char s[N];
inline void GetZ(){
int len=strlen(s);
for(register int i=0;i<len;++i)
while(i+z[i]-1<n&&s[z[i]]==s[i+z[i]]) z[i]++;
for(register int i=0;i<len;++i)
cout<<z[i]<<" ";
}
這就是一個根據定義的模擬,但是顯然 \(O(n^2)\) 的時間復雜度有些不太優秀,所以考慮優化:
擴展時的判斷條件根據上面的代碼,是:
while(i+z[i]-1<n&&s[z[i]]==s[i+z[i]]) z[i]++;
這一步是用枚舉實現的,是 \(O(n)\) 的,那么如何對這一步進行優化呢?
對於枚舉的優化:
這時考慮先考慮一下 \(Z\) 函數的性質:
從定義來說:這是滿足$S[0.....Z[i]-1] $與 \(S[i,i+Z[i]-1]\) 相等的最長長度
性質1:那么對於一個區間\([l,r]\),\(l \in [i,i+Z[i]-1]\),\(r\in [i+Z[i]-1]\),它一定與區間 \([l-i,r-i]\) 相等(定義),
那么考慮優化暴力的思路,即如何減少枚舉:
如何減少枚舉呢?大部分情況來說是從當前已知的情況去更新當前未知的情況,如果不行,再枚舉
記錄下\(i+Z[i]-1\) 的最大值 \(r\) ,與這個最大值對應的 \(i\),下面出現的 \(l\),就是這個最大值對應的 \(i\)。
如果對於當前的一個位置 $i $,如果 \(i \leq r\)。那么根據性質 \(1\) , \(S[i....r]\) 是與 \(S[i-l.....r-l]\) 相等的
所以要么 \(i\) 這個位置與 \(Z[i-l]\) 一樣,與 \(S\) 的LCP長度為 \(Z[i-l]\),要么它可以匹配完整個 \(r-i+1\),還可以繼續往后匹配。
簡單來說,就是\(Z[i] \geq min(r-i+1,Z[i-l])\)
那么如果此時 \(Z[i-l]\) 還滿足 \(Z[i-l] < r-i+1\) 也就是當前可以繼承的范圍並沒有到達此時的邊界 \(r\) ,我們選擇直接繼承。
即
if(Z[i-l]<r-i+1) Z[i]=Z[i-l];
根據上面的分析,如果不滿足上面的這個條件話,證明它可以匹配完整個 \(r-i+1\),並且還能向后匹配
所以代碼也就更簡單了:
if(Z[i-l]>=r-i+1){
Z[i]=r-i+1;
while(i+Z[i]-1<len&&S[Z[i]]==S[i+Z[i]]) Z[i]++;
}
但是我們發現上面的兩個程序本身是沒有問題的,只是有一些情況沒有考慮到:
1.比如當前的位置 \(i\),如果已經 \(>r\) 了,那么上面的所有結論都不成立。這時就應該直接暴力匹配
2.我們的 \(r\),表示的是當前匹配段最右邊的端點值,而 \(l\) 是它所對應的 \(i\) 值,所以在暴力匹配后,應該更新 \(l,r\) 的值。
所以整個求 \(Z\) 函數的代碼應該是這樣的:
int len=strlen(s);
Z[0]=0;//其實根據定義這里也珂以賦值為 len。
for(register int i=1;i<len;++i){
if(i<=r&&Z[i-l]<r-i+1) Z[i]=Z[i-l];
else{
Z[i]=max(0,r-i+1);
//因為可能有兩種情況進來,一個是i>r,一個是Z[i-l]>=r-i+1,而兩種情況對於Z[i]的賦值是不同的。所以這里直接一個max(0,r-i+1)概括兩種情況
while(i+Z[i]-1<len&&S[Z[i]]==S[i+Z[i]]) Z[i]++;
if(r<i+Z[i]-1) l=r,r=i+Z[i]-1;
}
}
為什么我們的循環要從1開始呢?
因為如果從0開始的話,\(r\) 會直接擴展完,而整個算法也會隨之退化到 \(O(n^2)\)
Z函數的應用:
1.字符串匹配
一個字符串算法少不了的就是字符串匹配了。
一道經典例題:
求一個字符串 \(A\) ,在另一個字符串 \(B\) 中的出現次數。
你先想了想 \(Z\) 函數,發現它儲存的都是 \(B\)的后綴與 \(B\) 匹配的信息,基本無法應用到與 \(A\) 匹配上面。
那么如何將 \(B\) 與 \(B\) 匹配的信息變成 \(B\) 與 \(A\) 統計的信息呢?
答案十分 \(Naive\)
把 \(A\) 加在 \(B\) 的前面不久好了?
此時在新的字符串中 \(A\) 是這個串的前綴,那么此時匹配的就都是 \(A\) 了。
當然這樣是有問題的,比如位置 \(i\) 的后綴已經可以把 \(A\) 全部匹配完了,他還是會和自己匹配,那么此時的信息根本無法用到與 \(A\) 的匹配中去。
所以我們還需要在 \(A\) 與 \(B\) 之間加上一個特殊符號 '#',從而保證匹配長度不會超過 \(len_A\)。
那么統計出現次數時只需要統計在 \(B\) 串的范圍內,有多少個位置滿足\(Z[i]=len_A\) 的就行了。
有了上面字符串匹配的知識,你就可以 \(A\)掉一些簡單的模板題了!
題目:
P5410 【模板】擴展 KMP(Z 函數)
CF126B Password
UVA12604 Caesar Cipher
2.判斷循環節
幾個概念:
對字符串 \(S\) 和 \(0> p \leq |S|\),若 \(S[i]=S[i+p]\) 對所有 \(i \in [0,|S|-p-1]\) 成立,則稱 \(p\) 為 \(S\) 的周期
對字符串 \(S\) 和\(0 \leq r <|S|\),若 \(S\) 長度為 \(r\) 的前綴 和 長度為 \(r\) 的后綴相等,則稱長度為$ r$ 的前綴為 \(S\) 的 $ border$。
注意,周期不等價於循環節!
如果一個長度為 \(k\) 的周期是循環節,那么一定滿足 \(len\% k=0\)
題目
求一個字符串 \(A\) 的最短循環節。
對於一個長度為 \(k\) 的循環節,一定滿足\(S[0......k-1]=S[len-k.....len-1]\)
如果轉化為 \(Z\) 函數的話,就是 \(i+Z[i]==len\) 就是 \(i\) 的后綴為 \(S\) 的一個Border,有一個長度為 \(Z[i]\) 的 \(border\) 等價於有一個長度為 \(len-Z[i]\) 的周期。(證明略過)
那么我們可以 \(O(n)\) 的掃,如果當前\(i+Z[i]==len\) 那么判斷 \(len\%(len-Z[i])\) 是否等於 \(0\) 。因為滿足 \(i+Z[i]=len\) 的 \(len-Z[i]\) 是遞減的(因為 \(i\) 枚舉時遞增。)所以第一個滿足上述條件的 \(len-Z[i]\) 就是最大的循環節,要找最小的可以直接倒敘枚舉,然后第一個直接退出。
例題:
UVA455 周期串 Periodic Strings
(因為我太弱了,所以我沒有找到更多的循環節例題 )
3.判斷回文
只要你理解了 \(Z\) 函數在字符串匹配的應用。如果要判斷一個串 \(S\) 是否為回文,只需要將它的反串 \(S'\) 拼在 \(S\) 前面,然后中間加上一個 '#' ,直接匹配,最后判斷 \(Z[len]\) 是否等於 \(len\) 就好了。(這里的 \(len\) 是指單個字符串的長度,不是拼在一起的長度)
例題:
UVA11475 Extend to Palindrome
題意:
就是加最少的字母,使得原串變為一個回文串。
設當前的字符串為 \(S\),\(S\) 一定可以被分成兩部分 \(A\),\(B\)。
其中\(B\)是一個回文串(也可以是一個空串),\(A\) 是一個普通的字符串。
放一個圖方便理解吧:
設 \(A\) 的反串為 $ A'$
而且 \(A+B+A'\) 一定是一個回文串(想一想為什么)
那么我們加上的字符串就是 \(A'\)
因為\(|A'|\) = \(|A|\),\(|A|=|S|-|B|\)
因為\(|S|\)一定,為了讓\(|A'|\)更小,所以需要找到最大的\(|B|\)
也就是找出 \(S\) 的后綴中最長的回文串。
這個利用 \(Z\) 函數很容易解決
我們將 \(S\) 的反串 \(S'\) 拼在 \(S\) 的前面,那么一個后綴回文串左端點 \(i\) 一定滿足 \(Z[i]=\)這個后綴回文串的 \(len\) ,也就是\(i+Z[i]=\) 整個字符串的 \(len\),即\(i+Z[i]=len_S\)
記住,我們找的是最長的后綴回文串,也就是 \(|B|_{max}\)
但答案需要的是\(|A|\),並且還要將 \(S[0\)~\(|A|\)-\(1]\)倒過來輸出
最后輸出就可以了。
Code:
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+3;
char s[N];
int len,z[N],siz;
inline void GetS(){
z[0]=siz+1;
for(int i=1,l=0,r=0;i<=siz;++i){
if(i<=r&&z[i-l]<r-i+1) z[i]=z[i-l];
else{
z[i]=max(0,r-i+1);
while(i+z[i]<=siz&&s[z[i]]==s[i+z[i]]) ++z[i];
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
}
return;
}
int main(){
while(scanf("%s",s)!=EOF){
len=strlen(s);siz=2*len;
s[len]='#';
for(register int i=len+1;i<=siz;++i) s[i]=s[i-len-1];
reverse(s,s+len);
GetS();int maxn=0;
for(register int i=siz;i>len;--i){if(z[i]==siz-i+1){maxn=z[i];} }
maxn=len-maxn;
for(register int i=len+1;i<=siz;++i) cout<<s[i];
reverse(s+len+1,s+len+1+maxn);
for(register int i=len+1;i<=len+maxn;++i) cout<<s[i];
putchar('\n');
}
return 0;
}
4.完美子串?
對於一個串 \(S\),如果一個串既是它的前綴又是它的后綴,那么他就是 \(S\) 的完美子串。用 \(Z\) 函數來說,就是 \(i\) 如果滿足 \(i+Z[i]==len\) 則 \(i\) 開頭的后綴為完美子串。
一些變式
1.求完美子串的出現次數:
首先注意到,每一個完美子串的長度都不相同,這就意味這我們不需要判斷一個完美子串與另一個完美子串是否本質相同。
而且大的完美子串中一定包含小的完美子串,這也就啟發我們可以利用 桶+后綴和 的思想來統計出現次數。
那么如何判斷某一個子串可以包含某一個大的完美子串( \(k\) )呢?很顯然,只需要這個點 \(i\) 的 \(Z[i]\geq len_k\) 就行了(因為每一個完美子串也是一個前綴。)
例題:
CF126B Password
CF432D Prefixes and Suffixes
//\(Z\) 函數蒟蒻會的就這么點了。。。覺得好的點個贊唄~(贊在文章底部作者欄的右邊)
前綴函數
好吧其實前綴函數和 \(KMP\) 的 \(next\) 數組沒什么大區別,只不過一個是下標一個是長度罷了。
給定一個長度為 \(len\) 的字符串 \(S\) , 其前綴函數被定義為一個長度為 \(n\) 的數組 \(\pi\)。其中\(\pi[i]\) 的定義為:
1.如果 \(i\) 的前綴 \(S[0...i]\) 有一對相等的真前綴與真后綴,即 \(S[0.....k-1]=S[i-k+1.....i]\) 那么 \(\pi[i]\) 就是這個相等的真前綴的長度,也就是 \(\pi[i]=k\)
2.如果有不止一對相等的,那么 \(\pi[i]\) 就是其中最長的那一對的長度;
3.如果沒有相等的,那么 \(\pi[i]=0\)
簡單來說 \(\pi[i]\) 表示的也就是以 \(i\) 為右端點的前綴最長的 \(border\) 長度( \(border\) 的定義看上面)
特別的,我們規定 \(\pi[0]=0\)
如果直接暴力計算前綴函數的話:
Code:
inline void Getpi(){
string s;cin>>s;int len=s.size();
for(register int i=1;i<len;++i){
for(register int j=i;j>=0;--j){
if(s.substr(0,j)==s.substr(i-j+1,j)){
pi[i]=j;
break;
}
}
}
return;
}
顯然上面的算法是 \(O(n^3)\) 的,不夠優秀
考慮優化
優化構造前綴函數
優化1:相鄰的兩個前綴函數值最多增加 1。
這個顯然,如果已經求出了當前的 \(\pi[i]\) 需要求出一個盡量大的 \(\pi[i+1]\) 時。
\(S[i+1]=S[\pi[i]]\) 的(下標從 \(0\) 開始),此時的 \(\pi[i+1]=pi[i]+1;\)
所以從 \(i\) 到 \(i+1\) 時,前綴函數值只可能增加 \(1\), 或者維持不變,或者減少。
此時可以將整個代碼優化成這樣:
inline void Getpi(){
string s;cin>>s;int len=s.size();
for(register int i=1;i<n;++i){
for(register int j=pi[i-1]+1;j>=0;--j){
if(s.substr(0,j)==s.substr(i-j+1)){
pi[i]=j;
break;
}
}
}
return;
}
這個時候,因為起始點變為了 \(\pi[i-1]+1\) 所以只有在最好的情況下才會在這個枚舉上限上 \(+1\) ,所以最多的情況時會進行 \(n-1+n-2+2n-3\) 次比較
所以這個時候整個算法時間復雜度已經是 \(O(n^2)\) 了。但還是不夠優秀
優化2:可以通過不斷地跳前綴函數來獲取一個合法的匹配長度
在優化1中,我討論了最優情況下的轉移,那么這時理所當然的就該來優化\(S[\pi[i]]!=S[i+1]\) 時的匹配了
我們在 \(S[\pi[i]]!=S[i+1]\) 時,根據 \(\pi\) 函數的最優性,我們應該找到第二長的長度 \(j\) 使得 \(S[0....j-1]==S[i-j+1.....i]\) 這樣我們才能繼續用 \(S[i+1]=S[j]\) 時的拓展。
而當我們觀察了一下可以發現:
\(S[0.....\pi[i]-1]=S[i-\pi[i]+1....i]\) 所以第二長 \(j\) ,也就等價於\([0,\pi[i]-1]\) 這個區間中的最長 \(border\) 的長度 ,在一想,這不就是 $\pi[pi[i]-1] $ 嘛?(因為 \(\pi\) 函數,代表的一定是這個區間最長的 \(border\) 的長度)
所以這時我們只需要不停地跳 \(\pi\) 函數,就可以得到當前的 \(\pi[i+1]\) 了。
Code:
inline void Getpi(){
string s;cin>>s;int len=s.size();
//因為下標從0開始,所以下標其實是長度-1,所以格式與上文可能有些不符合,但是理解了就對了!
for(register int i=1;i<len;++i){
int j=pi[i-1];
while(j&&S[i]!=S[j]) j=pi[j-1];
if(S[i]==S[j]) ++j;
pi[i]=j;
}
return;
}
發現:我們枚舉的 \(i\) 最多讓 \(j\) 增加 \(n\),而我們每次的跳至少會讓 \(j-1\),所以無論 \(j\) 減小多少次,總的次數也不會超過 \(O(n)\)
所以此時構造的時間復雜度就為 \(O(n)\)
前綴函數的應用~
1.經典字符串匹配
求一個字符串 \(A\) ,在另一個字符串 \(B\) 中的出現次數。
在前面 \(Z\) 函數匹配字符串的啟發下,很快就能想到:還是將 \(A\) 拼到 \(B\) 前面,中間加上一個特殊字符 '#' 。
因為有一個 ‘#‘ 在中間,所以所有的 \(\pi[i]\) 一定是 \(\leq\) \(len_A\) 的。同樣的想法:那么如何判斷 \(A\) 在 \(B\)中出現過呢?
既然 \(\pi[i]\) 表示的是以 \(i\) 為右端點的前綴長度,這個時候 \(A\) 為整個串的前綴,那么對於一個位置 \(i\),當 \(\pi[i]==len_A\) 時,代表着 \(S[i-len_A+1......i]\) 與 \(A\) 相同 。
學會了這個你就可以 \(A\) 下面的例題了!
例題:
P3375 【模板】KMP字符串匹配
CF126B Password
UVA12604 Caesar Cipher
一道字符串匹配的變式吧。。:
P6080 [USACO05DEC]Cow Patterns G
在很多普通的字符串匹配中,\(\pi\) 函數表示的是前綴中最長的 \(border\) ,也就是前綴中前后綴相等的最長長度。
但在這道題中,很明顯,無法用相等來表示。
首先,將模式串(\(K\) )和數字串(\(N\))拼起來,中間插入一個特殊符號 “#”。
根據題意:我們應該將 \(\pi\) 函數中的“相等”看做大小關系相同,於是$ \pi[i]$ 就表示當前 \(S[0\)~\(i]\) 中前后綴大小關系最長的長度,因為有個特殊符號 “#” ,所以所有的 \(\pi[i] \leq K\),而滿足“壞蛋團體”區間的右端點,一定滿足 \(\pi[r]=K\)。
那么這時問題就出在了如何判斷大小關系相同了。
如果說當前 \(S[0\)~\(j-1]\)與\(S[i-j,i-1]\) 大小關系相同。
那么對於 \(j\) 與 \(i\) 這兩個位置,(首先匹配時這個 \(j\) ,一定是\(\leq K\)的)
如果說 \([0,j-1]\) 中 比\(j\) 大的數與\([i-j,i-1]\)中比 \(i\) 大的數的個數相等
而且 \([0,j-1]\) 中 和\(j\) 相等的數與\([i-j,i-1]\)中和 \(i\) 相等的數的個數相等
又因為兩個區間長度是一樣的,那么區間中大於 \(j\) ,與大於 \(i\) 的數的個數也是相等的。
那么這\([0,j]\) 與 \([i-j,i]\)兩個區間的大小關系相等。
如此我們只需要用一個桶的前綴和,就可以在 \(O(S)\) 的復雜度中求出區間中比它小的與相等的數的個數了。
Warning : 最后需要的是左端點,但利用 \(\pi\) 函數判斷的話,符合條件的是右端點.
與它相似的一道題:CF471D MUH and Cube Walls
2.判斷循環節:
和 \(Z\) 函數差不多,整個前綴函數判斷循環節也是通過不斷地判斷合法的 \(border\) 來確定周期長度,從而確定循環節長度的。
但是其實有一個定理(最長循環串長度=總長度-最長相同前后綴長度(前提是這個長度合法,不合法則不存在合法的循環節))
但是由於 \(Z\) 函數的定義,所以 \(Z\) 函數並不能像前綴函數這樣 \(O(1)\) 求出最長循環節。
證明用的反證法。。這里就不放了。。。有需要的可以找我。。。
3.一個字符串中本質不同的子串個數
給定一個長度為 \(n\) 的字符串 \(S\) ,我們希望計算它的本質不同子串的數目。
我們將用一種在 \(S\) 的末尾添加一個字符后重新計算該數目的方法。
令 \(k\) 為當前 \(S\) 的本質不同子串的數量。我們添加一個新的字符 \(c\) 到 \(S\) 中。現然會有一些寫的子串以 \(c\) 結尾並且之前沒有出現過,我們需要對這些字符串基數。
構造一個字符串 \(T+S+c\) 將它反轉得到 \(T'\)。現在我們的任務變成了計算有多少個 \(T'\) 的前綴沒有在 \(T'\) 中的其他地方出現過,如果我們計算了 \(T'\) 的前綴函數的最大值 \(\pi_{max}\),那么最長的沒有在 \(S\) 中的前綴的長度就為 \(\pi_{max}\)。那么自然,所有更短的前綴也會出現
所以,當添加了一個新字符后出現的新字符串為 \(|S|+1-\pi_{max}\)
所以對於每次加入的字符,我們可以 \(O(n)\) 的算出新出現的子串的數量,所以最終復雜度就為 \(O(n^2)\)
這一段抄的老師的講義。。。(因為我描述不到這么詳細,我太弱了)