【学习笔记】KMP 字符串匹配

题外话

最初学这个算法,是在新初一的暑假里,一个初三即将毕业的学长给我们讲的。当时听得不是很懂,模板题也就只有 28pts。

后来过了好久,又想起来重新学一遍字符串,从 KMP 到 SAM 都来一遍。于是便有了这片文章。

什么是 KMP 算法

KMP 算法是由三名姓氏首字母分别为 K、M、P 的牛人发明的算法。虽然我不知道他们到底是谁hh

就像标题中那样说的,KMP 是字符串的匹配算法。

那什么叫做匹配呢?看下图就知道了:

kmp-1.png

接下来,我们会大量使用此类称呼,请务必记住这几个名称!

注 1:若未特别说明,下面用 (n) 表示文本串 (S) 的长度,(m) 表示模式串 (T) 的长度。

先来一个暴力

众所周知,很多优秀的算法都由于暴力。KMP 也不例外。我们先从一个一般的暴力算法开始:

  1. 初始化,(i=1)(j=1)
  2. 开始匹配,如果 (S_i=T_j),则 (ileftarrow i+1)(jleftarrow j+1)
  3. 如果 (S_i eq T_j)(也称为失配),则重新令 (ileftarrow i-j+2)(jleftarrow 1)
  4. 特别地,如果 (j=m),即得到一次匹配,则令 (ileftarrow i-m+2)(jleftarrow 1)

极其暴力,如果失配就从第一个没有匹配过的点开始。如果字符串是随机的可以跑得很优秀(因为很快就会失配),但是如果是精心构造过的数据就会被轻易卡到 (mathcal{O}(nm)) 从而爆炸。

优化

怎么给这种算法加上优化呢?

我们注意到,之前的失配是非常浪费时间的。如果我们能够尽量利用失配前得到的结果,是不是就能在一定程度上提高程序效率,同时保证正确性呢?

每一次匹配都为了找出可能存在的匹配方案,那么如果某些点可以在一开始就被证明是不可能存在匹配的,是不是就能节省很多时间呢?

如下图:

kmp-2.png

在 KMP 算法中,这样的前缀/后缀被称为 border(有些也称为 next 数组)。这是整个 KMP 算法优化的关键。

这样,我们就能完成匹配后的优化。那么怎么解决失配的优化问题呢?其实很简单。

失配是什么?其实是模式串的一个前缀的匹配。所以我们也可以用匹配的优化来优化失配,只是指针的边界处理略有不同而已。

经过了这样的优化,匹配的时间复杂度可以降到多少呢?注意到指向文本串的指针只增不减,而指向模式串的指针也是只增不减。每一次比较必然带来两个指针至少一个的变化,所以复杂度是 (mathcal{O}(n+m)),有了极大的优化。

这一段的 Code:(我的写法的下标从 (0) 开始)

ptr = -1;
for (int i = 0; a[i]; i++) // 指向文本串的指针
{
	while (ptr != -1) // 失配时的下一步匹配
	{
		if (b[ptr+1] == a[i]) // 找到了下一个匹配
			break;
		ptr = fail[ptr]; // 这个可以暂时不用理解,接下来讲怎么计算 border 时会证明这一点
	}

	if (a[i] == b[ptr+1]) // 这么写而不是 (ptr == -1) 是因为最后一位也有可能匹配成功
	{
		ptr++; // 指针移动一位
		if (ptr == lb - 1) // 发现一个匹配
		{
			printf("%d
", i - ptr + 1);
			ptr = fail[ptr]; // 极大利用目前结果。
		}
	}
}

讲了这么多,我们似乎没有办法很快地求出 border,而且是模式串所有前缀的 border。我们需要引入 DP 思想。

(f_i) 为到第 (i) 位的前缀的 border 长度。怎么转移呢?

首先根据 border 的定义,如果 (T_{f_{i-1}+1}=T_i),也就是可以从上一位转移,即 (f_i=f_{i-1}+1)。但是如果失配了呢?

这也是 KMP 极其精髓的一点,我们要证明一个定理,从而极大地优化复杂度:

Theorem:一个 border 的 border 是原串的次大 border,即使字符串前缀和长度相等后缀一样的长度次大的方案。

一张图看懂证明(字符串用线段表示):

kmp-3.png

这个性质至关重要,也就是说我们可以通过递归的方式来依次寻找最大、次大,从而转移。

有了这个性质,之前那个用递归方式进行指针移动的思路也明白了。那么……复杂度呢?这个玄学的复杂度怎么分析呢?(我不会)总之,这里的复杂度也是线性的。

贴代码时间到:

fail[0] = -1, ptr = -1;
for (lb = 1; b[lb]; lb++) // 遍历模式串
{
	while (ptr != -1) // 跳
	{
		if (b[ptr+1] == b[lb]) // 发现相同的
			break;
		ptr = fail[ptr]; // 继续跳
	}
	
	if (b[ptr+1] == b[lb]) // 理由同上
		ptr++;
	fail[lb] = ptr; // 数组实际上存的是指针
}

完整代码

KMP 算法的代码,可以通过模板题

#include <cstdio>
using namespace std;

const int max_n = 1000000;
char a[max_n], b[max_n];
int fail[max_n+1];

int main()
{
	int ptr, lb;

	scanf("%s%s", a, b);

	fail[0] = -1, ptr = -1;
	for (lb = 1; b[lb]; lb++)
	{
		while (ptr != -1)
		{
			if (b[ptr+1] == b[lb])
				break;
			ptr = fail[ptr];
		}
		
		if (b[ptr+1] == b[lb])
			ptr++;
		fail[lb] = ptr;
	}

	ptr = -1;
	for (int i = 0; a[i]; i++)
	{
		while (ptr != -1)
		{
			if (b[ptr+1] == a[i])
				break;
			ptr = fail[ptr];
		}

		if (a[i] == b[ptr+1])
		{
			ptr++;

			if (ptr == lb - 1)
			{
				printf("%d
", i - ptr + 1);
				ptr = fail[ptr];
			}
		}
	}

	for (int i = 0; i < lb; i++)
		printf("%d ", fail[i] + 1);
	putchar('
');

	return 0;
}
原文地址:https://www.cnblogs.com/5ab-juruo/p/note-kmp.html