前缀函数与 KMP 算法

发布时间 2023-08-30 15:18:48作者: Po7ed

文本串 \(t\),模式串 \(s\)\(m=|t|,n=|s|\)。(\(|s|\) 表示 \(s\) 的长度。)
\(s[i\dots j]\) 表示 \(s\)\(i\)\(j\) 的子串。
默认字符串下标从 \(0\) 开始。

引言

有时我们希望在文本串 \(t\) 中查找模式串 \(s\)。比如你按下 Ctrl+F 时浏览器就会在页面中查找你输入的字符串。
一种方法是暴力查找,时间复杂度 \(O(nm)\)
有没有更快的方法呢?

前缀函数

定义:

  • \(s\) 的 border:\(s\) 的最长公共真前后缀。例如 \(\texttt{abccdabc}\) 的 border 为 \(\texttt{abc}\)
  • \(s\) 前缀函数 \(\pi_i\)\(s[1\dots i]\) 的 border 长度。规定 \(\pi_0=0\)
    对于 \(s=\texttt{abcabcd}\)\(\pi_0=0,\pi_1=0,\pi_2=0,\pi_3=1,\pi_4=2,\pi_5=3,\pi_6=0\)

那么前缀函数 \(\pi\) 怎么求呢?
观察可以发现 \(\pi_i\) 至多增加 \(1\),也就是说如果考虑现在算 \(\pi_i\),那么我们希望最好 \(\pi_i=\pi_{i-1}+1\),此时 \(s[\pi_{i-1}]=s[i]\)
如:

\[\underbrace{\overbrace{s_0 ~ s_1 ~ s_2}^{\pi_{i-1} = 3} ~ s_3}_{\pi_i = 4} ~ \dots ~ \underbrace{\overbrace{s_{i-3} ~ s_{i-2} ~ s_{i-1}}^{\pi_{i-1}=3} ~ s_{i}}_{\pi_i = 4} \]

(来自 OI-Wiki。)
此时 \(\pi_i=3\),我们希望 \(s[3]=s[i]\),这样就可以得到 \(\pi_i=\pi_{i-1}+1\)
但是,如果 \(s[\pi_{i-1}]\ne s[i]\),那怎么办?我们将这种情况称为“失配”。
失配时,我们依然想让 border 尽可能地长,所以我们希望找到一个严格次长 border,我们将原 border 记为 \(b\),次长 border 记为 \(b'\)

\[\overbrace{\underbrace{s_0 ~ s_1}_{b'} ~ s_2 ~ s_3}^{b} ~ \dots ~ \overbrace{s_{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}_{b'}}^{b} ~ s_{i+1} \]

  1. 首先 \(|b|>|b'|\)(所以是“真前后缀”)。
  2. 然后又由于 \(b'\)\(b\) 均是 \(s[1\dots i]\) 的公共真前后缀,故 \(b'\)\(b\) 的公共真前后缀(可以从上图看出)。注意,\(b\)\(b'\) 并不表示长度,而是字符串,左右两对字符串 \(b\)\(b'\) 是相等的。左边可以看出 \(b'\)\(b\) 的前缀,右边可以看出 \(b'\)\(b\) 的后缀。
  3. 因为我们规定是严格次长 border,所以除了 \(b\) 没有其他 border 比 \(b'\) 长,又因为 \(b'\)\(b\) 的公共真前后缀,所以 \(b'\)\(b\) 的 border

所以我们找到了一个递归关系:一个 border 的严格次长 border 为它自己的 border。(好绕啊!)
前缀函数的求法就出来了:

  • 先看是否满足 \(s[\pi_{i-1}]=s[i]\)
    • 如果满足,那么 \(\pi_i=\pi_{i-1}+1\)
    • 否则就是失配,检查严格次长 border 能否匹配:\(s[|b'|]=s[i]\)
      • 若还是不能,继续上一个操作,使 \(b=b',b'=b~的~\text{border}\)。(检查严格次长 border 的严格次长 border,或者说检查严格次次长 border)。直到无解或匹配上。
      • 如果匹配,\(\pi_{i}=|b'|\)
      • 如果无解,\(\pi_{i}=0\)

代码

所以最终我们可以构建一个不需要进行任何字符串比较,并且只进行 \(O(n)\) 次操作的算法。
而且该算法的实现出人意料的短且直观:

vector<int> prefix_function(string s) {
  int n = (int)s.length();
  vector<int> pi(n);
  for (int i = 1; i < n; i++) {
    int j = pi[i - 1];
    while (j > 0 && s[i] != s[j]) j = pi[j - 1];
    if (s[i] == s[j]) j++;
    pi[i] = j;
  }
  return pi;
}

KMP

这时候我相信弹幕里满屏都是“RNM退钱!前缀函数讲一大堆,一直不讲 KMP 什么意思?!”
你先别急,你如果能真的理解前缀函数,那 KMP 就是小菜一碟了。

在文本串 \(t\) 中查找模式串 \(s\) 时,我们令 \(u=s+'\!\!\#'+t\),其中 \(+\) 为拼接,\('\!\#'\) 为任意 \(s\)\(t\) 中不包含的字符。对 \(u\) 求前缀函数,若 \(\pi_i=n\),那么 \(u[i-n+1\dots i]=s\)

细想为什么:若 \(\pi_i=n\),那么 \(s[1\dots i]\) 的最长公共真前后缀就是 \(s\),也就是说 \(t\) 中也出现了一个完整的 \(s\),我们就成功在 \(t\) 中查找到 \(s\) 啦!

vector<int> find_occurrences(string text, string pattern) {
  string cur = pattern + '#' + text;
  int sz1 = text.size(), sz2 = pattern.size();
  vector<int> v;
  vector<int> lps = prefix_function(cur);
  for (int i = sz2 + 1; i <= sz1 + sz2; i++) {
    if (lps[i] == sz2)
      v.push_back(i - 2 * sz2);
  }
  return v;
}

参考

  • OI Wiki
    代码均来自 OI Wiki,其页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用。