KMP的一些好題
KMP 練習題
在競賽中 KMP 已經考的比較少了,然而習題還是要做的。
KMP 的練習題目一般是圍繞著 \(next\) 陣列和 \(f\) 陣列的不同理解出發的,具體請看例題。
T1 [BOI2009]Radio Transmission 無線傳輸
題目連結:Link
題目描述:
給定一個字串 \(A\) ,\(A\) 是由另一個字串 \(B\) 不斷迴圈拼接而成的(可能不是整數個 \(B\)),求 \(B\) 可能長度的最小值。
Solution:
這道題目不難,但是如果自己獨立思考想出解法,可以極大提升對 KMP 演算法 \(next\) 陣列的理解。
首先,假設這個字串最短的長度為 \(k\)
1 2 3 4 5 6 7 8 9... a a b a a a b a a... next 0 1 0 1 1 2 3 4 5...
\(next_5\) 是這個等差數列的第一項,那麼這個等差數列的最後一項為 \(next_8=5\) ,根據等差數列的公式得知項數 \(n\) 等於 最後一項 \(next_8\) 的值。又因為答案為這個等差數列第一項的前一項,所以最後答案就是 \(n-next_n\) 。
Code:
#include<cstdio> #include<cstring> #include<algorithm> #include<iostream> //using namespace std; const int maxn=1000005; int n; char str[maxn]; int next[maxn]; void kmp(){ next[1]=0; for(int i=2,j=0;i<=n;++i){ while(j>0 && str[i]!=str[j+1]) j=next[j]; if(str[i]==str[j+1]) j++; next[i]=j; } printf("%d\n",n-next[n]); } signed main(){ scanf("%d",&n); scanf("%s",str+1); kmp(); return 0; }
T2 UVA1328 Period
題目連結:Link
題目描述:
洛谷上給出的翻譯比較簡略,這裡重新翻譯一下題面。(原版英文題面)
給定字串長度 \(N(N\leq 1e6)\) ,然後給出一個長度為 \(N\) 的字串 \(A\) 。
如果對於 \(A\) 的前 \(i\) 個字元是嚴格迴圈同構的(由一個字串 \(B\) 首尾相連 \(n(n\geq 2)\) 次構成),輸出 \(i,n\) 。
有多組資料,如果 \(N=0\) 表示輸入結束。
Solution:
本題和 T1 有異曲同工之處,類似 T1 的思路,先對字串跑 KMP 求出 \(next\) 陣列。
利用上面的結論,如果一個字串從第一項開始迴圈同構,那麼它的最小迴圈節為 \(i-next_i\) 。
假設字串 \(A\) 的某個字首長度為 \(i\) ,已經求出它的最小迴圈節,則這個迴圈節出現的次數應該是 \(\frac{i}{i-next_i}\) 。
根據題目要求,答案應該滿足 \(\frac{i}{i-next_i}\geq 2,i\%\frac{i}{i-next_i}=0\) ,於是掃描 \(next\) 陣列,輸出答案即可。
Code:
#include<cstdio>
#include<cstring>
#include<iostream>
#include<algorithm>
//using namespace std;
const int maxn=1000005;
char str[maxn];
int next[maxn];
int n=1,cas;
void kmp(){
next[1]=0;
for(int i=2,j=0;i<=n;++i){
while(j>0 && str[i]!=str[j+1]) j=next[j];
if(str[i]==str[j+1]) j++;
next[i]=j;
}
printf("Test case #%d\n",cas);
for(int i=2;i<=n;++i){//掃描、判斷答案是否合法
if(next[i]*2>=i && i%(i-next[i])==0)
printf("%d %d\n",i,i/(i-next[i]));
}
puts("");
}
signed main(){
while(n){
scanf("%d",&n);
if(n){
cas++;
scanf("%s",str+1);
kmp();
}
}
return 0;
}
T3 [USACO15FEB]Censoring S
題目連結:Link
題目描述:
給定兩個字串 \(A\) ,\(B\) 要求從 \(A\) 中不斷刪除找到的第一個 \(B\) ,然後拼接剩下的串,輸出刪除後的串。
注意:刪除之後,兩端的 \(A\) 串剩餘部分可能會再拼成一個 \(B\) 串
Solution:
設 \(A\) 長度為 \(n\) ,\(B\) 的長度為 \(m\) 。
顯然這題是字串匹配題,要從 \(A\) 中匹配 \(B\) ,可以使用 KMP 查詢 \(B\) 的位置。
這裡刪除操作不影響 \(B\),所以先跑 KMP 的第一階段,求出 \(B\) 的 \(next\) 陣列。
然後,還是一位一位地求解 \(f\) 陣列,如果還沒和 \(B\) 串匹配上,就把這一位的 \(A\) 加入答案串,答案串長度++。
如果匹配上了,把答案串刪掉 \(m\) 位,然後從 \(m\) 位之前重新開始匹配。
注意這個操作需要更新 \(j\) 的值為上一個答案的 \(f\) 值,這樣相當於忽略了中間的 \(m\) 個字元繼續進行匹配操作。
由於陣列不太好操作,這裡我使用了 STL stack 來實現記錄答案和更新 \(j\) 的操作。
Code:
#include<cstdio>
#include<cstring>
#include<iostream>
#include<algorithm>
#include<stack>
//using namespace std;
const int maxn=1000005;
char A[maxn],B[maxn];//A原串,B模式串
int lena,lenb;
int next[maxn],f[maxn];
std::stack<char>stk;
std::stack<int>s;
char ans[maxn];
void kmp(){
next[1]=0;
for(int i=2,j=0;i<=lenb;++i){
while(j>0 && B[i]!=B[j+1]) j=next[j];
if(B[i]==B[j+1]) j++;
next[i]=j;
}
s.push(0);//這是為了防止把元素全彈出去後 j=s.top() 操作導致 RE
for(int i=1,j=0;i<=lena;++i){
while(j>0 && A[i]!=B[j+1]) j=next[j];
if(A[i]==B[j+1]) j++;
f[i]=j;
stk.push(A[i]);
s.push(f[i]);//表示這一位答案的 f 值
//其實為了減小常數應該開結構體存,但是因為懶就用兩個同步棧代替了
if(f[i]==lenb){
for(int k=1;k<=lenb;++k)
stk.pop(),s.pop();
j=s.top();//彈完後 j 更新為棧頂元素的 f ,也就是 m 個元素之前的那個 f 值
}
}
int num=stk.size();
for(int i=1;i<=num;i++){
ans[i]=stk.top();
stk.pop();
}
for(int i=num;i>=1;--i)
putchar(ans[i]);
}
signed main(){
scanf("%s%s",A+1,B+1);
lena=std::strlen(A+1);
lenb=std::strlen(B+1);
kmp();
return 0;
}
T4 [POI2006]OKR-Periods of Words
題目連結:Link
題目描述:
給定一個長度為 \(N\) 的字串 \(A\) ,若一個非 \(A\) 本身的串 \(Q\) 是 \(A\) 的字首,且 \(A\) 是 \(Q+Q\) 的字首,則稱 \(Q\) 是 \(A\) 的週期( \(Q\) 可以為空),求 \(A\) 的所有字首的最大週期串長度之和。
Solution:
對於長度為 \(i\) 的串,要滿足的條件 \(A_1\rightarrow A_i\) 是 \(Q+Q\) 的字首。想要滿足這個條件,\(next_i\) 必須大於 0 。( \(next_i\) 的另一個定義是:模式串長度為 \(i\) 的字首,其字首和字尾相等的最長長度。這個性質可以簡單地根據 KMP 的原理得出,這裡不再贅述。)
假設藍色為模式串長度為 \(i\) 的字首,紅色為 \(next_i\) ,如果從前後綴第一個不相等的位置截為 \(Q\) (綠色),那麼把 \(Q'\) 拼在後面正好可以讓紅色的部分重合,而且使得 \(Q\) 的長度最長。
另外還有一個條件,顯然 \(Q\) 的長度至少為 \(\frac i 2\) 。
既然要使得 \(Q\) 的長度最長,那麼勢必要讓 \(next_i\) (紅色)部分最短。不巧的是,KMP 演算法求出的是最大可能的 \(next_i\) 。記得求 \(next\) 陣列時的證明嗎?\(next_i\) 所有的可能值是 next[i],next[next[i]],next[nxet[next[i]]]...
我們可以遞迴訪問 \(next\) 陣列來得到它的最小值 \(p\),此時 \(Q\) 長度最長,為 \(i-p\) 。
但是有這樣一個問題,如果這個字串全都是同一個字元(比如 'a' ),那麼我們每次遞迴只能向前跳一個字元,這樣導致每找一次長度為 \(i\) 的字首的“週期”就要遞迴 \(i-1\) 次,這樣總複雜度退化到 \(O(n^2)\) 。
要解決這個問題,就得防止遞迴次數退化。我聯想到了並查集的路徑壓縮方法,並查集的做法是:在查詢到 \(x\) 的父親時順便把 \(x\) 及其子樹直接掛到它父親的下面。那麼我們為什麼不在查詢到 \(next_i\) 時直接把它修改為最小值呢?
優化以後,時間複雜度 \(O(n)\) 。
Code:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<iostream>
//using namespace std;
const int maxn=1000005;
char str[maxn];
int n;
long long ans;//第一次交不開long long見了祖宗
int next[maxn];
void kmp(){
next[1]=0;
for(int i=2,j=0;i<=n;++i){
while(j>0 && str[i]!=str[j+1]) j=next[j];
if(str[i]==str[j+1]) j++;
next[i]=j;
}
for(int i=2,j=2;i<=n;++i,j=i){
while(next[j]) j=next[j];//找最小的next[i]的可能值
if(next[i]) next[i]=j;//類似路徑壓縮
ans+=i-j;//更新答案
}
printf("%lld\n",ans);
}
signed main(){
//freopen("P3435_13.in","r",stdin);
scanf("%d",&n);
scanf("%s",str+1);
kmp();
return 0;
}
T5 [NOI2014] 動物園
這個題我想了一下午,然而並沒有想出怎麼優化 \(O(Tn^2)\) 的複雜度到 \(O(Tn)\) ,於是口胡了一個 \(O(Tnlogn)\) 的做法。
吸氧 之後以最慢點 537ms 草過去了(\(nlogn\) 還是快啊)。
題目連結:Link
題目描述:
給定一個長度為 \(N\) 的字串 \(A\) ,求出 \(num\) 陣列。其中 \(num_i\) 表示 \(A\) 的長度為 \(i\) 的字首 \(P\),滿足既是 \(P\) 的字首又是 \(P\) 的字尾,且長度 \(k\leq \frac i 2\) 的子串 \(Q\) 的個數。
為了避免大量輸出,只要求輸出:\(\prod\limits_{i=1}^Nnum_i+1 \bmod 1e9+7\) 的值。
Solution:
在敘述解法之前,想問讀者一個問題:
- 關於 \(next\) 陣列的遞迴訪問,以及前面 T4 中提到的類似並查集路徑壓縮優化,你有沒有想到什麼?
顯然,\(next\) 陣列是一棵以 0 為根的帶權樹。想想吧,不管我們從哪裡開始遞迴訪問,總能訪問到 \(next_i=0\) 。
除此之外,\(next\) 樹還有一個十分重要的性質:對於每棵非空子樹,根節點權值一定小於子節點權值——這是 \(next\) 陣列的定義導致的。根據 \(next\) 的定義,不難發現:\(next_i<i\) ,故每個節點的父節點權值一定小於子節點權值。
回到本題,先 KMP 預處理出 \(next\) 陣列。很顯然,對於每一個 \(i\) ,它的 \(next_i\) 就表示可能的 \(Q\) 的長度(參考 T4 中給出的 \(next\) 陣列的另一個定義)。我們不斷的遞迴訪問其 \(next\) ,如果這個 \(next\) 值小於 \(\frac i 2\) ,那麼說明這個 \(next\) 代表一個合法的 \(Q\) 串,不斷遞迴操作,直到訪問到 0 為止,就找出了對於 \(i\) 的所有的合法的 \(Q\) 串。
但是,還是 T4 中的問題,這樣做會被全是相同字元的資料把複雜度卡到 \(O(n^2)\) 。
考慮優化:對於每個 \(i\) ,其 \(next\) 的訪問順序都是一條權值單調遞增的帶權鏈(第 \(i\) 個節點的權值是 \(next_i\))。這樣可以使用 ST 演算法在 \(logn\) 的時間內查詢權值小於 \(k\) 的第一個節點位置。利用這個性質可以查詢權值小於 \(\frac i 2\) 的第一個節點 \(v\),那麼 \(v\) 前面的節點權值必然小於 \(\frac i 2\) ,也就是說,\(\forall i\in[1,v],next_i=Q\) ,且這些 \(Q\) 都合法。要統計答案的話,再從 \(v\) 出發,倍增找出它前面有多少個節點,計入答案就行了。
另外,實際操作的時候要把這 \(n\) 條鏈建成一棵樹,不然開不下那麼大的空間,預處理也會超時的。
Code:
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
//using namespace std;
#define ll long long
#define mod 1000000007
const int maxn=1000005;
int N,n;
char str[maxn];
int next[maxn];
int f[20][maxn];
//f[i][j] 表示 j的2^i 祖先,量小的做第一維,空間連續性更強,會更快!
//2^20=1.4e6 卡滿常數,否則可能TLE
ll sum=1;
void kmp(){
next[1]=0;
for(int i=2,j=0;i<=n;++i){
while(j && str[i]!=str[j+1]) j=next[j];
if(str[i]==str[j+1]) j++;
next[i]=j;
f[0][i]=j;
}
for(int i=1;i<20;++i)
for(int j=1;j<=n;++j)
f[i][j]=f[i-1][f[i-1][j]];
sum=1;
for(int i=2;i<=n;++i){
int p=i;
for(int j=19;j>=0;--j)
if(f[j][p]*2>i) //現在的 p 不合法
p=f[j][p];//跳到 p 的 2^j 祖先
ll tot=0;
for(int j=19;j>=0;--j)
if(f[j][p])
tot+=1<<j,p=f[j][p];//這一蹦,蹦過了 2^j 個節點,也就是有 2^j 個合法方案
sum=sum*(tot+1)%mod;//統計答案
}
printf("%lld\n",sum);
}
signed main(){
scanf("%d",&N);
while(N--){
scanf("%s",str+1);
n=std::strlen(str+1);
kmp();
}
return 0;
}
寫在最後
特別鳴謝:
李煜東在《演算法競賽進階指南》中和 二gou子(徐隊) 提供的 KMP 好題。