前缀函数与 KMP 算法
文本串 \(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]\)。
如:
(来自 OI-Wiki。)
此时 \(\pi_{i-1}=3\),我们希望 \(s[3]=s[i]\),这样就可以得到 \(\pi_i=\pi_{i-1}+1\)。
但是,如果 \(s[\pi_{i-1}]\ne s[i]\),那怎么办?我们将这种情况称为“失配”。
失配时,我们依然想让 border 尽可能地长,所以我们希望找到一个严格次长 border,我们将原 border 记为 \(b\),严格次长 border 记为 \(b'\)。
- 首先 \(|b|>|b'|\)(所以是“真前后缀”)。
- 然后又由于 \(b'\) 和 \(b\) 均是 \(s[1\dots i-1]\) 的公共真前后缀,故 \(b'\) 是 \(b\) 的公共真前后缀(可以从上图看出)。注意,\(b\) 和 \(b'\) 并不表示长度,而是字符串,左右两对字符串 \(b\) 和 \(b'\) 是分别相等的。左边可以看出 \(b'\) 是 \(b\) 的前缀,右边可以看出 \(b'\) 是 \(b\) 的后缀。
- 因为我们规定是严格次长 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\gets b',b'\gets 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 协议之条款下提供,附加条款亦可能应用。