字符串匹配问题(KMP算法)
问题描述
给定一个文本串S,现有一个模式串P,求P在S中出现的位置(第一次出现的位置)。
前提
后文中会提到各种S串前进回退,P串前进回退,意指用于访问S串P串的指针或者迭代器的前进或者回退。后文为了简单,就直接写S串前进回退之类的了。
暴力匹配
对于上述字符串匹配的问题,首先自然能想到的就是暴力匹配,以S中每个字符为起点,初始化一个迭代器进行循环,往后搜索是否后续元素一一与P中元素匹配,如果出现不匹配的情况,则S回退到本次搜索后续元素匹配情况的起点位置的下一个位置,P回退到首元素位置。代码自然也很简单,具体代码如下:
int violent_search(const char *s, const char *p)
{
int sLen = (int) strlen(s);
int pLen = (int) strlen(p);
int i, j;
i = j = 0;
while (i < sLen && j < pLen) {
if (s[i] == p[j]) {
++i, ++j;
}
else { //S回退,P回退
i = i - j + 1;
j = 0;
}
}
if (j == pLen) {
return i - j;
}
else {
return -1;
}
}
假定S串长度为n,P串长度为m,则计算时间复杂度时,首先遍历S串中每个元素为O(n),再根据S中每个元素与P首元素的匹配情况,遍历P串中每个元素为O(m),结果为O(n) * O(m) = O(m * n)。在P串远小于S串的情况下,这种方法效率不算很差,接近于O(n)。不过如果S串和P串长度差不多,时间复杂度就接近于O(n ^ 2)了。
暴力搜索的优化版本
对于暴力搜索而言,简单直接,容易理解,但效率差这么一点。仔细观察上面的代码,可以发现一种情况:上面的版本将遍历S串中的每个元素,不管后续的元素是否有可能与P串匹配,举个例子,如果遍历S到倒数第三个元素,该元素与P串首元素相等,而P串长度为5,结果已经很明显了,S串后续元素是不可能与P串完全匹配的,遇到这种情况应该结束整个方法。具体代码如下:
int violent_search(const char *s, const char *p)
{
int sLen = (int) strlen(s);
int pLen = (int) strlen(p);
int i, j;
i = j = 0;
while (i < sLen && j < pLen) {
if (s[i] == p[j]) {
++i, ++j;
}
else { //失配,回退S,回退P
i = i - j + 1;
j = 0;
if (sLen - i < pLen) { //若S串剩余未匹配元素数量小于P串长度,应结束方法
break;
}
}
}
if (j == pLen) {
return i - j;
}
else {
return -1;
}
}
优化了这一步之后,这个方法要比O(m * n)要快一些,并且Linux Kernel中的strstr方法实现思路也是大致如此的。
KMP算法基本思路
暴力匹配以及暴力匹配的优化版本,从思路、代码实现上都很容易理解,如果不吝啬于内存,想要更快的方法,自然也有解决方案,这就是KMP算法。暴力匹配两法都需要S串与P串的回退,KMP算法仅用回退P串,S串可以继续前进,这样特别的匹配方式是依靠next数组实现的,而整个KMP最难理解的也是next数组。下面就先来解释一下next的数组的意义吧。
next数组用于存放对应位置之前的子串存在多长的相同前缀后缀。举个例子,对于字符串”abcabcd”,数组以0起始,则’d’位置的next数值,即next[6]为3。解释一下,’d’之前的子串是”abcabc”,对于该子串,存在前缀”abc”与后缀”abc”相同,所以next[6]就被计算为前后缀的长度,即为3。
next数组存在的意义就在于此,暴力搜索及其优化版本都并未记录失配之前的匹配情况,所以每次失配都要造成S串与P串的回退,对于KMP算法,依靠next数组记录失配之前的匹配情况,失配后,根据next数组的记录,我们就只需要回退P串就行了,失配字符之前的子串已经与S串的子串匹配,也就是说失配之前的子串的后缀也已经与S串的子串的后缀匹配了,如此,根据next数组的数值,P串只需要回退到失配位置的next数组数值的位置即可,此时刚才失配时,P串失配字符之前的子串的前缀刚好与S串的子串的后缀匹配了!我们可以用一副示意图来理解一下这个过程:
由上图可看出,当P串的’D’与S串的’E’匹配时,出现失配的情况。对于P串’D’之前,存在长度为3的相同的前后缀,所以可以将P回退到P[3]的位置,即第四个字符的位置继续与S串进行匹配。
说到这里,也就大概解释了S串为什么不需要回退的原因,那S串什么时候前进呢?当P串的首元素也无法与S串失配位置匹配时,自然就需要S串前进一个位置了。
next数组计算方法
假定有模式串P,长度为m,则P可表示为P[0]P[1]…P[m - 1]。重新定义一下next数组:
若有P[0]P[1]…P[i] = P[j - i]P[j - i + 1]…P[j],则next[j + 1] = next[j] + 1 = i + 1。
由上可得求next数组时会出现的两种情况:
- P[i] = P[j],则next[j + 1] = next[j] + 1 = i + 1;
- P[i] != P[j],则判断P[next[i]] == P[j],若为真,则next[j + 1] = next[i] + 1,否则令i = next[i],继续执行查找匹配情况的循环。
解释:
对于第2种情况需要说明一下,如果P[i] != P[j],转而去判断P[next[i]] == P[j],这是因为子串中并不存在相同的前缀P[0]P[1]…P[i]与后缀P[j - i]P[j - i + 1]…P[j],那么此时就该考虑是否存在一个更小的前缀P[0]…P[t]与更小的后缀P[j - t]…P[j]相等呢?所以对于这种情况,就应使用i = next[i],继续进行匹配。
刚接触next数组的计算,可能会想一个问题,为什么P[i] != P[j]时,要用i = next[i]去进行迭代?这是因为P[i]!=P[j],next[i]表示P串的第i个字符之前的子串存在长度为next[i]的相同前缀后缀,即存在P[0]…P[next[i] - 1] = P[i - 1 - (next[i] - 1)]…P[i - 1], 此时只需判断P[next[i]]与P[j]是否相等,若两者相等,则存在P[0]…P[next[i]] = P[j - next[i]]…P[j],即找到了比P[0]…P[i]更小的前缀可与P[j - next[i]]…P[j]相匹配;若不相等,则继续以i = next[i]进行迭代。
下面即可得到求解next数组的代码了:
void get_next(const char *p, int next[])
{
int pLen = (int) strlen(p);
next[0] = -1;
int i = -1;
int j = 0;
while (j < pLen - 1) { //注意next数组元素数量与p串相同
if (i == -1 || p[i] == p[j]) {
++i, ++j;
next[j] = i;
}
else {
i = next[i];
}
}
}
有了next数组,自然也能利用next数组求解匹配位置了,具体代码如下:
int kmp_search(const char *s, const char *p, int next[])
{
int sLen = (int) strlen(s);
int pLen = (int) strlen(p);
int i = 0, j = 0;
while (i < sLen && j < pLen) {
if (j == -1 || s[i] == p[j]) {
++i, ++j;
}
else {
j = next[j];
}
}
if (j == pLen) {
return i - j;
}
else {
return -1;
}
}