1. 程式人生 > 其它 >KMP的一些好題

KMP的一些好題

KMP 練習題

在競賽中 KMP 已經考的比較少了,然而習題還是要做的。

KMP 的練習題目一般是圍繞著 \(next\) 陣列和 \(f\) 陣列的不同理解出發的,具體請看例題。

T1 [BOI2009]Radio Transmission 無線傳輸

題目連結:Link

題目描述:

給定一個字串 \(A\)\(A\) 是由另一個字串 \(B\) 不斷迴圈拼接而成的(可能不是整數個 \(B\)),求 \(B\) 可能長度的最小值。

Solution:

這道題目不難,但是如果自己獨立思考想出解法,可以極大提升對 KMP 演算法 \(next\) 陣列的理解。

首先,假設這個字串最短的長度為 \(k\)

,那麼 \(A_{k+1}\rightarrow A_{k+k}\) (假設 \(A\) 長度 \(N\)\(N>2k\))一定等於 \(A_1\rightarrow A_k\) 。如果對 \(A\) 進行自我匹配,求出 \(next\) 陣列,則 \(next_{k+1}\rightarrow next_{N}\) 應該成首項為 1 ,公差為 1 的等差數列,因為第一個 \(B\) 串一定可以和第二個、第三個、第 \(n\)\(B\) 串完全匹配,如下表:

         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 好題。