目录
引言:
KMP算法是由 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位计算机科学家共同提出,称之为Knuth-Morria-Pratt算法,简称 KMP 算法。
该算法相对于 Brute-Force(暴力)算法有比较大的改进,主要是消除了主串指针的回溯,从而使算法效率有了某种程度的提高。
选择原因:
KMP的实现,需要熟练的代码能力和抽象思维能力,可以很好的锻炼数学思考能力,代码能力,抽象思维能力。因此选择KMP算法。
总体要求:
理解并且掌握KMP算法,理解最大元素长度数组的求法与过程,理解Next数组的求法与过程,理解Next数组优化的思想与过程。
暴力算法:
问题假设:有一串文本主串S,和一串模式子串P。我们要求,判断出子串P是否存在S主串中,如果存在,返回子串P在主串S的位置。不存在返回NULL。
如果使用暴力算法来解决这个问题,大致分为两个步骤:
假设:文本主串S匹配到i位置,模式子串P匹配到j位
- 判断S[i]与P[i]是否相等,如果相等i++,j++;
- 如果不相等,令i与j回溯,i=i-j+1,j=0;
于此,我们可以写出代码:
#include<stdio.h>
int violentmatch(char* S, char* P) {
int len1 = strlen(S);
int len2 = strlen(P);
int i = 0, j = 0;
while (i < len1 && j < len2) {
if (S[i] == P[j]) {
i++;
j++;
} else {
i = i - j + 1;//i需要回溯到主串的第n+1个 n=0;
}
}
if (j == len2) {
return i - j;
} else {
return -1;
}
}
得出代码后,我们进行过程分析:
- 假设:现在主串S为ABEABDABCE,字串L为ABC
- 通过表格分析,我们可以知道暴力算法的具体过程,红色代表匹配成功的字符,蓝色代表匹配失败的位置
ABEABDABCE | A |
ABEABDABCE | B |
ABEABDABCE | C 匹配失败 |
ABEABDABCE | A 匹配失败 |
ABEABDABCE | A 匹配失败 |
ABEABDABCE | A |
ABEABDABCE | B |
ABEABDABCE | C 匹配失败 |
...... | ...... |
ABEABDABCE | A |
ABEABDABCE | B |
ABEABDABCE | ABC匹配成功 |
通过代码与图文的分析后,我们可以明确暴力算法的一个缺点,时间过长。
通过计算,可以得出他的时间复杂度:
- 设文本主串长为M,模式子串长为N,同时M比N大的多
- 在最优情况下,主串与子串的失配都是发生在第一个字符处,时间复杂度为O(m+n)。
- 在最坏的情况下,即子串每一次与母串失配都是在最后一个字母时,时间复杂度为O(m*n)。
造成时间过长的原因是,每次子串与主串匹配失败,都需要回溯到最初i-j+1这个位置。
这时候,我们引出可以让i 不往回退,只需要移动j 即可的算法,KMP算法。
KMP算法:
假设:
文本主串S:ABEABADABABD
文本子串P:ABABD
KMP步骤:
假设:文本主串S匹配到i位置,模式子串P匹配到j位
- 判断S[i]与P[i]是否相等或者j是否等于-1,如果相等i++,j++;
- 如果S[i]与P[j]不相等,则令j=next[j];此举意味着匹配失败时,子串P相对于主串S向右移动了j - next [j] 位。
- 也就是说,当匹配失败时,子串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j - next[j],且此值大于等于1。
next 数组各数值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀(最大公共元素长度)。
例如如果next [j] == k,代表j 之前的字符串中有最大公共元素长度为k 的相同前缀后缀。
这也意味着在某个字符失配时,该字符对应的next 值会告诉你下一次匹配时,子串应该跳到哪个位置(跳到next [j] 的位置)。
如果next [j] 等于0或-1,则跳到子串的开头字符,若next [j] == k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
代码如下:
int KMPalgorithm(char* S, char* P) {
int len1 = strlen(S);
int len2 = strlen(P);
int i = 0, j = 0;
while (i < len1 && j < len2) {
if (j == -1 && S[i] == P[j]) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == len2) {
return i - j;
} else {
return -1;
}
}
得出代码后,我们进行过程分析,此处不涉及next[j]求解,将在后面详细叙述:
ABEABADABABD ABABD |
当匹配到P[2]时,P[2]!=S[2];
此时执行j=next[j];
ABEABADABABD ABABD |
Next[2]==0,所以j从2变成0;
子串向右移动j-next[2],即2-0步;
ABEABADABABD ABABD |
再次失配,j=next[0];
Next[0]=-1,所以j变为-1;
子串向右移动j-next[0],即1步;
ABEABADABABD ABABD |
再次失配,j=next[3];
Next[3]=1,所以j变为1;
子串向右移动j-next[3]=2,即2步;
ABEABADABABD ABABD |
再次失配,同上可得j-next[1]=1;
ABEABADABABD ABABD |
同上可得;
ABEABADABABD ABABD |
看到这里,基本的流程已经通过图文详细说明。但是也产生了新的问题,Next[j]是怎么求出的?等各种问题,下文将继续引入新的概念并解答Next数组的求解,
寻找前缀后缀最长公共元素长度:
对于P = p0,p1,p2, ...,p[j-1],p[j],寻找子串P中长度最大且相等的前缀和后缀。
如果存在p0,p1, ...,p[k-1], p[k] = p[j-k],p[j-k+1],...,p[j-1],p[j];那么在包含p[j]的子串中有最大长度为k+1的相同前缀后缀。
举个例子,假设:
文本主串S:ABEABADABABD
文本子串P:ABABD
字串 | 前缀 | 后缀 | 最大公共元素长度 |
A | NULL | NULL | 0 |
AB | A | B | 0 |
ABA | A,AB | A,BA | 1 |
ABAB | A,AB,ABA | B,AB,BAB | 2 |
ABABD | A,AB,ABA,ABAB | D,BD,ABD,BABD | 0 |
由上表,我们可以知道。最大公共元素长度其实就是前缀与后缀相同的最大长度。通过这个最大公共元素长度,我们可以得出一个非常关键的表,最大长度表。
字串 | A | B | A | B | D |
最大公共元素长度 | 0 | 0 | 1 | 2 | 0 |
由上文可得最大公共元素表的求法,我们继续使用上述例子,来求解Next数组;
假设:
文本主串S:ABEABADABABD
文本子串P:ABABD
字串 | 前缀 | 后缀 | 最大公共元素长度 |
A | NULL | NULL | 0 |
AB | A | B | 0 |
ABA | A,AB | A,BA | 1 |
ABAB | A,AB,ABA | B,AB,BAB | 2 |
ABABD | A,AB,ABA,ABAB | D,BD,ABD,BABD | 0 |
字串 | A | B | A | B | D |
最大公共元素长度 | 0 | 0 | 1 | 2 | 0 |
根据上文:
失配时,模式串向右移动的位数为:已匹配字符数 - 失配字符的上一位字符所对应的最大长度值
结合最大公共元素长度表可得Next表:
字串 | A | B | A | B | D |
Next数组 | -1 | 0 | 0 | 1 | 2 |
把next 数组跟之前求得的最大长度表对比后,不难发现,next 数组相当于“最大长度值” 整体向右移动一位,然后初始值赋为-1。
这时候,我们会发现,无论是基于最大长度表的匹配还是基于Next表的匹配都是一模一样的;
- 根据最大长度表,失配时,子串向右移动的位数 = 已经匹配的字符数 - 失配字符的上一位字符的最大长度值
- 根据Next 数组, 失配时,子串向右移动的位数 = 失配字符的位置 - 失配字符对应的next 值
- 其中,从0开始计数时,失配字符的位置 = 已经匹配的字符数(失配字符不计数),而失配字符对应的next 值 = 失配字符的上一位字符的最大长度值,两相比较,结果必然完全一致。
字串 | A | B | A | B | D |
最大公共元素长度 | 0 | 0 | 1 | 2 | 0 |
Next数组 | -1 | 0 | 0 | 1 | 2 |
前文已经通过最大公共元素表,来推出Next数组的具体值。
接下来,我们将通过代码实现Next数组的快速求解。
对于P的前j+1个序列字符:
- 若p[k] == p[j],则Next[j+1] = Next [j] + 1 = k + 1;
- 若p[k ] != p[j],如果此时p[Next[k]] == p[j],则Next[ j + 1 ] = Next[k] + 1,否则继续递归前缀索引k = Next[k],而后重复此过程。 相当于在字符p[j+1]之前不存在长度为k+1的前缀"p[0] p[1], …, p[k-1] p[k]"跟后缀“p[j-k] p[j-k+1], …, p[j-1] p[j]"相等,那么是否可能存在另一个值t+1 < k+1,使得长度更小的前缀 “p[0] p[1], …, p[t-1] p[t]” 等于长度更小的后缀 “pj-t pj-t+1, …, pj-1 pj” 呢?如果存在,那么这个t+1 便是Next[ j+1]的值,此相当于利用已经求得的next 数组(Next [0, ..., k, ..., j])进行P串前缀跟P串后缀的匹配。
代码如下:
void GetNext(char* P, int* next) {
int Len = strlen(p);
next[0] = -1;
int k = -1;
int j = 0;
while (j < Len - 1) {
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k]) {
++k;
++j;
next[j] = k;
} else {
k = next[k];
}
}
}
将上述代码与文字结合后,可以得出更容易理解的综述:、
- 当j的值为 0 或 1 的时候,它们的k值都为0,即 next[0] = 0、next[1] =0。但是为了后面 k 值计算的方便,我们将 next[0]的值设置成 -1。
- 当 P[j] == P[k] 的情况,观察下图可知,当 P[j] == P[k] 时,必然有"P[0]…P[k-1]" == " P[j-k]…P[j-1]",此时的 k 即是相同子串的长度。因为有"P[0]…P[k-1]" == " P[j-k]…P[j-1]",且 P[j] == P[k],则有"P[0]…P[k]" == " P[j-k]…P[j]",这样也就得出了next[j+1]=k+1。
- 当P[j] != P[k]时, 由第2中情况可知,当 P[j] == P[k] 时,P[j+1] 的最大子串的长度为 k,即 Next[j+1] = k+1。但是此时P[j] != P[k] 了,所以就有 Next[j+1] < k,那么求 Next[j+1] 就等同于求 P[j] 往前小于 k 个的字符(包括P[j])与 P[k] 前面的字符的最长重合串,即 P[j-k+1] 至P[j] 与 P[0] 至 P[k-1] 的最大公共元素串,那么就相当于求 Next[k](只不过 P[k] 变成了 P[j],但是 Next[k] 的值与 P[k] 无关)。所以才有了这句 k = Next[k],如果新的一轮循环(这时 k = Next[k] ,j 不变)中 P[j] 依然不等于 P[k] ,则说明倒数第二大 P[0至Next[k]-1] 也不行,那么 k 会继续被 Next[k] 赋值,直到找到符合重合的子串或者 k == -1。
通过前文,我们已经能够很好的理解了,KMP算法的核心步骤,但是这其中却依然存在着一些缺陷。如下图:
显然,我们可以得出Next数组是[ -1,0,0,1 ],那么我们应该将子串向右移动两步。
此时,我们发现再次失配,这一步没有任何的意义,因为后面的B既然失配,那么前面的B也必然失配。那么问题出现在哪呢?
问题出在,P[j] = P[ next[j] ]。
当P[j] != S[i] 时,下次匹配必然是P[ Next [j]] 跟S[i]匹配,如果P[j] = P[ next[j] ],必然导致后一步匹配失败(因为P[j]已经跟S[i]失配,然后你还用跟P[j]等同的值P[next[j]]去跟S[i]匹配,很显然,必然失配),所以不能允许P[j] = P[ next[j ]]。如果出现了P[j] = P[ next[j] ]则需要再次递归,即令Next[j] = Next[ Next[j] ]。
优化后的代码如下:
//优化过后的next 数组求法
void GetNextval(char* p, int* next) {
int Len = strlen(p);
next[0] = -1;
int k = -1;
int j = 0;
while (j < Len - 1) {
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k]) {
++j;
++k;
//较之前next数组求法,改动在下面4行
if (p[j] != p[k])
next[j] = k; //之前只有这一行
else
//因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
next[j] = next[k];
} else {
}
}
}
背景介绍:1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,比KMP算法的实际效能高。
BM算法定义了两个规则:
坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果"坏字符"不包含在模式串之中,则最右出现位置为-1。
好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
下面举例说明BM算法。例如,给定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。
首先,"文本串"与"模式串"头部对齐,从尾部开始比较。"S"与"E"不匹配。这时,"S"就被称为"坏字符"(bad character),即不匹配的字符,它对应着模式串的第6位。且"S"不包含在模式串"EXAMPLE"之中(相当于最右出现位置是-1),这意味着可以把模式串后移6-(-1)=7位,从而直接移到"S"的后一位。
依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在模式串"EXAMPLE"之中。因为“P”这个“坏字符”对应着模式串的第6位(从0开始编号),且在模式串中的最右出现位置为4,所以,将模式串后移6-4=2位,两个"P"对齐。
依次比较,得到 “MPLE”匹配,称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好后缀。
发现“I”与“A”不匹配:“I”是坏字符。如果是根据坏字符规则,此时模式串应该后移2-(-1)=3位。
更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
所有的“好后缀”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的头部出现,所以后移6-0=6位。
可以看出,“坏字符规则”只能移3位,“好后缀规则”可以移6位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。
Sunday算法的思想和BM算法中的坏字符思想非常类似。
差别只是在于Sunday算法在失配之后,是取目标串中当前和模式串对应的部分后面一个位置的字符来做坏字符匹配。
下标数:01234567890
目标串:abcdefghijk
模式串:bxcd
BM算法在b和x失配后,坏字符为b(下标1),在模式串中寻找b的位置,找到之后就对应上,移到下面位置继续匹配。
目标串:abcdefghijk
模式串: bxcd
而在sunday算法中,对于上面的匹配,发现失配后,是取目标串中和模式串对应部分后面的一个字符,也就是e,然后用e来做坏字符匹配。
e在模式串中没有,所以使用sunday算法,接下来会移动到下面的位置继续匹配。
目标串:abcdefghijk
模式串: bxcd
从这里可以看出,Sunday算法比BM算法的位移更大,所以Sunday算法比BM算法的效率更高。但是最坏的时间复杂度仍然有o(目标串长度*模式串长度)。
考虑这样的目标串:baaaabaaaabaaaabaaaa,要在里面搜索aaaaa,显然是没有匹配位置。但是如果用Sunday算法,坏字符大部分都是a,而模式串中又全部都是a,所以在大部分情况下,发现失配后模式串只能往右移动1位。而如果用改进的KMP算法,仍然是可以保证线性时间内匹配完。
另外,使用Sunday算法不需要固定地从左到右匹配或者从右到左的匹配(这是因为失配之后我们用的是目标串中后一个没有匹配过的字符), 我们可以对模式串中的字符出现的概率事先进行统计,每次都使用概率最小的字符所在的位置来进行比较,这样失配的概率会比较大,所以可以减少比较次数,加快匹配速度。
如下面的例子:
目标串:abcdefghijk
模式串:aabcc
模式串中b只出现了一次,a,c都出现了2次,所以我们可以先比较b所在的位置(只看模式串中的字符的话,b失配的概率会比较大)。
总之,Sunday算法简单易懂,思维跳出常规匹配的想法,从概率上来说,其效率在匹配随机的字符串时比其他匹配算法还要更快。
附上代码(代码与结果来自网络):
#include <stdio.h>
#include <string.h>
bool BadChar(const char* pattern, int nLen, int* pArray, int nArrayLen) {
if (nArrayLen < 256) {
return false;
}
for (int i = 0; i < 256; i++) {
pArray[i] = -1;
}
for (int i = 0; i < nLen; i++) {
pArray[pattern[i]] = i;
}
return true;
}
int SundaySearch(const char* dest, int nDLen,
const char* pattern, int nPLen,
int* pArray) {
if (0 == nPLen) {
return -1;
}
for (int nBegin = 0; nBegin <= nDLen - nPLen; ) {
int i = nBegin, j = 0;
for ( ; j < nPLen && i < nDLen && dest[i] == pattern[j]; i++, j++);
if (j == nPLen) {
return nBegin;
}
if (nBegin + nPLen > nDLen) {
return -1;
} else {
nBegin += nPLen - pArray[dest[nBegin + nPLen]];
}
}
return -1;
}
void TestSundaySearch() {
int nFind;
int nBadArray[256] = {0};
// 1 2 3 4
//0123456789012345678901234567890123456789012345678901234
const char dest[] = "abcxxxbaaaabaaaxbbaaabcdamno";
const char pattern[][40] = {
"a",
"ab",
"abc",
"abcd",
"x",
"xx",
"xxx",
"ax",
"axb",
"xb",
"b",
"m",
"mn",
"mno",
"no",
"o",
"",
"aaabaaaab",
"baaaabaaa",
"aabaaaxbbaaabcd",
"abcxxxbaaaabaaaxbbaaabcdamno",
};
for (int i = 0; i < sizeof(pattern) / sizeof(pattern[0]); i++) {
BadChar(pattern[i], strlen(pattern[i]), nBadArray, 256);
nFind = SundaySearch(dest, strlen(dest), pattern[i], strlen(pattern[i]),
nBadArray);
if (-1 != nFind) {
printf("Found \"%s\" at %d \t%s\r\n", pattern[i], nFind, dest + nFind);
} else {
printf("Found \"%s\" no result.\r\n", pattern[i]);
}
}
}
int main(int argc, char* argv[]) {
TestSundaySearch();
return 0;
}
代码运行结果:
Found "a" at 0 abcxxxbaaaabaaaxbbaaabcdamno
Found "ab" at 0 abcxxxbaaaabaaaxbbaaabcdamno
Found "abc" at 0 abcxxxbaaaabaaaxbbaaabcdamno
Found "abcd" at 20 abcdamno
Found "x" at 3 xxxbaaaabaaaxbbaaabcdamno
Found "xx" at 3 xxxbaaaabaaaxbbaaabcdamno
Found "xxx" at 3 xxxbaaaabaaaxbbaaabcdamno
Found "ax" at 14 axbbaaabcdamno
Found "axb" at 14 axbbaaabcdamno
Found "xb" at 5 xbaaaabaaaxbbaaabcdamno
Found "b" at 1 bcxxxbaaaabaaaxbbaaabcdamno
Found "m" at 25 mno
Found "mn" at 25 mno
Found "mno" at 25 mno
Found "no" at 26 no
Found "o" at 27 o
Found "" no result.
Found "aaabaaaab" no result.
Found "baaaabaaa" at 6 baaaabaaaxbbaaabcdamno
Found "aabaaaxbbaaabcd" at 9 aabaaaxbbaaabcdamno
Found "abcxxxbaaaabaaaxbbaaabcdamno" at 0 abcxxxbaaaabaaaxbbaaabcdamno
测试分析:
int KmpSearch(char* s, char* p, int* next) {
int i = 0;
int j = 0;
int sLen = strlen(s);
int pLen = strlen(p);
while (i < sLen && j < pLen) {
//①如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++
if (j == -1 || s[i] == p[j]) {
i++;
j++;
} else {
//②如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]
//next[j]即为j所对应的next值
j = next[j];
}
}
if (j == pLen)
return i - j;
else
return -1;
}
//优化过后的next 数组求法
void GetNextval(char* p, int* next) {
int pLen = strlen(p);
next[0] = -1;
int k = -1;
int j = 0;
while (j < pLen - 1) {
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k]) {
++j;
++k;
//较之前next数组求法,改动在下面4行
if (p[j] != p[k])
next[j] = k; //之前只有这一行
else
//因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
next[j] = next[k];
} else {
k = next[k];
}
}
}
int main() {
char s[] = "abcdabcabcab";
char p[] = "abcab";
int next[100] = {-1};
GetNextval(p, next);
printf("%d", KmpSearch(s, p, next));
return 0;
}
时间复杂度分析:
KMP步骤:
假设:文本主串S匹配到i位置,模式子串P匹配到j位
- 判断S[i]与P[i]是否相等或者j是否等于-1,如果相等i++,j++;
- 如果S[i]与P[j]不相等,则令j=next[j];此举意味着匹配失败时,子串P相对于主串S向右移动了j - next [j] 位。
- 也就是说,当匹配失败时,子串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j - next[j],且此值大于等于1。
我们发现如果某个字符匹配成功,子串首字符的位置保持不动,仅仅是i++、j++;如果匹配失配,i 不变(即 i 不回溯),子串会跳过匹配过的Next [j]个字符。整个算法最坏的情况是,当子串首字符位于i - j的位置时才匹配成功,算法结束。
所以,如果文本主串的长度为N,模式子串的长度为M,那么匹配过程的时间复杂度为O(n),算上计算Next的O(m)时间,KMP的整体时间复杂度为O(m + n)。
总结:在研究KMP算法的过程中,发现数学思维对于数据结构的理解,起到了很关键的作用。
对于字符串匹配的各种方法,无论是库函数的Strtr也好,又或者是Sunday算法,KMP,BM算法。都需要一花时间去理解与思考。
对于数据结构来说,这里只是入门。还有很多需要我们去学习的地方,所以要继续努力。
参考文献
- 《算法导论》的第十二章:字符串匹配;
- 字符串匹配的KMP算法 - 阮一峰的网络日志;
- 字符串匹配的Boyer-Moore算法 - 阮一峰的网络日志;
- 《数据结构 第二版》严蔚敏 & 吴伟民编著;
- 从头到尾彻底理解KMP(2014年8月22日版)
- KMP算法—终于全部弄懂了
- 六之续、由KMP算法谈到BM算法_结构之法 算法之道-CSDN博客;
- 经典算法研究系列:六、教你初步了解KMP算法、updated_结构之法 算法之道-CSDN博客;
- KMP算法[个人理解与笔记]
- KMP算法详解-彻底清楚了(转载+部分原创) - sofu6 - 博客园
- https://blog.csdn.net/sunnianzhong/article/details/8820123