淺談字串演算法
Luogu P3805 manacher演算法
馬拉車演算法是求最長迴文串的演算法,其核心在於減少了冗餘的重複計算,利用當前已知的資料儘可能的推出之後的資料,從而達到線性的複雜度。
我認為這個演算法的核心之處是充分利用了迴文串的對稱性。
首先是處理迴文串的一個小技巧,對於奇偶迴文串,我們只需要在相鄰的兩個字元之間加上一個 '#' 字元,那麼無論是奇串還是偶串,都會變成奇串(自行模擬)。
對於你目前已經求出的迴文串 \([1,mx]\),定義 \(mid\) 為這個串的迴文中心,p[i]
陣列表示以 \(i\) 為迴文中心的最長的迴文半徑。
首先給出一個結論,以 \(i\) 為迴文中心的字串的最長迴文串的長度會等於 p[i]-1
那麼怎麼進行 \(manacher\) 呢?
對於一個 \(i(mid<i<mx)\),這時根據對稱性,\(i\) 的對稱點 \(i'\) 點為 \(mid*2-i\),那麼以 \(i\) 為迴文中心的最長迴文半徑一定不會小於以 \(i'\) 點為迴文中心的最長迴文半徑,所以我們可以先讓 p[i]=min(p[mid*2-i],mx-i)
(注意,迴文半徑的長度不能超過 \(mx-i\)),然後在暴力一個一個判斷出 \(i\) 超過 \(mx\) 範圍的迴文串,並且更新 \(mx\) 和 \(mid\),反覆如此,終可求矣。
如果 \(i(mx<i)\)
#include<bits/stdc++.h> using namespace std; const int N=3e7+5; int cnt,p[N],ans; char s[N]; void read(char *str){ char ch=getchar(); str[0]='@'; while(ch>='a'&&ch<='z') str[++cnt]='#',str[++cnt]=ch,ch=getchar(); str[++cnt]='#'; } int main(){ read(s); int mid=1,mx=1; for(int i=1;i<cnt;i++){ if(i<mx){ p[i]=min(p[(mid<<1)-i],mx-i); } else p[i]=1; while(s[i-p[i]]==s[i+p[i]]) p[i]++; if(mx<i+p[i]){ mx=i+p[i]; mid=i; } ans=max(ans,p[i]-1); } printf("%d",ans); return 0; }
Luogu P1368 最小表示法
最小表示法就是對於一個字串,求她的迴圈同構的字串的最小的那個。
如字串 "gfedcba"
,她的迴圈同構的字串為:
"agfedcb"
,"bagfedc"
,"cbagfed"
,"dcbagfe"
,"edcbagf"
,"fedcbag"
那麼很顯然,對於她來說,她的最小表示法為 "agfedcb"
。
考慮怎麼來求一個字串的最小表示法
注:這裡存字串從 \(0\) 開始存。
我們定義兩個指標 \(i\) 和 \(j\),分別指向字串 \(s\) 中的兩個不同的位置(指向位置相同時就無意義了),定義當前分別從 \(i\),\(j\) 開始已經匹配了 \(k\) 個字元(從 \(i\) ,\(j\) 開始,包括 \(i\),\(j\) 本身的後 \(k\) 個字元相等)。
那麼對於 s[(i+k)%n]
和 s[(j+k)%n]
:
-
如果相等那麼匹配數
k++
; -
如果
s[(i+k)%n]>s[(j+k)%n]
,則說明 \(i\sim i+k\) 之間的字元都不會作為最小表示法的開頭,所以 \(i\) 直接跳到 \(i+k+1\); -
如果
s[(i+k)%n]<s[(j+k)%n]
,同理可得 \(j\) 跳到 \(j+k+1\)。
最後,\(i\) 和 \(j\) 位置最小的點就是最小表示法的開頭點,記為 \(ans\)。最後輸出 s[(i+ans)%n]
就好了。
有一點要注意的,就是 \(i\) 不能等於 \(j\),因為要指向不同的位置,否則將會一直匹配,所以相等時讓 i++
就好了。
#include<bits/stdc++.h>
using namespace std;
inline int read(){
int ans=0,f=1;char ch=getchar();
while(!isdigit(ch)){if(ch=='-') f=-f;ch=getchar();}
while(isdigit(ch)){ans=(ans<<3)+(ans<<1)+ch-48;ch=getchar();}
return ans*f;
}
const int N=3e5+5;
int n,ans;
int s1[N];
int minshow(){
int i=0,j=1,k=0;
while(i<n&&j<n&&k<n){
if(s1[(i+k)%n]==s1[(j+k)%n]) k++;
else{
if(s1[(i+k)%n]>s1[(j+k)%n]) i+=k+1;
else j+=k+1;
if(i==j) i++;
k=0;
}
}
return min(i,j);
}
int main(){
n=read();
for(int i=0;i<n;i++){
s1[i]=read();
}
ans=minshow();
for(int i=0;i<n;i++){
printf("%d ",s1[(i+ans)%n]);
}
return 0;
}
Luogu P3375 KMP字串匹配
激動人心的 看毛片 演算法來了
KMP演算法核心處也是減少重複冗餘的計算。
樸素演算法就是雙指標一個一個匹配,當有一個失配後,就將模式串從頭開始匹配。複雜度可以被卡到 \(O (nm)\)
KMP失配後不是從頭開始,而是利用一個 kmp[j]
陣列,記錄 \(j+1\) 失配後 \(j\) 應該從哪裡開始重新匹配 。並且很容易發現,文字串的指標只增不減,模式串的指標會減少也會增加(因為每次失配後只用改變模式串指標位置就好了)。
上圖:
此時 a[i]!=b[j+1]
失配。
於是我們將 \(j\) 從 \(4\) 移動到 \(2\) 的位置,重新開始匹配,就避免了從頭開始匹配。
我們先假設我們已經求出了 kmp
陣列,那麼對於這個問題就很簡單了。
我們只用雙指標依次匹配,當失配後令模式串指標 j=kmp[j]
,再與文字串重新匹配。
程式碼如下:
j=0;
for(int i=1;i<=lena;i++){
while(j&&a[i]!=b[j+1]) j=kmp[j];//如果不匹配,模式串就往前跳
if(a[i]==b[j+1]) j++;//如果相等就匹配下一位
if(j==lenb){//找到相同的串後輸出起始位置
printf("%d\n",i-lenb+1);
j=kmp[j];//跳回去
}
}
因為要讓指標跳回的儘可能少,所以我們就要讓 kmp[j]
陣列存的可以跳回的點儘可能大,所以我們就可以讓 kmp[j]
陣列為從 \(1 \sim j-1\) 位中,字首和字尾相等的部分的最長長度是多少。這樣就可以讓指標每次跳回的儘可以少。
那麼怎麼求出 kmp[j]
陣列呢?
其實就是用模式串對模式串自己做KMP操作
引用 Matrix67 Blog 中的巧妙證明就是
程式碼如下:
j=0;
for(int i=2;i<=lenb;i++){
while(j&&b[i]!=b[j+1]) j=kmp[j];
if(b[i]==b[j+1]) j++;
kmp[i]=j;
}
複雜度如何證明?
引用 \(rqy\) 大佬的證明
每次位置指標 \(i++\) 時,失配指標 \(j\) 至多增加一次,所以 \(j\) 至多增加 \(len\) 次,從而至多減少 \(len\) 次,所以就是 \(\Theta(len_N + len_M) = \Theta(N + M)\) 的。
至此,我們結束了看毛片演算法
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
char a[N],b[N];
int lena,lenb;
int kmp[N],j;
int main(){
cin>>a+1;
cin>>b+1;
lena=strlen(a+1),lenb=strlen(b+1);
j=0;
for(int i=2;i<=lenb;i++){
while(j&&b[i]!=b[j+1]) j=kmp[j];
if(b[i]==b[j+1]) j++;
kmp[i]=j;
}
j=0;
for(int i=1;i<=lena;i++){
while(j&&a[i]!=b[j+1]) j=kmp[j];
if(a[i]==b[j+1]) j++;
if(j==lenb){
printf("%d\n",i-lenb+1);
j=kmp[j];
}
}
for(int i=1;i<=lenb;i++) printf("%d ",kmp[i]);
return 0;
}
Luogu P3808 AC自動機(簡單版)
AC自動機,全稱"Aho_Corasick_Automaton"
,用於求解多個模式串匹配一個文字串的問題。聯想到之前的學過的演算法,發現有一個KMP演算法(看毛片)可以求解一個模式串匹配一個文字串的問題,那麼利用OI的基本思想之一的 x套x
思想,是不是可以考慮把兩個演算法結合起來,以達到一種神奇的效果呢?
我們發現,對於多個模式串,我們可以建一棵 \(trie\) 樹方便求出他們的公共字首,然後就可以在 \(trie\) 樹上面看毛片KMP了!
眾所周知,KMP最重要的是失配陣列 fail
,那我們怎麼在 \(trie\) 樹上求 fail
陣列呢?
很簡單,只用跑一邊 \(bfs\) 就好了,邏輯很明瞭看看程式碼就會了,不再贅述。注意優化!
void build(){
for(int i=0;i<26;i++) if(trie[0][i]) fail[trie[0][i]]=0,q.push(trie[0][i]);
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){
if(trie[u][i]) fail[trie[u][i]]=trie[fail[u]][i],q.push(trie[u][i]);
else trie[u][i]=trie[fail[u]][i];//優化,沒有這條邊的時候早晚要跳回去,還不如現在跳
}
}
}
有了 \(fail\) 陣列,那麼怎麼匹配呢?
也很簡單,我們只用遍歷,列舉一條當前文字串字元連向的邊,然後把這些點的 \(fail\) 全部跑一邊,然後答案直接加上結束標記 \(end\)。注意不要重複計算,用完的標記要丟掉!
int query(char *str){
int len=strlen(str),p=0,res=0;
for(int i=0;i<len;i++){
p=trie[p][str[i]-'a'];
for(int t=p;t&&end[t]!=-1;t=fail[t]) res+=end[t],end[t]=-1;
}
return res;
}
最後,注意常數優化,這題別用藍書上面的程式碼,一直卡常,吸氧吸中毒了都沒用。
程式碼:
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int n;
int trie[N][26],end[N],fail[N],tot;
queue<int> q;
void ins(char *str){
int len=strlen(str),p=0;
for(int i=0;i<len;i++){
int ch=str[i]-'a';
if(!trie[p][ch]) trie[p][ch]=++tot;
p=trie[p][ch];
}
end[p]++;
}
void build(){
for(int i=0;i<26;i++) if(trie[0][i]) fail[trie[0][i]]=0,q.push(trie[0][i]);
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<26;i++){
if(trie[u][i]) fail[trie[u][i]]=trie[fail[u]][i],q.push(trie[u][i]);
else trie[u][i]=trie[fail[u]][i];
}
}
}
int query(char *str){
int len=strlen(str),p=0,res=0;
for(int i=0;i<len;i++){
p=trie[p][str[i]-'a'];
for(int t=p;t&&end[t]!=-1;t=fail[t]) res+=end[t],end[t]=-1;
}
return res;
}
char tmp[N];
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>tmp,ins(tmp);
build();
cin>>tmp;
printf("%d",query(tmp));
return 0;
}
Luogu P5410 擴充套件KMP(Z函式)
擴充套件看毛片。
其實我覺得有點像馬拉車思想和KMP的結合,但這三個演算法都是利用減少冗餘重複計算來達到線性的。
相較於KMP演算法,exKMP演算法求的是模式串與文字串的字尾的LCP(最長公共字首)
我們令 extend
陣列為文字串 \(S\) 的字尾與模式串 \(T\) 的LCP,z
陣列為模式串 \(T\) 的字尾與模式串 \(T\) 的LCP。
怎麼來求呢?先畫圖。
我們假設當前已經求出了 \(1 \sim i\) 的 extend
陣列和 z
陣列,在匹配過程中記最遠的匹配距離為 \(r\),即 \(r=max(i+extend[i]-1)\),能夠取到最大值 \(r\) 的 \(i\) 我們記為 \(l\)。
- 第一種情況,當 \(i+L\) 在 \(r\) 左邊時:
z[i-l+1]
代表著 \(T[i-l+2\sim m]\) 中與 \(T[1 \sim m]\) 的最長公共字首,我們設為 \(L\)。
所以 \(T[1\sim L]=T[i-l+2\sim i-l+2+L]\),圖中藍色與綠色部分長度字元都相等(下文簡稱“相等”)。
根據 \(l\) 和 \(r\) 的定義,可知 \(S[l\sim r]=T[1\sim r-l+1]\),所以 \(S[i+1\sim i+L]=T[i-l+2\sim i-l+2+L]\),圖中紅色與藍色部分相等。
綜上圖中紅綠藍三個部分都相等
因為 \(S[i\sim n]\) 與 \(T[1\sim m]\) 的最長公共字首為 extend[i]
,所以 extend[i]=L
。
- 第二種情況,當 \(i+L\) 在 \(r\) 右邊時:
我們令 extend[i]=r-i+1
靠暴力跑出剩下的答案。
那麼從哪裡開始跑暴力呢?
就從當前的 \(i\) 開始跑,與模式串一一比對。
最後別忘了更新一下 \(l\) 和 \(r\)。
對於 \(z\) 陣列就是重複一遍這個演算法的事情了。
因為每個點最多跑一次,所以做一遍的複雜度是線性的。
對 z
和 extend
分別做了一遍,所以總的時間複雜度為\(\Theta(n+m)\)。
程式碼:
#include<bits/stdc++.h>
using namespace std;
const int N=2e7+5;
char s[N],t[N];
int lens,lent;
int z[N],extend[N];
void doZ(){
z[1]=lent;
for(int i=2,l=0,r=0;i<=lent;i++){
if(i<=r) z[i]=min(z[i-l+1],r-i+1);
while(i+z[i]<=lent&&t[i+z[i]]==t[z[i]+1]) ++z[i];
if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}
}
void exkmp(){
doZ();
for(int i=1,l=0,r=0;i<=lens;i++){
if(i<=r) extend[i]=min(z[i-l+1],r-i+1);
while(i+extend[i]<=lens&&s[i+extend[i]]==t[extend[i]+1]) ++extend[i];
if(i+extend[i]-1>r) l=i,r=i+extend[i]-1;
}
}
int main(){
scanf("%s%s",s+1,t+1);
lens=strlen(s+1);lent=strlen(t+1);
exkmp();
long long ans1=0,ans2=0;
for(int i=1;i<=lent;i++) ans1^=1ll*i*(z[i]+1);
for(int i=1;i<=lens;i++) ans2^=1ll*i*(extend[i]+1);
printf("%lld\n%lld",ans1,ans2);
return 0;
}
還有一點就是為什麼跑暴力不分情況,寫在迴圈外面,因為對於上述的情況一,不可能有 \(T[L+1]=S[i+L+1]\) 了。因為如果存在 \(T[L+1]=S[i+L+1]\),他們的最長公共字首就是 \(L+1\) 了,\(L\) 就不是最長公共字首了,就不滿足 \(L\) 的定義了。所以只會在情況二的時候跑暴力,這也是演算法的巧妙精簡美啊!