前缀函数与 KMP 算法
字符串前缀和后缀定义
关于字符串前缀、真前缀,后缀、真后缀的定义详见 字符串基础
前缀函数
定义
给定一个长度为
- 如果子串
有一对相等的真前缀与真后缀: 和 ,那么 就是这个相等的真前缀(或者真后缀,因为它们相等)的长度,也就是 ; - 如果不止有一对相等的,那么
就是其中最长的那一对的长度; - 如果没有相等的,那么
。
简单来说
用数学语言描述如下:
特别地,规定
过程
举例来说,对于字符串 abcabcd
,
a
没有真前缀和真后缀,根据规定为 0
ab
无相等的真前缀和真后缀
abc
无相等的真前缀和真后缀
abca
只有一对相等的真前缀和真后缀:a
,长度为 1
abcab
相等的真前缀和真后缀只有 ab
,长度为 2
abcabc
相等的真前缀和真后缀只有 abc
,长度为 3
abcabcd
无相等的真前缀和真后缀
同理可以计算字符串 aabaaab
的前缀函数为
计算前缀函数的朴素算法
过程
一个直接按照定义计算前缀函数的算法流程:
- 在一个循环中以
的顺序计算前缀函数 的值( 被赋值为 )。 - 为了计算当前的前缀函数值
,我们令变量 从最大的真前缀长度 开始尝试。 - 如果当前长度下真前缀和真后缀相等,则此时长度为
,否则令 j 自减 1,继续匹配,直到 。 - 如果
并且仍没有任何一次匹配,则置 并移至下一个下标 。
实现
具体实现如下:
显见该算法的时间复杂度为
计算前缀函数的高效算法
第一个优化
第一个重要的观察是 相邻的前缀函数值至多增加
参照下图所示,只需如此考虑:当取一个尽可能大的
所以当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。
实现
此时的改进的算法为:
在这个初步改进的算法中,在计算每个 n-1
次。
而由于存在 j = pi[i-1]+1
(pi[0]=0
)对于最大字符串比较次数的限制,可以看出每次只有在最好情况才会为字符串比较次数的上限积累 1,而每次超过一次的字符串比较消耗的是之后次数的增长空间。
由此我们可以得出字符串比较次数最多的一种情况:至少 1
次字符串比较次数的消耗和最多 n-2
次比较次数的积累,此时字符串比较次数为 n-1 + n-2 = 2n-3
。
可见经过此次优化,计算前缀函数只需要进行
第二个优化
在第一个优化中,我们讨论了计算
失配时,我们希望找到对于子串
如果我们找到了这样的长度
观察上图可以发现,因为
也就是说
显然我们可以得到一个关于
最终算法
所以最终我们可以构建一个不需要进行任何字符串比较,并且只进行
而且该算法的实现出人意料的短且直观:
实现
这是一个 在线 算法,即其当数据到达时处理它——举例来说,你可以一个字符一个字符的读取字符串,立即处理它们以计算出每个字符的前缀函数值。该算法仍然需要存储字符串本身以及先前计算过的前缀函数值,但如果我们已经预先知道该字符串前缀函数的最大可能取值
应用
在字符串中查找子串:Knuth–Morris–Pratt 算法
该算法由 Knuth、Pratt 和 Morris 在 1977 年共同发布[1]。
该任务是前缀函数的一个典型应用。
过程
给定一个文本
为了简便起见,我们用
我们构造一个字符串
因此如果在某一位置
正如在前缀函数的计算中已经提到的那样,如果我们知道前缀函数的值永远不超过一特定值,那么我们不需要存储整个字符串以及整个前缀函数,而只需要二者开头的一部分。在我们这种情况下这意味着只需要存储字符串
因此 Knuth–Morris–Pratt 算法(简称 KMP 算法)用
实现
static List<Integer> find_occurrences(String text, String pattern) {
String cur = pattern + '#' + text;
int sz1 = text.length(), sz2 = pattern.length();
List<Integer> v = new ArrayList<>();
int[] lps = prefix_function(cur);
for (int i = sz2 + 1; i <= sz1 + sz2; i++) {
if (lps[i] == sz2) {
v.add(i - 2 * sz2);
}
}
return v;
}
字符串的周期
对字符串
对字符串
由
根据前缀函数的定义,可以得到
所以根据前缀函数可以在
统计每个前缀的出现次数
在该节我们将同时讨论两个问题。给定一个长度为
首先让我们来解决第一个问题。考虑位置
实现
解释
在上述代码中我们首先统计每个前缀函数值在数组
现在考虑第二个问题。我们应用来自 Knuth–Morris–Pratt 的技巧:构造一个字符串
一个字符串中本质不同子串的数目
给定一个长度为
我们将迭代的解决该问题。换句话说,在知道了当前的本质不同子串的数目的情况下,我们要找出一种在
令
构造字符串
因此,当添加了一个新字符后新出现的子串数目为
所以对于每个添加的字符,我们可以在
值得注意的是,我们也可以重新计算在头部添加一个字符,或者从尾或者头移除一个字符时的本质不同子串数目。
字符串压缩
给定一个长度为
显然,我们只需要找到
让我们计算
假定
证明
诚然,我们仍需证明该值为最优解。实际上,如果有一个比
现在假设
根据扩展欧几里得算法我们可以得到一组
由于
综上所述,不存在一个长度小于
根据前缀函数构建一个自动机
让我们重新回到通过一个分隔符将两个字符串拼接的新字符串。对于字符串
实际上在这种情况下,知道
换句话说,我们可以构造一个 自动机(一个有限状态机):其状态为当前的前缀函数值,而从一个状态到另一个状态的转移则由下一个字符确定。
因此,即使没有字符串
实现
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
int j = i;
while (j > 0 && 'a' + c != s[j]) j = pi[j - 1];
if ('a' + c == s[j]) j++;
aut[i][c] = j;
}
}
}
然而在这种形式下,对于小写字母表,算法的时间复杂度为
实现
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
if (i > 0 && 'a' + c != s[i])
aut[i][c] = aut[pi[i - 1]][c];
else
aut[i][c] = i + ('a' + c == s[i]);
}
}
}
最终我们可在
该自动机在什么时候有用呢?首先,记得大部分时候我们为了一个目的使用字符串
因此使用该自动机的最直接的好处是 加速计算字符串
通过构建
但除此以外,还有第二个不那么直接的应用。我们可以在字符串
出于完整性考虑,我们来解决这样一个问题:给定一个数
由于其天文数字般的长度,在这种情况下即使构造字符串
除了自动机之外,我们同时需要计算值
我们该如何计算这些值呢?首先根据定义,初始条件为
其中
递归代入会使字符串长度爆炸式增长,他们的长度甚至可以达到
该问题同样可通过构造前缀函数的自动机解决。同之前一样,我们利用先前计算过的结果对每个模式计算其转移然后相应统计答案即可。
练习题目
- UVa 455 "Periodic Strings"
- UVa 11022 "String Factoring"
- UVa 11452 "Dancing the Cheeky-Cheeky"
- UVa 12604 - Caesar Cipher
- UVa 12467 - Secret Word
- UVa 11019 - Matrix Matcher
- SPOJ - Pattern Find
- Codeforces - Anthem of Berland
- Codeforces - MUH and Cube Walls
参考资料与注释
本页面主要译自博文 Префикс-функция. Алгоритм Кнута-Морриса-Пратта 与其英文翻译版 Prefix function. Knuth–Morris–Pratt algorithm。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。
-
在俄文版及英文版中该部分证明均疑似有误。本文章中的该部分证明由作者自行添加。 ↩
创建日期: 2018年8月6日