KMP
\(\pi(s)\)表示一个字符串 s 的最长真公共前后缀长度。
比如:abacabab 它的 pi 值为 2,a a它的 pi 值为 1。
前缀函数\(\pi[i]\)表示:一个字符串 s 的 [0, i] 前缀的 pi 值。
形式化的:\(\pi[i] = \max_{k = 0}^{i}[k:s[i ... (k - 1)] = s[i-(k - 1)...i]] \)特别的\(\pi[0]=0\)
求解前缀函数\( \pi\)的过程:
首先\( \pi[i]\)表示前 i + 1 个字符所组成的字符串的最长真公共前后缀的长度,我们可以从 j = [1, i + 1] 去枚举长度,然后依次比较每个字符是否相同,时间复杂度为\(O(n^3)\)。
对于上述过程的优化探讨:
定义一个字符串的最长真公共前后缀叫做其 border,如:abacabab的 border 为 ab,border 具有以下的性质:
- 一个字符串的 border 的 border 还是其真公共前后缀

- 字符串的 border 的 border 一定是其次长真公共前后缀
结合上面的图片可以用反证法来证明这个性质,假设 s 的一个次长的真公共前后缀的长度为 2,这个2长度会通过
border 传递给 3 长度的前缀,那么\(\pi[\pi[s]] = 2\),矛盾。
tips:结合这个性质我们可以发现,s 有一个长度为$\pi[i] \(的真公共前后缀,有一个长度为\)\pi[\pi[i]]\(的真公共前后缀,有一个长度为\)\pi[\pi[\pi[i]]]$的真公共前后缀... ,据此可以求出 s 的全部真公共前后缀。
现在用 dp 的思想考虑优化前缀函数的求解过程
当求完 i 位置的 \(\pi[i]\)后,i + 1 位置的最好情况为 s 的第\(\pi[i]\)+ 1 个字符与 s 的 i + 1 个字符相同,此时\(\pi[i + 1] = \pi[i] + 1\)

如果上述条件没有满足,我们需要找到一个s[0, i]次长的的一个真公共前后缀,由上面性质 2 可以知道,这个次长真公共前后缀为\(\pi[\pi[i]]\),有性质 1 不难发现如果$s[\pi[\pi[i]] + 1]= s[i + 1] \(,\)\pi[i + 1] = \pi[\pi[i]] + 1$。
通过一直重复上面两个转移的过程,直到 j (可能的匹配长度)为 0。
下面的代码中 s 的下标默认从 1 开始
code:
void get_pi()
{
for(int i = 2, j = 0; i < s.size(); i++)
{
while(j && s[j + 1] != s[i]) j = pi[j];
if(s[j + 1] == s[i]) j++;
pi[i] = j;
}
}
上面的两个指针 i 和 j 的距离不会减小,最坏情况下 i 走到末尾位置,j 回退到开头,时间复杂度为\(O(n)\)
KMP:
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)
令模式串为 t ,目标串为 s,t 的长度为 m , s 的长度为 n。
拼接 t 和 s,中间加上一个不会出现的字符如 '#',形式化的 s = t + '#' + s。
s 求\(\pi\)函数,当出现$\pi[i] = m $时匹配成功,对用字符串 s 原来的位置为 i - 2 * m

这就是一种 KMP 算法的实现思路,具体代码如下:
void kmp1()
{
s = " " + t + '#' + s;
for(int i = 2, j = 0; i <= n + m + 1; i++)
{
while(j && s[j + 1] != s[i]) j = pi[j];
if(s[j + 1] == s[i]) j++;
pi[i] = j;
if(pi[i] == m) cout << i - 2 * m << endl;
}
for(int i = 1; i <= m; i++) cout << pi[i] << " ";
}
时间复杂度为\(O(n + m) \)
由于添加的是一个 s 和 t 中不可能出现的字符,j 不会越过下表为 m 的位置,因此仅需求 t 的前缀函数next数组即可。
void kmp2()
{
// 生成模式串的 next 数组
s = " " + s; t = " " + t;
for(int i = 2, j = 0; i <= m; i++)
{
while(j && t[j + 1] != t[i]) j = ne[j];
if(t[j + 1] == t[i]) j++;
ne[i] = j;
}
for(int i = 1, j = 0; i <= n; i++)
{
while(j && s[i] != t[j + 1]) j = ne[j];
if(s[i] == t[j + 1]) j++;
if(j == m) cout << i - m + 1 << endl;
}
for(int i = 1; i <= m; i++) cout << ne[i] << " ";
}
这样的写法也是大多数教材中采用的,但两种 KMP 写法的本质都是模拟前缀函数的求解过程,这个过程通过失配后 j 的巧妙跳跃,使得 j 不回退,并且快速达到下一个次最优的匹配位置,实现了线性模式串匹配。
字符串的周期:对字符串 \(s\),若 \(0 < p \le |s|\),且对所有 \(i \in[0, |s| - p -1]\) 满足 \(s[i] = s[i + p]\),则称 \(p\) 是 \(s\) 的周期。
border 的妙用在于可以求出 s 的最短周期或者周期数最多的字符串长度,假设有一个长度为 len 的公共真前后缀,n - len 为 s 的一个周期,s 的最短周期即为\(n - \pi(s)\),最长周期为 n。
循环节:即一个字符串是由若干个相同的子字符串组成,这个子字符串即为循环节。
循环节的求解同上面类似,如果 n 能整除\(n - \pi(s)\),\(n - \pi(s)\)即为 s 的一个循环节,否则循环节只有 s 本身,原因\(n - \pi(s)\)显然是一个最短的循环节,如果还有别的循环节那么\(\pi(s)\)会更大。
浙公网安备 33010602011771号