KMP算法及JavaScript实现
KMP模式匹配算法用于在文本串中快速查找模式串是否存在。
重点就在于构建最大相同前缀后缀数组。
当匹配出现不同时,模式串不是再次从头开始,而是利用前缀后缀相同的特点,从指定位置出发开始匹配。
示例如下:
-
在匹配过程中,D与空格不匹配时
-
普通做法,模式串指针指向起点,重新从头开始匹配:
-
使用KMP算法,利用前缀后缀的一致性。对于模式串中D的前一位B为止的字符串ABCDAB来说,最大相同前缀后缀长度为2,即前缀中的AB与后缀中的AB是相同的。利用这一特点,因为模式串后缀的AB与文本串中空格前面的的AB是匹配的,所以根据ABCDAB的最大相同前缀后缀长度来移动模式串上的指针如下:
注意,当不匹配出现时,说明前面部分的字符串是匹配的,所以要访问前面字符串的最大相同前缀后缀长度,不包括不匹配位置,即上例中,是利用ABCDAB的最大相同前缀后缀长度,而不是ABCDABD的。
注意, 这样的移动就是只是移动指针的位置。
思路:
1. 先对模式串进行遍历,建立模式串的最大相同前缀后缀数组。
前缀在这里表示为第一个字符开头,但不包括最后一个字符的字符串。
后缀为最后一个字符结尾,但不包括第一个字符的字符串。
最大相同前缀后缀长度表示前缀后缀相等的最大长度
分析:
- 单个字母的情况视为0, 如 ‘a’ 的最大相同前缀后缀长度就是0。
- 因为前缀不包括末尾字符,后缀不包括开头字符。'aa’的最大相同前缀后缀长度就是1,即前缀是第一个a,后缀是第二个a。
- 要注意不是回文串,比如 ‘aba’ 的最大公共前缀后缀长度也是1,因为ab不等于ba。
这里要对模式串的进行遍历,计算以每个字符为结尾时的最大相同前缀后缀长度,比如abaa,需要以分别求出以第一位的a结尾的最大长度,第2位b结尾的最大长度,第三位a结尾的最大长度,第四位a结尾的最大长度。
下图为一个模式串及其最大相同前缀后缀长度数组的例子:
这里计算的难点就在于如何快速计算出每个字符对应的最大相同前缀后缀长度,并不是对每个字符进行完全的遍历来求值,而是利用前面字符的最大相同前缀后缀长度来进行计算。
快速计算最大相同前缀后缀长度的方法如下所示:
对于模式串中的 a 来说,要计算以 a 为结尾的最大相同前缀后缀长度。
- 首先需要利用 a 之前一位的最大相同前缀后缀长度,即图中的红色部分;表示这一部分前缀与后缀相同。此时对于前缀区域后面一位b来说,如果b与a相同,则a的最大相同前缀后缀长度就是前一位的长度加1。
- 如果不相同,表示无法构成前一位的长度加1的最大相同前缀后缀。就需要利用红色区域即前一位前缀与后缀的相同性,就这个前缀后缀划分为更短的结构。即在相同的前缀后缀长度中寻找更小的相同前缀后缀,如图中黑色区域。
3. 而为了这种符合的黑色区域需要对红色区域进行分析展开。因为这里并不是回文串,而是最大相同前缀后缀,所以不是红色区域中的任意部分构成的黑色区域都满足相等条件。
即,可以看作在下图这种结构中找出相等的最大相同前缀后缀长度(黑色区域)

即图中上面表示后缀,下面的表示前缀,即需要找出满足相等条件的黑色区域,因为这里红色表示最大相同前缀后缀区域,因此上下的字符串是相同的,所以在前缀后缀中分别找子最大相同前缀后缀就等同于在一个部分(前缀或后缀)中找子最大相同前缀后缀,如下所示:
- 然后比较黑色区域所代表的最大相同前缀后缀的下一位是否与a相同,
如果相同,a处的最大相同前缀后缀长度就是黑色区域的长度加1。如果不相同,就对黑色区域进行同样的操作,展开求 子最大相同前缀后缀区域,然后下一位与a比较。直到展开到区域长度为1,即比较首字符与a还不相同时,就赋值0,表示最大相同前缀后缀长度为0。
图解表示如下:
图中的 i 和next数组可以忽视,思想就是这种一层层展开比较的结构。
实现代码:
// 计算next数组 在主函数中移动时取前一位的形式(相当于右移)
function caculateNext(strArr) {
// 先全部赋0
nextArr = new Array(strArr.length).fill(0);
let j;
// 从第一位也就是第二个字符开始比较
for (let i=1; i<strArr.length; i++){
j = nextArr[i-1]
while( j >= 0 && strArr[j] !== strArr[i]){
// 其实这里利用了数组负索引返回undefined因此当j-1小于0时会直接退出循环
// if(j-1<0) {
// j = -1
// break
// }
j = nextArr[j-1];
}
// 如果能继续上一位的相等情况,就在上一位基础上加1,否则就使用默认值0
if (j >= 0) nextArr[i] = j + 1;
}
// // 第一位赋值-1,方便后续的移位操作
// nextArr[0] = -1;
return nextArr
}
分析:
这里初始化一个0数组,后续会根据最大相同前缀后缀长度进行选择性地赋值。
// 先全部赋0
nextArr = new Array(strArr.length).fill(0);
i 指针进行遍历,即指向上图中的a,j 指针负责指向最大相同前缀后缀长度的下一位并与 i 处的字符进行比较。
因为索引是从0开始,所以这里指针指向长度所在的索引刚好就是需要与 i 进行比较的位置。
即j = nextArr[i-1];
时会刚好指向 b 所在位置。因为是要根据前一位的最大相同前缀后缀长度来计算这一位,所以要获取 i -1处的长度值。
while( j >= 0 && strArr[j] !== strArr[i]){
// 其实这里利用了数组负索引返回undefined因此当j-1小于0时会直接退出循环
// if(j-1<0) {
// j = -1
// break
// }
j = nextArr[j-1];
}
这个while循环就表示了不相等时会一直进行展开,直到相等或 j 不符合条件。这里利用了当 j 遍历到 0 时, nextArr[j-1]
的负索引会返回undefined从而退出while循环。
// 如果能继续上一位的相等情况,就在上一位基础上加1,否则就使用默认值0
if (j >= 0) nextArr[i] = j + 1;
这里的if循环判断是匹配成功退出循环还是失败退出循环,若成功就会根据 j 来进行赋值。因为 j 指针既指向待匹配的下一位,又代表了匹配前的的最大相同前缀后缀长度,所以匹配成功后要对长度加1并赋值给数组 i 处。
如果没有相同前缀后缀就使用默认值0. 表示最大相同前缀后缀长度为0。
2. 在文本串中进行查找
这里的实现就是直接利用构建的最大相同前缀后缀长度数组,即next数组来处理当前字符匹配失败的情况。
当前字符匹配失败时,利用前一位字符的next数组值,来移动模式串上的指针。
当前字符匹配成功时,模式串上的指针和文本串上的指针同时进行移动。
while (l_point < l_length && s_point < s_length) {
// 匹配时同时移动
if (longStr[l_point] === shortStr[s_point]) {
++l_point;
++s_point;
}
else {
// 当起始位置也不匹配时,需要在文本串移动
if (s_point === 0) ++l_point;
// 其他情况下不匹配时的移动指针
else s_point = nextArr[s_point-1];
}
}
注意当模式串第一位字符也与文本串当前字符不匹配时,需要移动文本串字符的指针,所以要添加这个判断条件。
s_point = nextArr[s_point-1];
当前字符不匹配时的移位操作与next数组构建时的操作原理相同,要利用前一位的最大相同前缀后缀长度进行移动,并且由于索引从0开始,所以会自动指向待比较的位置。
示例:

上面字符串构建的next数组值如上所示。

当D处不匹配时,j 会指向前一位的B的数组值2

即下图中的c的位置,进行比较。
要注意模式串为空时,返回0表示直接匹配成功。
这样就实现了KMP算法。
代码
function KMP_search(longStr, shortStr) {
const l_length = longStr.length, s_length = shortStr.length;
// longStr表示文本串 shortStr表示模式串
if (l_length < s_length) return -1;
else if (s_length === 0) return 0;
// 计算next数组的函数
nextArr = caculateNext(shortStr);
// 两个指针分别指向文本串和模式串
let l_point = 0, s_point = 0;
while (l_point < l_length && s_point < s_length) {
// 匹配时同时移动
if (longStr[l_point] === shortStr[s_point]) {
++l_point;
++s_point;
}
else {
// 当起始位置也不匹配时,需要在文本串移动
if (s_point === 0) ++l_point;
// 其他情况下不匹配时的移动指针
else s_point = nextArr[s_point-1];
}
}
// 判断是否是匹配成功才退出的循环
if (s_point >= s_length) return l_point - s_length;
else return -1;
}
// 计算next数组 在主函数中移动时取前一位的形式(相当于右移)
function caculateNext(strArr) {
// 先全部赋0
nextArr = new Array(strArr.length).fill(0);
let j;
// 从第一位也就是第二个字符开始比较
for (let i=1; i<strArr.length; i++){
j = nextArr[i-1]
while( j >= 0 && strArr[j] !== strArr[i]){
// 其实这里利用了数组负索引返回undefined因此当j-1小于0时会直接退出循环
// if(j-1<0) {
// j = -1
// break
// }
j = nextArr[j-1];
}
// 如果能继续上一位的相等情况,就在上一位基础上加1,否则就使用默认值0
if (j >= 0) nextArr[i] = j + 1;
}
// // 第一位赋值-1,方便后续的移位操作
// nextArr[0] = -1;
return nextArr
}
复杂度分析:
- 时间复杂度: O ( m + n ) O(m+n) O(m+n),m为文本串长度,n为模式串长度。先遍历模式串建立next数组,再遍历文本串进行比较。
- 空间复杂度: O ( n ) O(n) O(n),建立next数组,长度为n。