KMP模式匹配算法
在讲KMP模式匹配算法之前,先来讲讲朴素的模式匹配算法:
朴素的模式匹配算法:
假设现在有一个母串 S=“GoodGoogle"和一个子串T = “Google”,要在母串中找到子串的位置(首字符位置)。那么可以这样做:从S和T的首字符开始遍历,如果首字符相等,就进而比较第二个字符,第二个字符相等,就比较第三个字符......直到成功在母串找到子串(一次成功),或者因为某个字符不同而中断。
从首字符开始直接成功找到子串自然不必多说(这是最理想的状况)。来说说因为字符不同而中断的情况:
母串S = “GoodGoogle”,字串T = “Google",显然,在字符串下标为3(第四个字符)的地方,‘d’ 和 ‘g’ 不同,这个时候就要中断比较。中断之后呢? 就从母串S的第二个字符开始比较(和字串的首字符开始比较,也就是‘ o '和’ G '),直到比较的字符不同为止,继而换母串的第三个字符,继续同字串的第一个字符开始比较起来,如此反复,就能找到字串在母串中的位置。
总结一下:对母串的每一个字符作为字串的开头,与要匹配的字符串进行匹配。对母串做大循环,每个字符开头做子串的小循环,直到匹配成功或者全部遍历完(即未找到)。
源代码大致如下:
1 int Index(char S[], char T[]) 2 { 3 int i = 0, j = 0; //下标从1开始,下标为0的字符用空格代替,不计 4 int lenS = strlen(S) - 1; 5 int lenT = strlen(T) - 1; 6 7 while (i <= lenS && j <= lenT) 8 { 9 if (S[i] == T[j] 10 { 11 ++i; 12 ++j; 13 } 14 else 15 { 16 i = i - (j - 1) + 1; 17 j = 0; //i下移一位,j回到子串开头。 18 } 19 if (j > lenT) //也就是说匹配成功 20 reutrn i - lenT; //返回匹配字串的首字符地址 21 else 22 return 0; //匹配失败 23 }
这个算法的优缺点都很明显,优点显然是易于理解,而缺点即是时间复杂度: (假设字串为m,母串为n),那么最坏的情况是每次遍历都在最后一个不等,直到母串的最后一个元素,也就是说,每次最多比较m次,最多比较(n - m + 1)次。那么时间复杂度也就是O(m * ( n - m + 1)),也就是平方阶。这样的话在比较大量字符串的时候这个算法也就无卵用了。
且不论我们能不能忍受这个低效的算法。对于科学家们来说这是无法忍受的,于是,D.E.Knuth J.H.Morris V.R.Pratt三人便发明了一个模式匹配的算法,也就是KMP算法。
KMP算法:(我想吐槽一下这玩意儿理解起来真难)
利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让母串尽量地移动到有效的位置。
嘛 ,听我慢慢说来。
假设现在有一个母串“abcdefgab" 以及一个子串" abcdex",假设i指针指向母串首字符,j指针指向字串首字符。
接下来,按照前面介绍的模式朴素匹配算法,一个一个进行比较。但是,仔细观察,你会发现,子串"abcdex"的首字母”a"与它后面的"bcdex"都不相同,而子串与母串的前面五位都相同(相匹配),这也就是说,子串的"a"不可能和母串的第二到第五位(也就是”bcde")相同,这样的话,我们就可以把模式朴素匹配算法省略一部分(也就是子串的首字符和母串的第二到第五个字符不用比较,比较了也是白费劲)。
如此一来,i的指针就没有了回溯的必要了(在模式朴素匹配算法里面经常要回溯)。需要回溯的就只有j指针,也就是指向子串的指针。
所以,整个KMP的重点就在于当子串的某一个字符与母串不匹配时,j指针要移动到哪儿?
那么接下来我们就来发现一下子串的 j 指针的移动规律。
如左上图:C 和 D不匹配了,我们要把j移动到哪儿? 显然是第二位(B),为什么呢? 因为在子串中 ,D前面的A和子串的第一个字符A一样。移动结果如右上图。
再看一组图:C和B不匹配,我们可以把指针移动到第三位C
这样的话,我们就可以看出来点什么了:j 要移动的下一个位置k存在着这样的性质:k之前(0 ~ k - 1)的元素和 j 之前(j - k ~ j - 1)的元素相同(实际上,符合条件的k不止一个)。
那么现在问题又来了,怎么求这个k?
因为在每一个位置都可能发生不匹配,因此我们要计算每一个位置所对应的k值,也就是说我们需要一个数组来存放k,假设这个数组名为next,那么就有 next[j] = k; 即把k 的值赋值个对应的j,表示当母串[i] != 子串[j]时(即母串和子串不匹配的时候),j指针指向的下一个位置(也就是k)。
实现next的源代码如下:
1 void get_next(char T[], int *next) 2 { 3 int k = -1, j = 0; 4 next[0] = -1; 5 while (j < lenT) // lenT = strlen(T) - 1 6 { 7 if (k == -1 || T[j] == T[k]) 8 { 9 next[++j] = ++k; 10 } 11 else 12 k = next[k]; 13 } 14 }
这个源代码看起来很简单,实际上理解起来有难度(我tm困在这个半天)。
当j = 0的时候,如果不匹配,此时j 不可能再向左(向前)回溯,只能由i 向右移动一位,因此,next[0] = -1。
而当j = 1时,j 就只能移动到前面(也就是第一个元素的位置了)
、
接着就是当 j > 1时 了。仔细观察上面两个图,会发现一些规律: 当T[k] == T[j]时,有next[j + 1] == next[j] + 1。
而这个规律,也可以证明:已知 next[j] == k; 即 T[ 0 ~ k - 1] == T[j - k ~ j - 1];
又因为T[k] = =T[j];
所以 T[0 ~ k - 1] + T[k] == T[j - k ~ j - 1] + T[j] ,即T[0 ~ k] == T[j - k ~ k]
从而可得 next[++j] == next[j] + 1 == ++k;
这也就是上面的if语句块的公式的由来。
而当T[k] != T[j]时,又该怎么办呢? 直接使用上面的next[++j] = ++k肯定是不行的,因为当T[k] != T[j] 的时候 不可能存在T[0 ~ k] == T[j - k ~ k]。
那么这时候,我们可以通过缩小k的范围,使得彼时的k 满足 T[k] == T[j] ——也就是T[0 ~k] == T[j - k ~ k]。
具体要怎么做呢?怎样缩小k的范围?
答案也很简单,对k做递归运算就好了。
我们可以把 (j - k ~ j )的这个范围作为母串,把(0 ~ k)范围作为子串,进行一次KMP匹配。
如上图,进行了一次kmp匹配之后,k = next[k] (递归调用) ,这个时候我们就会发现出现 T[next[k]] == T[j]这个条件了(next[k] == k)。这样子,也就把未知的东西转化成已知的了。
这样一来,获取子串T的next数组也就搞定了,接下来是KMP算法本身(调用next数组)。
int Index_KMP(char S[], char T[], int pos) { int i = 0; j = 0; //i用于母串,j用于子串 int next[255]; get_next(T, next); //得到next数组 while (i <= strlen(S) - 1 && j <= strlen(T) - 1) { if (j == -1 || S[i] == T[j]) //俩字母相等则继续,而j == -1 则是用于一开始就不匹配的情况 { ++i; ++j; } else { j = next[j]; //j 退回合适的位置,也就是get_next()里面k的位置,而i不需要回溯 } } if (j > strlen(T) - 1) return i - (strlen(T) - 1); else return 0; }
分析一下上面的代码:代码和朴素的匹配模式算法相比,改动不多,关键就是去掉了i 值回溯的部分以及把j 退回到合适的位置(而不是j = 0)。而我们比较关心的是代码的复杂度,这个才是最重要的,平方阶的代码无卵用。
假设子串T的长度为m,母串的长度为n,那么get_next()函数的复杂度为O(m),而Index_KMP的复杂度是O(n) 。总的时间复杂度应该就是O( m + n), 也就是说是线性的。(nice)
整个KMP大致就是这样了,但实际上.................还没完,这个算法还存在缺陷。
我举个栗子:
显然,由上面的get_next()函数得到的next数组应该是 [ -1, 0, 0, 1],那么j就应该移动到第二个字符的位置(下标为1)。 如下图:
问题就出在这里了,看上图,这一步是没有意义的,后面的B是不匹配的,那么前面的B也不匹配。那么原因在于哪里呢?
很容易看出,问题出现在不应该有T[ j ] == T [ next [j] ],为什么?
假设母串是S,那么当S [ i ] != T[ j ] 的时候,j 会调到下一个位置,也就是 next[ j ],那么,如果j == next[ j ] 的话,那么这个步骤就毫无意义(重复了)。
解决办法也很简单,就是将本来的next[ j ] = k 中的k 再次递归,变成next [ k ]。直到不重复为止。
void get_next(char T[], int *next) { int j = 0, k = -1; next[0] = -1; while (j < strlen(T) - 1) { if (k == -1 || T[j] == T[k]) { if (T[++j] == T[++k] //如果相等,那么就不要用next[j] = k这一步骤,而是跳过,使用next[k]来找到更小的。 { next[j] = next[k]; } else next[j] = k; } else k = next[k]; } }
这样子,KMP的缺陷就搞定了。
-_-
总算搞定了。