「筆記」KMP 算法


寫在前面

不是我吹,我是真的剛學會 KMP 啊(

引入

給定字符串 \(s_1,s_2\left(|s_2|\le |s_1|\right)\),求 \(s_2\)\(s_1\) 中的所有出現位置。
\(1\le |s_2|\le |s_1|\le 5\times 10^6\)
1S,128MB。

朴素的想法是枚舉 \(s_2\)\(s_1\) 中的開頭位置,暴力枚舉判斷是否匹配。如果失配,則拋棄當前已匹配的部分,到下一位置再從開頭匹配。時間復雜度為 \(O(|s_1||s_2|)\)
而 KMP 算法可以在 \(O(|s_1| + |s_2|)\) 的時空復雜度內解決上述問題,且常數較小。

定義

\(s[i:j]\):字符串 \(s\) 的子串 \(s_i\cdots s_j\)
真前/后綴:字符串 \(s\) 的真前綴定義為滿足不等於它本身的 \(s\) 的前綴。同理就有了真后綴的定義:滿足不等於它本身的 \(s\) 的后綴。

\(\operatorname{border}\):字符串 \(s\)\(\operatorname{border}\) 定義為,滿足既是 \(s\) 的真前綴,又是 \(s\) 的真后綴的最長的字符串 \(t\)
\(\texttt{aabaa}\)\(\operatorname{border}\)\(\texttt{aa}\)

\(\operatorname{fail}\):字符串 \(s\)\(\operatorname{fail}\) 是一個長度為 \(|s|\) 的整數數組,它又被稱為 \(s\)失配指針\(\operatorname{fail}_i\) 表示前綴 \(s[1:i]\)\(\operatorname{border}\) 的長度,即:

\[\operatorname{fail}_i = \max{\{ j \}},\, (j<i)\land(s[1,j] = s[i-j+1, i]) \]

特別的,若不存在這樣的 \(j\),則 \(\operatorname{fail}_i = 0\)。如 \(\texttt{aabaa}\)\(\operatorname{fail} = \{0, 1, 0 , 1, 2\}\)

原理

在朴素算法中,如果在某一位上失配,則會拋棄當前已匹配的部分,跳到下一個位置再從開頭進行匹配。
而 KMP 利用了當前已匹配的部分,使得在下一個位置時不必從開頭進行匹配,從而對朴素算法進行了加速。
舉個例子,如下圖所示:

看貓片

失配指針

\(s\) 的失配指針 \(\operatorname{fail}\) 可以通過在 \(s\) 上按上述思想匹配自身求得。下述算法中枚舉到第 \(i\) 位時即可求得 \(\operatorname{fail}_i\)
首先顯然有 \(\operatorname{fail}_1 = 0\)。設枚舉到第 \(i\) 位,考慮已知 \(\operatorname{fail}_1\sim \operatorname{fail}_{i-1}\) 的情況下如何求得 \(\operatorname{fail}_i\)
設當前匹配部分為 \(s[i-l,i-1]\),即有 \(s[i-l,i-1] = s[1,l]\)。則顯然有 \(\operatorname{fail}_{i-1} = l\)。接下來考察 \(s_i = s_{l+1}\) 是否成立。

若成立,則有 \(s[i-l,i] = s[1,l + 1]\),得 \(\operatorname{fail}_i = l + 1\)
若不成立,一種朴素的想法是減小已匹配長度 \(l\) 並暴力檢查,直到找到最大的一個 \(l'<l\),滿足 \(s[i-l',i-1] = s[1,l']\)\(s_{i}=s_{l'+1}\),此時 \(\operatorname{fail}_i = l'+1\)。考慮利用已匹配部分的 border 加速上述過程。

引理:滿足 \(l'<l\)\(s[i-l',i-1] = s[1,l']\)\(l'\) 的最大的 \(l'\)\(\operatorname{fail}_{l}\)

證明:考慮反證法,設存在 \(j\) 滿足 \(\operatorname{fail}_{l}<j<l\) 是最大的滿足條件的 \(l'\)
根據條件,有 \(s[i-j,i-1] = s[1,j]\),又 \(j<l\),則 \(s[i-j,i-1]\)\(s[i-l,i-1]\) 的一段后綴, \(s[1,j]\)\(s[1,l]\) 的一段前綴。則有 \(s[1,j] = s[l - j, l]\) 成立。
\(j > \operatorname{fail}_{l}\),根據 border 的定義,則 \(\operatorname{fail}_{l}\) 應為 \(j\),這與已知矛盾,反證原結論成立。

直觀的理解如下所示:

\[\large \overbrace{\underbrace{s_1 ~ s_2}_{\operatorname{fail}_{l}} ~ s_3 ~ s_4}^{l =\operatorname{fail}_{i-1}} ~ \cdots ~ \overbrace{s_{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}_{\operatorname{fail}_{l}}}^{l = \operatorname{fail}_{i-1}} ~ s_{i+1} \]

\(l'=\operatorname{fail}_{l}\) 仍不滿足 \(s_{i}=s_{l'+1}\),則一直令 \(l' = \operatorname{fail}_{l'}\),直到滿足條件或 \(l' = 0\)

模擬上述過程,可以得到下述代碼:

fail[1] = 0;
for (int i = 2, j = 0; i <= n2; ++ i) { //j 為匹配長度
  while (j > 0 && s2[i] != s2[j + 1]) j = fail[j]; //找到滿足條件的 border
  if (s2[i] == s2[j + 1]) ++ j; //匹配成功
  fail[i] = j;
}

匹配

按照上述過程實現即可,代碼如下:

for (int i = 1, j = 0; i <= n1; ++ i) {  //j 為匹配長度
  while (j > 0 && (j == n2 || s1[i] != s2[j + 1])) j = fail[j]; //找到滿足條件的 border,注意當整個串匹配成功的特判。
  if (s1[i] == s2[j + 1]) ++ j; //第 j 位匹配成功
  if (j == n2) printf("%d\n", i - n2 + 1); //整個串匹配成功
}

完整代碼

P3375 【模板】KMP 字符串匹配

//知識點:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
char s1[kN], s2[kN];
int n1, n2;
int fail[kN];
//=============================================================
inline int read() {
  int f = 1, w = 0;
  char ch = getchar();
  for (; !isdigit(ch); ch = getchar())
    if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
void Chkmax(int &fir, int sec) {
  if (sec > fir) fir = sec;
}
void Chkmin(int &fir, int sec) {
  if (sec < fir) fir = sec;
}
//=============================================================
int main() {
  scanf("%s", s1 + 1);
  scanf("%s", s2 + 1);
  n1 = strlen(s1 + 1), n2 = strlen(s2 + 1);

  fail[1] = 0;
  for (int i = 2, j = 0; i <= n2; ++ i) {
    while (j > 0 && s2[i] != s2[j + 1]) j = fail[j];
    if (s2[i] == s2[j + 1]) ++ j;
    fail[i] = j;
  }
  for (int i = 1, j = 0; i <= n1; ++ i) {
    while (j > 0 && (j == n2 || s1[i] != s2[j + 1])) j = fail[j];
    if (s1[i] == s2[j + 1]) ++ j;
    if (j == n2) printf("%d\n", i - n2 + 1);
  }
  for (int i = 1; i <= n2; ++ i) printf("%d ", fail[i]);
  return 0;
}

復雜度

求失配指針與匹配兩部分的代碼類似,僅解釋其中一部分。

for (int i = 2, j = 0; i <= n2; ++ i) {
  while (j > 0 && s2[i] != s2[j + 1]) j = fail[j];
  if (s2[i] == s2[j + 1]) ++ j;
  fail[i] = j;
}

代碼中僅有 while 的執行次數是不明確的。但可以發現,在 while\(j\) 每次至少減少 1,每層循環中 \(j\) 每次至多增加 1。
又時刻保證 \(j\ge 0\),則 \(j\) 的減少量不大於 \(j\) 的增加量,即 \(n_2\)。故 while 最多執行 \(n_2\) 次,則整個循環的復雜度為 \(O(n)\) 級別。

例題

CF126B Password

給定一字符串 \(s\),求一個字符串 \(t\),滿足 \(t\) 既是 \(s\) 的前綴,又是 \(s\) 的后綴,同時 \(t\) 還在 \(s\) 中間出現過(即不作為 \(s\) 的前后綴出現)。
\(1\le |s|\le 10^6\)
2S,256MB。

既是 \(s\) 的前綴,又是 \(s\) 的后綴的串可以通過枚舉 \(\operatorname{fail}_n\)\(\operatorname{fail}_{\operatorname{fail}_n},\cdots\) 獲得。
\(s\) 中間出現過的所有 \(s\) 的前綴為 \(s[1:\operatorname{fail}_2]\sim s[1:\operatorname{fail}_{n-1}]\),用桶判斷這兩部分有無重復元素即可。
代碼:A submission

P4391 [BOI2009]Radio Transmission 無線傳輸

給定一字符串 \(s_1\),已知它是由某個字符串 \(s_2\) 不斷自我連接形成的,即有:

\[s_1 = s_2 + s_2 + \cdots+s_2[1,|s_1|\bmod |s_2|] \]

求字符串 \(s_2\) 的最短長度。
\(1\le |s_1|\le s_2\)

考慮一個更簡單的問題,如何判斷 \(s_1\) 的一個前綴 \(i\) 是否為 \(s_1\) 的循環節?
考慮求 \(s_1\)\(\operatorname{fail}\),顯然當 \(i\mid |s_1|\)\(\operatorname{fail}_{|s_1|} = |s_1| - i\)\(i\) 為循環節。
正確性顯然,若該條件成立,則保證了 \(s[1:i] = s[i+1:2i], s[i+1:2i] = s[2i+1:3i],\cdots\) 如下所示:

\[\begin{aligned} s_1 = &\texttt{ababab}\\ \operatorname{border} = &\ \ \ \ \ \texttt{abab} \end{aligned}\]

發現呈現錯位相等的關系,對應的,則有 \(s[1:i] = s[|s_1| - i+1, |s_1|]\),可得 \(i\) 是一個循環節。


由上,可以得到兩種做法。

第一種是暴力枚舉前綴 \(i\),判斷 \(\operatorname{fail}_{n - (n\bmod i)}\) 是否等於 \(n - (n\bmod i) - i\),且 \(\operatorname{fail}_n\ge i\)
第一個條件保證了 \(i\)\(s_1[1:n - (n\bmod i)]\) 部分的循環節,第二個條件保證了剩下的部分是 \(i\) 的一個前綴。

第二種是直接輸出 \(n - \operatorname{fail}_n\)。原理如下所示:

\[\begin{aligned} s_1 = &\texttt{abbabbab}\\ \operatorname{border} = &\ \ \ \ \ \ \ \texttt{abbab} \end{aligned}\]

顯然可知最后的不完整部分是 \(n - \operatorname{fail}_n\) 的一個前綴。又保證了 \(\operatorname{fail}_n\) 是最長的既是 \(s_1\) 的前綴又是 \(s_1\) 的后綴的字符串,則 \(n-\operatorname{fail}_n\) 即為答案。

總復雜度均為 \(O(|s_1|)\) 級別。

//知識點:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
char s[kN];
int n, fail[kN];
//=============================================================
inline int read() {
  int f = 1, w = 0;
  char ch = getchar();
  for (; !isdigit(ch); ch = getchar())
    if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
//=============================================================
int main() {
  n = read();
  scanf("%s", s + 1);
  fail[1] = 0;
  for (int i = 2, j = 0; i <= n; ++ i) {
    while (j && s[i] != s[j + 1]) j = fail[j];
    if (s[i] == s[j + 1]) ++ j;
    fail[i] = j;
  }
  //Sol 2:
  printf("%d\n", n - fail[n]);
  return 0;

  //Sol 1:
  for (int i = 1; i <= n; ++ i) {
    int lth = n - (n % i);
    if (fail[lth] == lth - i && fail[n] >= n % i) {
      printf("%d\n", i);
      return 0;
    }
  }
  return 0;
}

「NOI2014」動物園

\(n\) 組數據,每次給定一字符串 \(s\)
定義 \(\operatorname{num}_i\) 表示是 \(s[1:i]\) 的前后綴,且長度不大於 \(\left\lfloor\frac{i}{2}\right\rfloor\) 的字符串的個數。
求:

\[\prod_{i=1}^{n}\left(\operatorname{num}_i + 1\right)\pmod {10^9 + 7} \]

\(1\le n\le 5\)\(1\le |s|\le 10^6\)
1S,512MB。

做法是自己 YY 的,效率被爆踩但是能過(
\(\mathbf{B}(i)\) 表示滿足既是前綴 \(s[1:i]\) 的真前綴,又是其真后綴的字符串組成的集合。

先不考慮長度不大於 \(\left\lfloor\frac{i}{2}\right\rfloor\) 這一限制,對於前綴 \(s[1:i]\),顯然 \(\operatorname{num}_i\) 的值為 \(|\mathbf{B}(i)|\)。則顯然有 \(\operatorname{num}_{i} = \operatorname{num}_{\operatorname{fail}_i} + 1\),表示在 \(\operatorname{num}_i\) 的基礎上計入 \(s[1:i]\)\(\operatorname{border}\) 的貢獻。\(\operatorname{num}\) 可在 KMP 算法中順便求得。
再考慮限制,若前綴 \(s[1:i]\)\(\operatorname{border}\) 的長度大於 \(\left\lfloor\frac{i}{2}\right\rfloor\),則需要不斷跳 \(\operatorname{fail}\),跳到第一個滿足長度合法的位置 \(j\in \mathbf{B}(i)\),再統計其貢獻 \(\operatorname{num}_j\)
暴跳實現可以獲得 50pts 的好成績。

發現跳 \(\operatorname{fail}\) 過程中對應的字符串長度會縮短(廢話),考慮倒序枚舉各位置 \(i\),使得 \(\left\lfloor\frac{i}{2}\right\rfloor\) 也呈現遞減的狀態。
考慮暴跳過程,顯然是由於某些 \(\operatorname{fail}\) 的轉移被重復統計,導致暴跳效率較低。考慮並查集的思路,將重復的轉移進行路徑壓縮。
\(\operatorname{pos}_{i}\) 表示前綴 \(s[1:i]\) 在跳 \(\operatorname{fail}\) 之后對應的最大的第一個滿足長度合法的 \(\mathbf{B}\) 中的元素,初始值為 \(\operatorname{pos}_i = i\)。在暴力跳 \(\operatorname{fail}\) 時,更新沿途遍歷到的 \(\operatorname{pos}\) 即可。
這個路徑壓縮的復雜度我並不會證,但是感覺跑的還蠻快的= =

//知識點:KMP
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
const int mod = 1e9 + 7;
//=============================================================
int n, ans, next[kN], num[kN], pos[kN];
char s[kN];
//=============================================================
inline int read() {
  int f = 1, w = 0;
  char ch = getchar();
  for (; !isdigit(ch); ch = getchar())
    if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
void Init() {
  ans = 1;
  scanf("%s", s + 1);
  n = strlen(s + 1);
}
void KMP() {
  for (int i = 2, j = 0; i <= n; ++ i) {
    while (j > 0 && s[i] != s[j + 1]) j = next[j];
    if (s[i] == s[j + 1]) ++ j;
    next[i] = j;
    if (! j) continue ;
    pos[j] = j; //初始化
    num[j] = 1ll * (num[next[j]] + 1ll) % mod;
  }
}
int Find(int x_, int lth_) {
  if (pos[x_] <= lth_ / 2) return pos[x_];
  return pos[x_] = Find(next[pos[x_]], lth_); //路徑壓縮
}
//=============================================================
int main() {
  int t = read();
  while (t --) {
    Init(); KMP();
    for (int i = n; i >= 2; -- i) {
      pos[next[i]] = Find(next[i], i); //找到貢獻位置
      if (! pos[next[i]]) continue ; //特判無貢獻情況
      ans = 1ll * ans * (num[pos[next[i]]] + 1) % mod;
    }
    printf("%d\n", ans); 
  }
  return 0;
}

寫在最后

參考資料:

《算法競賽進階指南》-李煜東
OI-wiki


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM