一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情。
KMP算法简介
KMP算法是一种字符串匹配算法,可以在的时间复杂度内实现两个字符串的匹配。(暴力解法时间复杂度为)
前置概念介绍
- 前缀:包含首位字符,但不包含尾字符的连续字符串。(abcdefg 的其中一个前缀 abcd,但是 abcdefg 就不是前缀,因为包含了尾字符g)例如,”Harry”的前缀包括{”H”, ”Ha”, ”Har”, ”Harr”},我们把所有前缀组成的集合,称为字符串的前缀集合。要注意的是,字符串本身并不是自己的前缀。
- 后缀:包含末位字符,但不包含首字符的连续字符串。(abcdefg 的其中一个后缀 defg,但是 abcdefg 就不是后缀,因为包含了首字符a)例如,”Potter”的后缀包括{”otter”, ”tter”, ”ter”, ”er”, ”r”},然后把所有后缀组成的集合,称为字符串的后缀集合。要注意的是,字符串本身并不是自己的后缀。
next[]数组定义:记录当主串与模式串的某一位字符不匹配时,模式串要回退的位置。(也就是前缀表,最长相等的前后缀)next[j]:其值为 第j位字符前面j - 1位字符组成的子串的前后缀重合字符数。 (根据定义的不同,有的写法会让next数组统一右移或者统一加一、减一操作,所以根据具体定义来决定next数组的操作,涉及到的是具体实现,不涉及具体原理,用哪一个定义都可以完成KMP算法工作,不用纠结于使用哪一个方法实现)- 例如下图pmt(部分匹配表(Partial Match Table))和next两种定义:
对于右移舍去的那位数值(下标 index 为 7)在实现中是用不到的,如果是在 j 位 失配,那么影响 j 指针回溯的位置的其实是第 j −1 位的 pmt 值,所以为了编程的方便,我们不直接使用 pmt 数组,而是将 pmt 数组向后偏移一位。我们把新得到的这个数组称为 next 数组。下面给出根据 next 数组进行字符串匹配加速的字符串匹配程序。其中要注意的一个技巧是,在把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义。
- 例如下图pmt(部分匹配表(Partial Match Table))和next两种定义:
KMP算法核心
KMP算法的核心是在模式串中获得next数组,这个数组表示模式串的子串的前缀和后缀相同的最长长度,这样就可以在匹配的过程中如果遇到不匹配的字符,模式串用next数组进行递归跳转到最长符合的位置进行继续匹配,从而不需要目标串进行重复的往返匹配。
简而言之,以图中的例子来说,在 i 处失配,那么主字符串和模式字符串的前边6位就是相同的。又因为模式字符串的前6位,它的前4位前缀和后4位后缀是相同的,所以我们推知主字符串i之前的4位和模式字符串开头的4位是相同的。就是图中的灰色部分,那这部分就不用再比较了。
如果Pk ≠ Pj(当前字符不匹配的情况),那么next[j + 1]可能的次大值为next[next[j]] + 1,以此类推即可高效求出next[j + 1]。(重点,递归回溯思想)
代码实现部分
获取next数组: (因为在程序中next可能会与某些关键字或函数冲突,所以代码中用的是nxt表示)
- 写法一(推荐):巧妙通过while循环和if/else判断条件做到了递归
void getNext(char p[], int *nxt[]){
nxt[0] = -1;//把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义。
int i = 0, j = -1;
while (i < (int)strlen(p)){
if (j == -1 || p[i] == p[j]){
//相等的话就继续
next[++i] = ++j;
}
else{
//不相等就利用已经求出来的nxt数组进行跳转
j = next[j];
}
}
}
这里还能继续 优化 next[] 数组的跳转,按照上述代码,我们可以看到匹配的时候可能出现这种情况:
不难发现,这样的回溯跳转是完全没有意义的。因为后面的字符
B 已经不匹配了,那前面的字符 B 也一定是不匹配的,同样的情况其实还发生在元素 A 上:
显然,发生问题的原因在于 p[j] == p[next[j]] 上,所以我们也只需要添加一个判断条件即可:
void getNext(char p[], int *nxt[]){
nxt[0] = -1;//把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义。
int i = 0, j = -1;
// 注意越界问题 i < (int)strlen(p) - 1
while (i < (int)strlen(p) - 1){
if (j == -1 || p[i] == p[j]){
//相等的话就继续
//nxt[++i] = ++j;
//优化前,原本只有上面一句
i++;
j++;
if(p[i] == p[j]) nxt[i] = nxt[j];
else nxt[i] = j;
//优化后在回溯跳转的时候会回溯跳转到首次与当前字符不一样字符的位置
//避免了跳转到和当前字符一样的位置进行重复判断
}
else{
//不相等就利用已经求出来的nxt数组进行跳转
j = next[j];
}
}
}
上述代码优化后在回溯跳转的时候会回溯跳转到首次与当前字符不一样字符的位置,避免了跳转到和当前字符一样的位置进行重复判断
- 写法二:利用for循环和while循环直观递归思想
void getNext(char p[], int *nxt[]){
nxt[1] = 0;
for(int i = 2, j = 1; i <= n; j++, i++){
nxt[i] = j;
while(j && s1[j] != s1[i]) j = nxt[j];
}
}
字符串匹配:
int KMP(char * t, char * p) {
int i = 0;
int j = 0;
while (i < (int)strlen(t) && j < (int)strlen(p)){
if (j == -1 || t[i] == p[j]){
i++;
j++;
}
else j = next[j];
}
if (j == strlen(p)) return i - j;
else return -1;
}
总结
KMP算法的核心是前缀表数组nxt。只要理解了这个前缀表的意义,其实整个算法都迎刃而解。其中要注意的一个技巧是,在把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义。
结束语
每个人在生活中,都会或多或少走一些弯路。但只要我们把脚下的路走得踏实稳健,不虚度每一分光阴,那么即使是弯路,最终也会变成我们走向更好人生的铺垫。努力走好每一步,就是对自己最好的成全。