KMP的作用
在学习数据结构串部分的时候,我们经常遇到经典题型,字符串匹配。我们立刻会想到采用双层循环遍历的方式去匹配,即当字串与主串出现字符不匹配的时候,主串回溯到第二个字符,字串回溯到第一个字符再进行一一匹配,以此类推,设字串长度为n,母串长度为m,则最坏的时间复杂度为O(m * n),没有占用多余的空间,因此空间复杂度为O(1)。当字符串非常长时,所需时间就会很长。
步入正题:KMP算法。举一个例子吧:字串为abcabcnm,母串为abcabcdabcabcnm,在匹配时
a | b | c | a | b | c | d | a | b | c | a | b | c | n | m |
a | b | c | a | b | c | n | m | |||||||
a | b | c | a | b | c | n | m |
当字串匹配到n时,匹配不成功,因此需要重新匹配,我们发现n前面的字符串abc、abc,因此我们直接使第一个abc与匹配的第二个abc对其进行匹配,就可以减少匹配的次数,减少了时间复杂度。我们怎么知道该向前移动多少位呢,那就需要了解字符串的最长公共前后缀,了解KMP算法的人就知道,其长度就是我们所需的next数组。
最长公共前后缀
了解前缀表与后缀表
前缀表就是字符串当中除了最后一个字符以外的前缀字符串
后缀表就是字符串当中除了第一个字符以外的后缀字符串
以 abca为例
前缀表 | 后缀表 |
a | a |
ab | ca |
abc | bca |
最长公共前后缀
以aabaaf为例:
当指向下表为0的字符时,字符串为a,则最长公共前后缀为0
当指向下表为1的字符时,字符串为aa,则最长公共前后缀为1
当指向下表为2的字符时,字符串为aab,则最长公共前后缀为0
下标 | 0 | 1 | 2 | 3 | 4 | 5 |
字串 | a | a | b | a | a | f |
最长公共前后缀 | 0 | 1 | 0 | 1 | 2 | 0 |
next数组
在上面的解释当中,我们知道了KMP算法当中字串的移动为前面所匹配的字符串的最长公共前后缀。因此我们采用一个next数组存储字串的最长相等前后缀的长度,next数组只与字串相关。
next[i] = j,含义为:下标为i的字符的字符串最长相等前后缀的长度为j
例:ababcnm
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
字串 | a | b | c | a | b | c | n | m |
next[i] | 0 | 0 | 0 | 1 | 2 | 3 | 0 | 0 |
因此明确next数组的作用:
1.next[i]的值表示下标为i的字符前的字符串最长相等前后缀的长度
2.表示该处字符不匹配时应该回溯到的字符的下标
KMP算法的时间复杂度
我们使用KMP算法的时候,先对字串求了next数组,再对主串进行遍历,每次都只使字串与其匹配的位置发生变化。我们设主串s长度为n,字串t的长度为m。求next数组的时间复杂度为O(m),在匹配期间主串不进行回溯,因此比较次数为n,即KMP算法的时间复杂度为O(m + n),空间复杂度为O(m),相比于朴素模式匹配的时间复杂度O(m * n),算法速度提高了许多。
代码分析
next数组的代码
1.定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。然后还要对next数组进行初始化赋值。一开始i为0,则字符串只有一个字符,因此它的最长相等的前后缀长度为0。
next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)所以初始化next[0] = j 。
2.对于循环部分,我们将两个字符进行对比,当两个字符不相同时,也就是遇到前后缀末尾不相同的情况,就要向前回退。
怎么回退呢?
next[j]就是记录着j(包括j)之前的子串的相同前后缀的长度。
那么 s[i] 与 s[j] 不相同,就要找 j 前一个元素在next数组里的值(就是next[j - 1])。
3.如果 s[i] 与 s[j] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。注意赋值前先对j进行加一,因为我们所求的是最大相等前后缀的长度。
private void getNext(int[] next, String s) {
int j = 0;
next[0] = 0;
for (int i = 1; i < s.length(); i++) {
while (j > 0 && s.charAt(j) != s.charAt(i))
j = next[j - 1];
if (s.charAt(j) == s.charAt(i))
j++;
next[i] = j;
}
}
使用next数组匹配的代码
定义两个下标j 指向子串起始位置,i指向母串起始位置。
那么j初始值依然为0,为什么呢? 依然因为next数组里记录的起始位置为0。
接下来就是 s[i] 与 t[j]进行比较。
如果 s[i] 与 t[j] 不相同,j就要从next数组里寻找下一个匹配的位置,以上面的求next数组的思想类似。
int j = 0;
for (int i = 0; i < haystack.length(); i++) {
while (j > 0 && needle.charAt(j) != haystack.charAt(i))
j = next[j - 1];
if (needle.charAt(j) == haystack.charAt(i))
j++;
if (j == needle.length())
return i - needle.length() + 1;
}
return -1;
具体的代码分析在下题的代码标注部分:会有每一步的详解!
例题:
原题连接:
28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
代码如下:
public static int strStr(String haystack, String needle) {
if (needle.length() == 0){
return 0;
} //当我们所查询的字符串为空时,直接返回0
int[] next = new int[needle.length()]; //next数组存放的是下标为i时最长公共前后缀的字符串长度
getNext(next, needle); //此函数来求出next数组
int j = 0;
for (int i = 0; i < haystack.length(); i++) { //i为遍历的主串的结尾位置,j为所要找的字串的结尾位置
while (j > 0 && needle.charAt(j) != haystack.charAt(i)) {
j = next[j - 1];
} //字串进行返回
if (needle.charAt(j) == haystack.charAt(i)) {
j++;
} //当其匹配成功,对j进行加1
if (j == needle.length()) { //代表找到了字串的结尾位置,则代表找到了字串
return i - needle.length() + 1; //i为遍历的母串时的尾部位置,而我们所求的是第一个匹配的下标
}
}
return -1; //由题,没有返回,代表needle 不是 haystack 的一部分,则返回-1
}
public static void getNext(int[] next, String s) {
int j = 0; //使用i,j指针分别指向的是字符串的前缀的末尾与后缀的末尾
next[0] = 0; //一开始,使j初始化为0,因为当i为0时,字符串的最长公共前后缀的字符串长度为0
//并将其初始化给next[0]
for (int i = 1; i < s.length(); i++) {
while (j > 0 && s.charAt(j) != s.charAt(i)) {
j = next[j - 1];
} //此循环是为了找到最长共前后缀时j的位置,j > 0是因为,当j为0时,j = next[j - 1]数组越界
//当不匹配时,j会找前面所求的最大公共前后缀的位置,使其返回到那个位置,进行匹配
if (s.charAt(j) == s.charAt(i)) {
j++;
} //当所找的字符串的前缀与后缀一样时,j指向的是前缀的位置,而我们所求的是前缀的长度,因此要自增1
next[i] = j; //将所求的最长公共前后缀的字符串长度存入数组当中
}
}
}