【字符串】前缀函数与KnuthMorrisPratt KMP算法
https://oi-wiki.org/string/kmp/
研究的对象其实主要是前缀函数,而不是KMP算法,KMP算法只是前缀函数的一个应用,如何求前缀函数,理解前缀函数的作用才是关键。
简单理解然后应用
前缀函数:对字符串s(从1开始)求其前缀函数pi,则pi[i]表示长度为i的s的前缀s[1..i]中,最长的公共真前后缀的长度。也就是说,是求每一个s的前缀的“border”,一个字符串的border就是其最长的公共真前后缀的长度。例如 abcdddabc 的border为 abc, 而 abcabcabc 的border 为 abcabc。
Z函数:对字符串s(从1开始)求其z函数。z[i]表示从i开始的s的后缀s[i..n]中,与s本身的最长公共前后缀的长度。也就是说从i位置开始,有z[i]长度的字符串s[i.. i + z[i] - 1]和s[1.. z[i] - 1] 是相同的。
由于公共真前后缀(边界)一定可以表示为两个前缀最大匹配的长度,所以我目前想到的所有的问题,kmp能解决的Z函数都能更方便解决。Z函数的理解成本更低,更方便控制里面的指针跳转节约常数。
简而言之:
abcdddabc:前后缀交叠重复/周期 前缀函数
abcdddabceee:后缀和自己的前缀匹配 Z函数
abcdddcba:回文
可以用这道题(求LCP)来看看kmp/Z函数/哈希之间的性能差异:https://codeforces.com/contest/1968/problem/G2 kmp和哈希在这道题中的性能差异是类似的(每次check都一定要跑满长度n枚举所有的子串/kmp的下标i只能逐次移动)。Z函数得益于其一旦匹配上某个位置即可跳过tlen长度的位置(因为不能有重叠的子串),所以在t的长度比较长的时候速度飞快。
以下模板代码的字符串下标均从1开始。
当遇到性能问题时很可能是vector导致的,换成普通数组也可以。
字符串 \(s\) 的前缀函数 \(\pi[i]\) 表示长度为 \(i\) 的前缀中最长的公共前后缀的长度。
kmp 找出模式字符串 \(p\) 在文本字符串 \(t\) 中出现的所有的位置。其方法是构造一个字符串 p + '#' + t,然后计算整个串的前缀函数,由于'#'的存在,前缀函数的值不可能超过|p|,字符串扫过去的时候,对于t的某一个位置若前缀函数pi[i]==|p|,就说明在t的这个位置中出现了模式串p,且恰好以他为末尾。
优化:实际上并不需要关心t的前缀函数,因为它的值都是一次性的(因为前缀函数不可能超过|p|,所以每次都只是在p的部分跳来跳去,用完就扔了,所以没必要开个空间记录下来。加上是个在线的算法,所以可以省下这部分空间。
// 找模式串t在文本串s中的所有出现位置的起始下标,这些出现可以互相重叠
namespace KnuthMorrisPrattAlgorithm {
vi getPi(char *s) {
int slen = strlen(s + 1);
vi pi(slen + 1);
for(int i = 2, j = 0; i <= slen; ++i) {
for(; j > 0 && s[i] != s[j + 1]; j = pi[j]); // 不断失配,直到第一个匹配或者j为0
j += (s[i] == s[j + 1]); // 如果匹配,则前缀函数+1,这些都是正常的前缀函数操作
pi[i] = j; // [1,i]串的最长公共前后缀长度为j
}
return pi;
}
vi kmp(char *s, char *t) {
int slen = strlen(s + 1), tlen = strlen(t + 1);
vi pi = getPi(t), res;
for(int i = 1, j = 0; i <= slen; ++i) {
for(; j > 0 && s[i] != t[j + 1]; j = pi[j]); // 不断失配,直到第一个匹配或者j为0
j += (s[i] == t[j + 1]); // 如果匹配,则前缀函数+1,这些都是正常的前缀函数操作
if(j == tlen) // 前缀函数刚好是模式串的长度
res.pb(i - tlen + 1); // 串s的[i-tlen+1, i]位置出现了这个串,其实就是以i为结尾的串
}
return res;
}
}
// 一个变形,求s的长度为tlen的前缀,在s中出现的所有不重叠的位置,解决这个问题时,如果多次调用kmp求解不同长度的前缀的答案,则不如改成用Z函数会更快,因为Z函数可以更方便地利用“无重叠”这个条件。
vector<int> pi;
namespace KnuthMorrisPrattAlgorithm {
void getPi (char *s) {
int slen = strlen (s + 1);
pi.clear();
pi.resize (slen + 1);
for (int i = 2, j = 0; i <= slen; ++i) {
for (; j > 0 && s[i] != s[j + 1]; j = pi[j]); // 不断失配,直到第一个匹配或者j为0
j += (s[i] == s[j + 1]); // 如果匹配,则前缀函数+1,这些都是正常的前缀函数操作
pi[i] = j; // [1,i]串的最长公共前后缀长度为j
}
}
int kmp (char *s, int tlen) {
char *t = s;
int slen = strlen (s + 1);
int cnt = 0;
int last = 0;
for (int i = 1, j = 0; i <= slen; ++i) {
for (; j > 0 && (j + 1 > tlen || s[i] != t[j + 1]); j = pi[j]); // 不断失配,直到第一个匹配或者j为0
j += (j <= tlen && s[i] == t[j + 1]); // 如果匹配,则前缀函数+1,这些都是正常的前缀函数操作
if (j == tlen) {
int begin = i - tlen + 1;
if (begin > last) {
++cnt;
last = begin + tlen - 1;
}
}
}
return cnt;
}
}
字符串 \(s\) 的z函数 \(z[i]\) 表示以 \(i\) 位置为左端点的后缀中,与字符串 \(s\) 的最长的公共前缀的长度。
exkmp:找出从\(s[i]\)开始的,和模式串 \(t\) 的最长公共前缀。
namespace ExtendKnuthMorrisPrattAlgorithm {
vector<int> getZ (char *s, int n) {
vector<int> z (n + 1);
z[1] = n;
for (int i = 2, l = 0, r = 0; i <= n; ++i) {
if (i <= r)
z[i] = min (z[i - l + 1], r - i + 1);
for (; i + z[i] <= n && s[i + z[i]] == s[z[i] + 1]; ++z[i]);
if (i + z[i] - 1 > r)
l = i, r = i + z[i] - 1;
}
return z;
}
vector<int> exkmp (char *s, char *t) {
int n = strlen (s + 1), m = strlen (t + 1);
vector<int> z = getZ (t, m), res (n + 1);
for (int i = 1, l = 0, r = 0; i <= n; ++i) {
if (i <= r)
res[i] = min (z[i - l + 1], r - i + 1);
for (; i + res[i] <= n && s[i + res[i]] == t[res[i] + 1]; ++res[i]);
if (i + res[i] - 1 > r)
l = i, r = i + res[i] - 1;
}
return res;
}
}
统计既是字符串 \(s\) 的前缀又是字符串 \(s\) 的后缀的所有字符串的长度及其出现次数:易知 \(z[i]=(n+1-i)\) 表示后缀字符串 \(s[i,n]\) 与字符串 \(s\) 的最长公共前缀的长度等于后缀字符串 \(s[i,n]\) 的长度,所以后缀字符串 \(s[i,n]\) 满足既是字符串 \(s\) 的前缀又是字符串 \(s\) 的后缀,且其长度为 \(z[i]\) 。那么它的出现次数可以直接用dp求出来(长度为 \(z[i]+1\) 的字符串出现,则长度为 \(z[i]\) 的字符串也出现)。
上面这些函数存在越界访问的现象,在对字符串使用时由于字符串末尾一定是特殊字符所以没事,但是对整数数组进行匹配时要小心。
字符串的周期:
字符串的最小周期,定义就是最小的整数p,使得s[i]==s[i+p]恒成立,注意对于字符串s[1,n]来说,前缀函数pi[n]就是n的最长的公共前后缀,也就是最长的border,由于周期的定义,可以知道周期就是n - border的长度。
所有的border分别是pi[n], pi[pi[n]], pi[pi[pi[n]]] 这样一层一层解套,其实每次套就会截短一点,但是保持了末尾不变,当然也是原串的后缀,又由于是公共前后缀,所以一定也是前缀,所以也是border。
统计每个前缀s[1,i]出现的次数:
也是用前面的解套思想,pi[i]变成pi[pi[i]],然后再变成pi[pi[i]],也就是长的会给短的贡献,所以是这样:
// C++ Version 这里的s不是从0开始的,但是没太大区别
vector<int> ans(n + 1);
for (int i = 0; i < n; i++) ans[pi[i]]++; // 这里是因为s[1,i]的最长公共前后缀长度就是pi[i],它在i结尾出出现了,也在开头出现了,先计算了i结尾出现的那次
for (int i = n - 1; i > 0; i--) ans[pi[i - 1]] += ans[i]; // 倒序扫描,把长的结尾出现的传递给短的结尾出现的,也就是把i的答案传递给pi[i],因为s[1,i]的串一定以s[1,pi[i]]结尾,所以要把自己的出现次数传给s[1,pi[i]]
for (int i = 0; i <= n; i++) ans[i]++; // 这里补上开头出现的那次,注意因为是真前缀和真后缀的公共部分,所以不会出现同一个串被第一行和第三行同时数了
统计s的每个前缀s[1,i]在字符串t中出现的次数。
也是拼接s+'#'+t,然后求出前缀函数。也是一样搞一个ans,但是此次就不关心s中的问题了。所以就从|s|+1的位置的前缀函数开始算,相当于就是t的[1,i]中包含了最长的s的前缀就是s[1,pi[i]],所以给s[1,pi[i]]的出现次数贡献答案1,然后套用倒序扫描(由于t的ans都是0,所以没必要扫描)让s处理s内部的包含出现问题。最后也不用再+1因为不存在这样的前缀。
本质不同的子串数量,支持在线修改,每次修改是O(n),合计O(n^2),见oiwiki
字符串压缩,= 字符串周期 = 字符串border,同一个意思,只不过字符串压缩多一个整除的条件(最小周期必须被长度整除,否则不存在答案)。
TODO:字符串的经典问题中,同一个问题往往能被多种算法所解决,可能他们有着不同的时空复杂度,有的在线有的离线,可以做个表对比一下。