KMP算法与前缀函数

(一)前缀函数

一个字符串s的border是一个最长的字符串,且既是s的后缀,又是s的真前缀。

给定长为n的字符串s,其前缀函数定义为一个长为n的数组π。其中π[i]为s的第i个前缀的border长度。


(二)KMP算法

全称为 Knuth-Morris-Pratt 算法,是由 Knuth, Morris 和 Pratt 这三个人创造的算法,可以在O(n+m)的时间内使用 O(n) 的空间完成如下的任务:

给定一个字符串S和一个模式串T ,求出S在T中所有出现的位置。

其中 |S| = n, |T| = m。

KMP算法主要依赖的是“Next函数”这个东西。


(1)Next函数

Next函数,有时候也被称作 “前缀函数”,是KMP算法的核心部分。

我们以一个数组 π 来表示它。

其旨在求得任意一个前缀的border长度。


(2)什么是border?

border指的是一个字符串内,真前缀和真后缀相等的那一部分。

这样的真前缀和真后缀可能有很多种,我们需要找的是最长的那一组。

真前缀和真后缀说的是前缀和后缀中除去字符串本身之后剩下的部分。


(3)如何求得border?

1. 朴素算法

我们显然可以暴力扫,最终的复杂度是复杂度

// C++ Version
vector<int> prefix_function(string s)
{
	int n = ( int )s.length();
	vector<int> pi(n);
	for(int i = 1; i < n; i++)
		for(int j = i; j >= 0; j--)
			if(s.substr(0, j) == s.substr(i - j + 1, j))
			{
				pi[i] = j;
				break;
			}
	return pi;
}
# Python Version
def prefix_function(s):
	n = len(s)
	pi = [0] * n
	for i in range(1, n):
		for j in range(i, -1, -1):
			if s[0 : j] == s[i - j + 1 : i + 1]:
				pi[i] = j
				break
	return pi

2. 优化

我们会发现,相邻的两个函数值最多会增加1。

也就是说,当我们移动到下一个位置时,Next函数的值要么增加一,要么维持不变,要么减少。

此时改进的算法如下:

// C++ Version
vector<int> prefix_function(string s)
{
	int n = ( int )s.length();
	vector<int> pi(n);
	for(int i = 1; i < n; i++)
		for(int j = pi[i - 1] + 1; j >= 0; j--)  // improved: j=i => j=pi[i-1]+1
			if(s.substr(0, j) == s.substr(i - j + 1, j))
			{
				pi[i] = j;
				break;
			}
	return pi;
}
# Python Version
def prefix_function(s):
	n = len(s)
	pi = [0] * n
	for i in range(1, n):
		for j in range(pi[i - 1] + 1, -1, -1):
			if s[0 : j] == s[i - j + 1 : i + 1]:
				pi[i] = j
				break
	return pi

此时,我们每一个前缀最多需要比对O(n)级别的字符串,总复杂度降到了总复杂度


3. 继续优化

刚才我们只考虑到了优化01的情况,即函数值增加1。

那么对于其他的情况呢?

我们考虑优化02

在我们之前的想法里面,我们就需要枚举出来可能的border长度,并与实际情况进行比较。

我们尝试避免这些无谓的比较。

观察一下我们想要找到的东西:

继续优化


我们想要找到两个字符串 s [ 0 → j − 1 ] 和 s [ i − j + 1 → i ] ,他们完全相等,同时也分别是 s [ 0 → i ]  的一个前缀和一个后缀。

我们发现,这两个字符串是完全包含在 s [ 0 → π [ i ] − 1 ] 和 s [ i − π [ i ] + 1 → i ] 这两个完全相等的字符串内的。


所以,我们就可以将其转化成为寻找字符串 s [ 0 → π [ i ] − 1 ] 的border。


所以说,我们需要找的就是 s [ 0 → π [ π [ i ] ] − 1 ] 和 s [ i − π [ π [ i ] ] + 1 → i ] 。


然后我们尝试将Si+1纳入我们当前找到的border里面。

如果匹配,那就向前移动;

如果失配,那就继续寻找当前长度的border,直到最后到达0。

此时改进的算法如下:

// C++ Version
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;
}
# Python Version
def prefix_function(s):
	n = len(s)
	pi = [0] * n
	for i in range(1, n):
		j = pi[i - 1]
		while j > 0 and s[i] != s[j]:
			j = pi[j - 1]
		if s[i] == s[j]:
			j += 1
		pi[i] = j
	return pi

同时我们还可以发现,我们进行优化过的算法是一个在线算法。


(4)KMP

现在终于来到了KMP算法的本体部分。

我们考虑根据题目给定的 S T 两个字符串,拼接成一个新的字符串 S + ♯ + T ,其中 ♯ 代表在 S 和 T 中都没有出现过的分隔符。

我们考虑计算新字符串 T 部分的Next函数。

因为对于 T 部分的每一个位置,其位置所对应的前缀绝对包含 S 和分隔符的。

所以,其Next函数长度绝对不会超过n 。(即 |S|)

同时,我们保证了只会比对 T 部分的字串,因为分隔符的出现使得包含其的后缀无法与同样长度的前缀匹配,因为这个字符不在 S 或 T 中出现过,而假如前缀中也包含了它,也会因为位置不一样而无法匹配。

所以说,如果在某一个位置 i 有 π [ i ] = n 成立,那么 S 就会在 T  的 i − 2 n 处出现。


(三)应用

求一个字符串的周期

我们考虑利用border的性质。

如果一个字符串 s 有长度为 r 的border,那么 ∣ s ∣ − r 一定是 s 的周期,其长度我们这里记作 p 。

就像这样:

求一个字符串的周期

从这里我们可以得出 s [ 0 → 1 ] = s [ 2 → 3 ] = s [ 4 → 5 ] = s [ 6 → 7 ] ,从而得出 r − ∣ s ∣ = 2 为 s 的周期。


同时,如果这个周期的长度 p 可以被 ∣ s ∣ 整除的话,那么长度为 p 的前缀就是 s 的最小循环元。


点赞(0)

C语言网提供由在职研发工程师或ACM蓝桥杯竞赛优秀选手录制的视频教程,并配有习题和答疑,点击了解:

一点编程也不会写的:零基础C语言学练课程

解决困扰你多年的C语言疑难杂症特性的C语言进阶课程

从零到写出一个爬虫的Python编程课程

只会语法写不出代码?手把手带你写100个编程真题的编程百练课程

信息学奥赛或C++选手的 必学C++课程

蓝桥杯ACM、信息学奥赛的必学课程:算法竞赛课入门课程

手把手讲解近五年真题的蓝桥杯辅导课程

Dotcpp在线编译      (登录可减少运行等待时间)