KMP 字符串匹配 学习笔记

WiuehPlus的博客 / 2023-08-23 / 原文

KMP 算法是用来判断一个文本串 \(a\) 是否存在子串 \(b\) 的高效算法。

定义

以下所有解释,字符串下标都以 \(1\) 开始。
\(a\):文本串;
\(b\):模式串。需要判断 \(b\) 是否为 \(a\) 的一个子串;
\(len_a\)\(a\) 的字符长度(\(m\));
\(len_b\)\(b\) 的字符长度(\(n\))。

朴素算法

显然,判断上述问题需要两层循环,第一层枚举文本串 \(a\) 的每个字符,记做 \(a_i\),第二层用来枚举从 \(a_i\) 开始往后的 \(len_b\) 个字符,每次判断当前字符是否与 \(b\) 对应的字符是否相等,这样一直操作下去,直到匹配成功或循环结束。
显然,这种算法的时间复杂度为 \(O(mn)\),效率低下。

我们发现每次匹配失败都会将模式串向后移动一位继续与文本串进行匹配,这样做效率太慢了,而 KMP 算法也是对这一点进行了优化。

KMP

原理

举例:

a: abcabxabdd
b: abcabd
        ^

我们在第一轮匹配中,发现 \(a_6\)\(b_6\) 匹配不上,在朴素算法中,我们选择将 \(b\) 串往后移动一位继续匹配。
即:

a: abcadxabdd
b:  abcabd

这样效率是很慢的。

我们发现在第一轮匹配中,\(b\) 串中第 \(6\) 匹配失败了,那么意味着,我们前 \(5\) 位是匹配成功了的,那么我们前 \(5\) 位中,abcab,这都是匹配上了的,我们发现这一个子串中有一个公共前后缀 ab,那么我们下一次挪动 \(b\) 串时,将 \(b\) 串中前一个 ab 对准 \(a\) 串中后一个 ab,那么我们就会发现我们节省了很多没有必要的挪动,这样一来我们就可以直接从第三个字母 c 开始匹配。
即:

a: abcab xabdd
b: abcab d
   ^^ ^^     

挪动为:

a: abcab xabdd
b:    ab cabd
      ^^     

这样我们只需要从再匹配后面的 cabd 就好了。节省了大量的时间。
那么我们就需要找到匹配串前 \(x\) 个中的最长公共前后缀(如上面 abcabab),为什么要找最长的呢?因为这样挪动的越多,越节省时间。

步骤一:求解 nxt 数组

知道了原理以后,我们需要求解每个 \(b\) 串中所有前 \(i\) 个的最长公共前后缀。
我们记 \(nxt_x\)\(b\) 串中前 \(x\) 个的最长公共前后缀的长度。
我们使用两个指针 \(i\)\(j\)

做法:
我们每次判断 \(b_j\)\(b_i\) 是否相等。
\(\bullet\) 若相等,将 \(nxt_i=nxt_j+1\),然后将 \(i\)\(j\) 一起向后移动。
\(\bullet\) 若不相等,将 \(j=nxt_j-1\),再判断 \(b_i,b_j\) 是否相等,然后重复执行操作,直到 \(b_i=b_j\) 或 (\(b_i \neq b_j\)\(j=1\))。

对于做法的解释:
由于我们定义了 \(nxt_x\)\(b\) 串中前 \(x\) 个的最长公共前后缀的长度。
\(\bullet\) 那么当 \(b_j=b_i\) 时,说明前 \(i\) 个的子串公共前后缀在前 \(i-1\) 个的最长子串公共前后缀的基础上,有了新的字符加入,所以将 \(nxt_i=nxt_j+1\)
\(\bullet\) 反之,当 \(b_i \neq b_j\) 时,说明前 前 \(i-1\) 个的子串的最长公共前后缀的基础上不能加上当前这个字符(如果加上了就不是公共前后缀了),那么我们就让 \(j\) 往前面跳,即让 \(j=nxt_j-1\),虽然这样跳了之后对于前 \(i-1\) 个子串的公共前后缀不是最长的,但对于当前子串来说,需要找到一个可以容纳加入当前字符的公共前后缀,如果 \(j=1\) 了还不能匹配上,说明已经前 \(i\) 个的子串已经找不到公共前后缀了。

举例:
我们现在对 abcdabca 这个字符串进行求解 \(nxt\) 数组。

p:   j i
b:   a b c d a b c a
nxt: 0

根据以上说的做法,我们容易得出:

p:   j       i
b:   a b c d a b c a
nxt: 0 0 0 0

此时 \(b_i=b_j\),那么我们的 \(nxt_i=nxt_j+1\),且 \(j\) 也跟着走。
我们继续这样做,易得:

p:         j       i
b:   a b c d a b c a
nxt: 0 0 0 0 1 2 3

那么现在我们现在 \(b_i \neq b_j\),现在就需要让 \(j\) 往前跳了,找到一个能容纳当前字符的公共前后缀,那么我们 \(j=nxt_j-1\),即跳到了第 \(0\) 个位置,由于我们下标是从 \(1\) 开始的,所以我们跳转时需要将公共前后缀的长度 \(+1\) 转换成下标,所以这里是跳到了第 \(1\) 个位置。那么此时我们 \(b_i=b_j\),那么 \(nxt_i=nxt_j+1\),得到:

p:         j       i
b:   a b c d a b c a
nxt: 0 0 0 0 1 2 3 1

那么我们整个求解过程就结束了。

代码实现
时间复杂度 \(O(n)\)

j = 0;
for (int i = 2;i <= lena;i++){
	while (j && b[i] != b[j + 1]){
		j = nxt[j];
	}
	if (b[i] == b[j + 1]){
		j++;
	}
	nxt[i] = j;
}

步骤二:开始匹配

我们仍然用到两个指针 \(i\)\(j\)
不过与上一个部分不同的是,这里的 \(i\)\(a\) 字符串的指针。\(j\) 串是 \(b\) 字符串的指针。
匹配与上一个部分也很类似。
\(i\) 一直往前走。
\(a_i=b_j\),那么 \(j\) 往前走。
否则,\(j\) 通过\(nxt\) 数组往回跳,直到 \(a_i=b_j\) 或跳到头了。(原因在原理这一部分说过了),就是对朴素算法的优化。

代码实现
时间复杂度 \(O(m)\)

j = 0;
for (int i = 1;i <= lena;i++){
	while (j && a[i] != b[j + 1]){
		j = nxt[j];
	}
	if (a[i] == b[j + 1]){
		j++;
	}
	if (j == lenb){
		cout << "Yes";
		return 0;
	} 
}

总结

KMP 就是对朴素算法匹配字符串的优化,通过求最长公共前后缀进行灵活的挪动。
总复杂度 \(O(n+m)\)