在计算机科学中,字符串匹配是一个常见的问题。给定一个文本串和一个模式串,我们需要在文本串中找到所有与模式串匹配的位置。传统的字符串匹配算法如暴力匹配(Brute Force)方法在最坏情况下的时间复杂度为O(m*n),其中m和n分别是文本串(长的字符串)和模式串(短的字符串)的长度,kmp算法是一种高效的字符串匹配算法。
废话不多说我们直接介绍重点,带你理解kmp算法
为什么暴力匹配这么慢?
我们发现当每次匹配失败后,bf算法都会让 文本串(长的字符串)后退到匹配的第一个字符的下一个字符,让模式串(短的字符串)后退到第一个字符,重新开始匹配,例如:
0 1 2 3 4 5 6 7 8 9 10
a b c a b a b c a b d
a b c a b d
0 1 2 3 4 5
当我们使用bf算法时,在 5 下标位置匹配失败时,会让 文本串 后退到 1 下标 ,让模式串后退到 0 下标,重新开始匹配,但其实我们发现:文本串的 1 下标和 模式串的 0 下标其实并不匹配,其实大可跳过,文本串的 1 下标,如果重新匹配,我们发现,只有从文本串的 3 位置开始匹配才可能成功,kmp算法对于bf算法的优化就是在于,跳过了那些一定匹配不上的位置。
kmp的算法核心在于,让文本串不后退:
如上述例子,我们在5位置匹配失败了,此处不让文本串后退,只让 模式串 后退,我们发现,文本串,的 3 4 下标是和模式串的 0 1 下标匹配的,所以我们可以让,让模式串后退到 2 下标位置,与 文本串 5 下标位置 进行比较:
0 1 2 3 4 5 6 7 8 9 10
a b c a b a b c a b d
a b c a b d
0 1 2 3 4 5
即相当于,我们跳过了用 模式串 与 1 位置, 和 2 位置 的比较,因为这两个位置匹配一定是失败的,也跳过了 ,模式串 的 0 1 下标和,文本串的 3 4 下标的比较,因为我们知道一定是成功的,所以直接从模式串的 3 位置与文本串的 5 位置开始匹配
我们要如何知道 模式串 回退的位置?靠眼睛看肯定是不行的
如果上面的内容没有看懂,没关系,请重点理解下面的内容:
在模式串中,如果一个子串的前缀和后缀相同,则称该子串为前缀后缀。例如,模式串"a b c a b "的前缀有"a"、"ab"、"abc"、"abca"、"abca",后缀有"b"、"ab"、"cab"、"bcab"、"abca"。
那么"abcab"的最长前缀后缀不就是 "ab"吗。
注意:最长前缀后缀不能是这个字串本身
练习一下:
"abcdbcabcd"的最长前后缀是?
没错,是 "abcd"。现在你已经会求最长前后缀了,现在我们可以解决,模式串回退的位置的问题了,这是最关键的一步。
以刚才的例子来说:
0 1 2 3 4 5 6 7 8 9 10
a b c a b a b c a b d
a b c a b d
0 1 2 3 4 5
现在我们发现了不匹配的地方,根据kmp算法,我们只回退模式串,要知道回退的位置,我们刚才的最长前缀后缀就有用处了。我们发现:前面绿色的代码匹配成功的字串。
我们现在把这两个字串的最长前缀后缀标出:
0 1 2 3 4 5 6 7 8 9 10
a b c a b a b c a b d
a b c a b d
0 1 2 3 4 5
现在你发现了什么。
没错,模式串中匹配成功的字串的 0 1 下标 和 3 4 下标互为最长前缀后缀,即 0 1 下标 的字符与 3 4 下标的字符相等,即模式串的的0 1 下标与 文本串中的 3 4 下标 相等,所以我们移动模式串,让模式串已经匹配成功的字串的 前缀 与 文本串中的后缀对应:
0 1 2 3 4 5 6 7 8 9 10
a b c a b a b c a b d
a b c a b d
0 1 2 3 4 5
于是我们得出,模式串匹配失败后的回退的位置 为 最长前后缀的 前缀 的后一个位置,也就是前缀/后缀的长度当前例子为 2
如果理解了这一步,kmp 的关键你就已经掌握了。
接下来就是一个重复的过程了,模式串中每一个字符的前面的字串都有最长前缀后缀,而且最长相等前后缀的长度是我们移位的关键,所以我们用一个next数组记录下每个字符前面的字串的最长前缀后缀的长度,即在该字符匹配失败后模式串回退的位置。
例如:a b c a b d 的next数组为:
下标: 0 1 2 3 4 5
模式串: a b c a b d
next数组: -1 0 0 0 1 2
注意:在第一个位置 为 -1 做特殊处理,下面会详细讲解。最长前后缀为空字符串即长度为0。
下面做一个练习:
abcababcabca的next数组为?
答案:
-1 0 0 0 1 2 1 2 3 4 5 3
将next数组用代码计算出来:
我们发现,next 的值一定是序数递增的,不会由 0 直接到2,只能先到1 再到2。
举个例子:
0 1 2 3 4 5
a b c a b d
-1 0 0 0 1 2
5 位置前的字串 最长前缀后缀为 a b 长度为2,那 4 前面的字串的最长前缀后缀一定为 a
如果4 前面 的最长前缀后缀为 0 即 0 位置的字符,与 3 位置的字符没有匹配上, 5 前面的字串的 最长前缀后缀 长度要为2 的话只能是 0 1小标和3 4 下标,所以0 3下标必须是匹配的 长度才可能为2。
那么,我们求 next数组,是不是就可以只判断当前的前一个字符是不是和他的最长前缀后缀的长度的对应位置的字符是否相等,如果相等,则当前的next值即为前一个值+1.
举个例子:
0 1 2 3 4 5 6 7 8 9 10 11
a b c a b a b c a b c a
-1 0 0 0
现在要求4下标的next值,我们判断 4 下标的前一个字符 即 3下标的 a 是否和 它的 最长前缀后缀的长度 即对应的next值 即 0 位置的元素 即 a 是否相等 ,显然 a 与 a 相等,所以 4 下标的值应为
0 + 1 = 1
0 1 2 3 4 5 6 7 8 9 10 11
a b c a b a b c a b c a
-1 0 0 0 1
同理,5下标 只需判断 4 下标 下的字符 是否等于 1 下标的字符,显然相等,所以 5 下标下的值应该为 1 + 1 = 2。
0 1 2 3 4 5 6 7 8 9 10 11
a b c a b a b c a b c a
-1 0 0 0 1 2
那如果比较的字符不相等呢?
0 1 2 3 4 5 6 7 8 9 10 11
a b c a b a b c a b c a
-1 0 0 0 1 2 1 2 3 4 5
如现在求11下标的next值,我们比较 10 下标的 字符和 5 下标的字符 发现并不相等。
注意!!此时我们继续 判断 5 下标下的 next 的值 即 2 对应位置的字符是否和 10 下标下的字符相等,显然 c == c ,所以 10 下标下的 next 值即为 2 + 1 = 3.
以下为证明过程,理解不了直接记下结论即可
解释:因为我们尝试在 11 的前面找比 5 更长的最长前后缀,显然 匹配失败了,代表找不到,所以我们,继续向前,找“最长前后缀的前后缀的长度”,即在 abcab中找最长前缀后缀,即 5 下标下的next 值 2,由于 10 下标前的 最长前缀后缀的长度是知道的 ,即 5 ,说明 0 到 4 下标与 5 到 9 下标是匹配的,先找到 0 到 4 下标字串的最长前缀后缀 的长度为 ,2 那说明 0 1 下标 与 2 4 下标匹配,又因为 0 4 下标与 5 9 下标匹配 ,所以 ,0 1 下标 与 8 9 下标匹配,所以我们直接判断,2 下标与10 下标下的字符是否相等,相等则该下标对应的next 值应为 2 + 1 = 3. 如果不相等 则 继续 用 2 对应的 next 值往下找直到 为 -1 时做特殊处理
代码实现:
c语言版:
int* getNext(char* s, int len)
{
//申请一块内存用于返回next数组
int* next = (int*)malloc(4 * len);
next[0] = -1; //处理特殊值,以便代码实现
next[1] = 0; //第二个字符匹配失败一定是回到 0 位置开始比较
int k = 0; // 代表前一个下标的 next 值,我们从2开始,所以k初始为next[1] = 0
int i = 2;
while (i < len)
{
//判断当前位置前一个字符是否等于 k 位置的 字符
if (s[i - 1] == s[k])
{
//相等则当前位置的next值为 k + 1
next[i] = k + 1;
//让 i++
i++;
//让k的值更新
k++;
}
else
{
//匹配失败,让当前字符与,next[k] 下标下的字符进行比较
//我们这里直接更新k的值,在下一次循环比较,注意不需要 i++
k = next[k];
}
}
return next;
}
注意如果一直匹配失败会导致 k 的值 为 -1 导致 上面 数组越界
所以我们在上方的if中做特殊处理
int* getNext(char* s, int len)
{
int* next = (int*)malloc(4 * len);
next[0] = -1; //处理特殊值,以便代码实现
next[1] = 0; //第二个字符匹配失败一定是回到 0 位置开始比较
int k = 0; // 代表前一个下标的 next 值,我们从2开始,所以k初始为next[1] = 0
int i = 2;
while (i < len)
{
//判断当前位置前一个字符是否等于 k 位置的 字符,注意处理 k == -1
if (k == -1 || s[i - 1] == s[k])
{
//相等则当前位置的next值为 k + 1
next[i] = k + 1;
//让 i++ 进入下次循环继续获取next数组
i++;
//让k的值更新
k++;
}
else
{
//匹配失败,让当前字符与,next[k] 下标下的字符进行比较
//我们这里直接更新k的值,在下一次循环比较,注意不需要 i++
k = next[k];
}
}
return next;
}
我们让k等于-1的时候也进入if内部,当k等于-1说明,当前i位置的字符前面最长前缀后缀的长度为0,此时我们给 next[i] 赋的值为 -1 + 1 = 0,所以这就是我们为什么要把next[0] 设置为 -1
现在我们的 kmp 算法以及完成大半了,只需再写一个 函数用来比较两个字符串,在匹配失败的时候用next数组找到 模式串回退的位置,然后继续比较即可。
代码实现:
int KMP(char* s1, char* s2, int len1, int len2)
{
int* next = getNext(s2, len2);
int i = 0;
int j = 0;
while (i < len1 && j < len2)
{
//注意处理j为-1的情况
if (j == -1 || s1[i] == s2[j]) {
i++;
j++;
}
else
{
j = next[j];
}
}
if (j == len2)
{
//j == len2 说明匹配成功了,返回s1中匹配成功时,匹配的第一个字符的位置
return i - j;
}
//匹配失败返回-1
return -1;
}
至此我们的 kmp 算法就完成了,如果觉得对你有帮助的话,请给一个免费的赞,有什么问题也可以在下方讨论。
Java代码:
//求next数组
public static int[] getNext(String s) {
int n = s.length();
int[] next = new int[n];
next[0] = -1;//处理特殊值,以便代码实现
int i = 2;//第二个字符匹配失败一定是回到 0 位置开始比较所以直接从 2 位置开始
int k = 0; // 代表前一个下标的 next 值,我们从2开始,所以k初始为next[1] = 0
while (i < n) {
//判断当前位置前一个字符是否等于 k 位置的 字符,注意处理 k == -1
if(k == -1 || s.charAt(i-1) == s.charAt(k)) {
//相等则当前位置的next值为 k + 1
next[i] = k + 1;
//让k的值更新
k++;
//让 i++ 进入下次循环继续获取next数组
i++;
}else {
//匹配失败,让当前字符与,next[k] 下标下的字符进行比较
//我们这里直接更新k的值,在下一次循环比较,注意不需要 i++
k = next[k];
}
}
return next;
}
public static int KMP(String s1, String s2) {
int n = s1.length();
int m = s2.length();
int i = 0;
int j = 0;
int[] next = getNext(s2);
while(i < n && j < m) {
//注意处理 j == -1
if(j == -1 || s1.charAt(i) == s2.charAt(j)) {
i++;
j++;
}else{
j = next[j];
}
}
if(j == m) {
return i - j;
}
return -1;
}