KMP算法
- KMP算法
- 算法介绍
- 代码演示:
KMP算法
KMP算法是为了解决这一类问题,给定一个字符串str1,和一个字符串str2,如果str2属于str1d的字串,则返回字串第一个出现位置的下标,不存在返回-1.
注意:
子串是连续的.
举个例子
str1 = “abc123abs” str1 长度假设m
str2= “123”; str2 长度假设是n
返回2,123第一个出现的下标是 3位置.
算法介绍
KMP 算法是一个快速查找匹配串的算法,它的作用其实就是本题问题:如何快速在「原字符串」中找到「匹配字符串」的下标。
暴力解法:
在str1中,每来到一个字符后,就向右匹配str2 的长度,这样就可以找到第一次出现的下标了,但这个时间复杂度就是m * n了.
怎么样能优化这个时间复杂度,首先要想明白,为什么时间复杂度会这么高,这是因为每次到一个字符配合的结果,无法给下一个字符去用,这样就导致了每个字符串都要重新匹配n次,
kmp算法就是要优化这个过程,使得每次的结果都能被后面继续使用,从而将时间优化成,o(m + n).
你可能不太理解,没关系,我们可以通过举 🌰 来理解 KMP。
- 匹配过程
在模拟 KMP 匹配过程之前,我们先建立两个概念:
前缀:对于字符串 abcxxxxefg,我们称 abc 属于 abcxxxxefg 的某个前缀。
后缀:对于字符串 abcxxxxefg,我们称 efg 属于 abcxxxxefg 的某个后缀。
然后我们假设原串为 abeababeabf,匹配串为 abeabf:
我们可以先看看如果不使用 KMP,会如何进行匹配(不使用 substring 函数的情况下)。
首先在「原串」和「匹配串」分别各自有一个指针指向当前匹配的位置。
首次匹配的「发起点」是第一个字符 a。显然,后面的 abeab 都是匹配的,>两个指针会同时往右移动(黑标)。
在都能匹配上 abeab 的部分,「朴素匹配」和「KMP」并无不同。
直到出现第一个不同的位置(红标):
接下来,正是「朴素匹配」和「KMP」出现不同的地方:
先看下「朴素匹配」逻辑:
将原串的指针移动至本次「发起点」的下一个位置(b 字符处);匹配串的指针移动至起始位置。
尝试匹配,发现对不上,原串的指针会一直往后移动,直到能够与匹配串对上位置。
如图:
也就是说,对于「朴素匹配」而言,一旦匹配失败,将会将原串指针调整至下一个「发起点」,匹配串的指针调整至起始位置,然后重新尝试匹配。
这也就不难理解为什么「朴素匹配」的复杂度是 了。
然后我们再看看「KMP 匹配」过程:
首先匹配串会检查之前已经匹配成功的部分中里是否存在相同的「前缀」和「后缀」。
如果存在,则跳转到「前缀」的下一个位置继续往下匹配:
跳转到下一匹配位置后,尝试匹配,发现两个指针的字符对不上,并且此时匹配串指针前面不存在相同的「前缀」和「后缀」,这时候只能回到匹配串的起始位置重新开始:
到这里,你应该清楚 KMP 为什么相比于朴素解法更快:
因为 KMP 利用已匹配部分中相同的「前缀」和「后缀」来加速下一次的匹配。
因为 KMP 的原串指针不会进行回溯(没有朴素匹配中回到下一个「发起点」的过程)。
第一点很直观,也很好理解。
我们可以把重点放在第二点上,原串不回溯至「发起点」意味着什么?
其实是意味着:随着匹配过程的进行,原串指针的不断右移,我们本质上是在不断地在否决一些「不可能」的方案。
当我们的原串指针从 i 位置后移到 j 位置,不仅仅代表着「原串」下标范围为 的字符与「匹配串」匹配或者不匹配,更是在否决那些以「原串」下标范围为 为「匹配发起点」的子集。
- 分析实现
到这里,就结束了吗?要开始动手实现上述匹配过程了吗?
我们可以先分析一下复杂度。
如果严格按照上述解法的话,最坏情况下我们需要扫描整个原串,复杂度为 。
同时在每一次匹配失败时,去检查已匹配部分的相同「前缀」和「后缀」,跳转到相应的位置;如果不匹配则再检查前面部分是否有相同「前缀」和「后缀」,再跳转到相应的位置… 这部分的复杂度是 ,因此整体的复杂度是 ,而我们的朴素解法是 的。
说明还有一些性质我们没有利用到。
显然,扫描完整原串操作这一操作是不可避免的,我们可以优化的只能是「检查已匹配部分的相同前缀和后缀」这一过程。
再进一步,我们检查「前缀」和「后缀」的目的其实是「为了确定匹配串中的下一段开始匹配的位置」。
同时我们发现,对于匹配串的任意一个位置而言,由该位置发起的下一个匹配点位置其实与原串无关。
举个 🌰,对于匹配串 abcabd 的字符 d 而言,由它发起的下一个匹配点跳转必然是字符 c 的位置。因为字符 d 位置的相同「前缀」和「后缀」字符 ab 的下一位置就是字符 c。
可见从匹配串某个位置跳转下一个匹配位置这一过程是与原串无关的,我们将这一过程称为找 next 点。
显然我们可以预处理出 next 数组,数组中每个位置的值就是该下标应该跳转的目标位置( next 点)。
当我们进行了这一步优化之后,复杂度是多少呢?
预处理 next 数组的复杂度未知,匹配过程最多扫描完整个原串,复杂度为 。
因此如果我们希望整个 KMP 过程是 的话,那么我们需要在 的复杂度内预处理出 next数组。
所以我们的重点在于如何在 复杂度内处理处 next 数组。
- next 数组的构建
接下来,我们看看 next 数组是如何在 的复杂度内被预处理出来的。
假设有匹配串 aaabbab,我们来看看对应的 next 是如何被构建出来的。
代码演示:
package class27;
public class Code01_KMP {
public static int getIndexOf(String s1, String s2) {
if (s1 == null || s2 == null || s2.length() < 1 || s1.length() < s2.length()) {
return -1;
}
char[] str1 = s1.toCharArray();
char[] str2 = s2.toCharArray();
int x = 0;
int y = 0;
// O(M) m <= n
int[] next = getNextArray(str2);
// O(N)
while (x < str1.length && y < str2.length) {
if (str1[x] == str2[y]) {
x++;
y++;
} else if (next[y] == -1) { // y == 0
x++;
} else {
y = next[y];
}
}
return y == str2.length ? x - y : -1;
}
public static int[] getNextArray(char[] str2) {
if (str2.length == 1) {
return new int[] { -1 };
}
int[] next = new int[str2.length];
next[0] = -1;
next[1] = 0;
int i = 2; // 目前在哪个位置上求next数组的值
int cn = 0; // 当前是哪个位置的值再和i-1位置的字符比较
while (i < next.length) {
if (str2[i - 1] == str2[cn]) { // 配成功的时候
next[i++] = ++cn;
} else if (cn > 0) {
cn = next[cn];
} else {
next[i++] = 0;
}
}
return next;
}
// for test
public static String getRandomString(int possibilities, int size) {
char[] ans = new char[(int) (Math.random() * size) + 1];
for (int i = 0; i < ans.length; i++) {
ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
}
return String.valueOf(ans);
}
public static void main(String[] args) {
int possibilities = 5;
int strSize = 20;
int matchSize = 5;
int testTimes = 5000000;
System.out.println("test begin");
for (int i = 0; i < testTimes; i++) {
String str = getRandomString(possibilities, strSize);
String match = getRandomString(possibilities, matchSize);
if (getIndexOf(str, match) != str.indexOf(match)) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}