在前面的文章里,我已经介绍完了部分的字符串函数,接下来我将介绍另外一个字符串函数,并分别用两个算法来模拟实现该函数。
strstr函数
strstr函数是在一个字符串中寻找另外一个字符串的函数,我们来了解该函数的返回类型和参数类型.
在msdn查询可以得知,strstr函数的返回类型和参数类型是:char* strstr(const char* str1,const char* str2)。(在这里我们只考虑返回类型和参数类型,不关注参数名字)那么,我们继续往下寻找,查看strstr函数的返回值。
经过查询可以到,strstr函数的返回值有三种情况(为了方便理解,我先假设strstr函数查询strCharSet字符串是否存在于string字符串)。
1.如果strCharSet可以在string字符串查找到,便返回StrCharSet在string第一次出现的指针。
2.如果strCharSet没有出现在string字符串,那么就返回NULL。
3.如果strCharSet是一个长度为零的字符串(即char arr[] = "";
),那么就返回string字符串。
接下来,我来举一个strstr函数的使用例子。
#include<stdio.h>
#include<string.h>
int main()
{
char arr1[] = "abcdef";
char arr2[] = "bcd";
char* ret = strstr(arr1,arr2);
if(*ret != NULL)
{
printf("%s\n",ret);
}
else
{
printf("找不到\n");
}
return 0;
}
运行结果如下:
接下来,我来验证一下strCharSet字符串在string字符串中多次出现,返回的是第一次出现的指针位置。
#include<stdio.h>
#include<string.h>
int main()
{
char arr1[20] = "abcdefabcdef";
char arr2[] = "bcde";
char* ret = strstr(arr1,arr2);
printf("%s\n",ret);
return 0;
}
运行结果如下:
在该程序中的string字符串是abcdefabcdef,strCharSet字符串是bcde,如果返回第二次出现的指针,那么strstr函数返回的是第二个b的地址,自然打印bcdef。如果返回第一次出现的指针,那么strstr函数返回第一个b的地址,自然打印bcdefabcdef,由运行结果可知,strstr函数返回的是strCharSet字符串第一次出现在string字符串的位置。
BF算法
BF算法是一个比较简单的算法,但是效率却不是很高。在模拟实现strstr函数的过程中,先采用BF算法来实现strstr函数,再采用KMP算法来实现strstr函数,形成了一个从简单到困难的学习思路,有利于我们加强对strstr函数、BF算法、KMP算法的理解。
现在我来介绍一下,BF算法的思路,我依然假设我要查找strCharSet字符串是否存在于string字符串。
如上图,我要查找strCharSet字符串是否存在于string字符串,BF算法中先设置一个指针指向string字符串的第一个元素,设置另外一个指针指向strCharSet字符串的第一个元素。(假设每个小方框都存储着一个字符)
接下来,我将str1指向的元素和str2指向的元素进行比较,如果str1指向的元素和str2指向的元素是相同的,那么str1和str2都会向下走一步,比较下一个str1指向的元素和str2指向的元素是否相等。
假设现在两个元素相等,则str1和str2开始向下走一步。
那么继续比较str1指向的元素和str2指向的元素,如果str2走完strCharSet字符串时,str1指向的元素和str2指向的元素都一直相等,那么表明strCharSet字符串存在于string字符串。
如果str2在strCharset字符串走的过程中,str1指向的元素和str2指向的元素不相等,那么str1将退到string字符串相应的元素位置(第一次查询的是从string字符串的第一个元素开始的,如果匹配失败,str1回到第二个元素开始匹配;如果,str1开始从第二个元素开始匹配的,也是匹配一半的strCharSet字符串就失败,那么str1就回到第三个元素),而str2将退到str2CharSet的第一个元素。
如果感觉理解起来有点吃力,没关系,我画图带你们理解。
在第一次查询中,str1指向string字符串的第一个元素,str2指向strCharSet的第二个元素,接着str1指向的元素和str2指向的元素进行比较。由于str1指向的元素和str2指向的元素是相等的,那么str1和str2都向后面走一步。
str1指向的元素和str2指向的元素依然是相等的,那么str1和str2还是向后面走一步。
str1指向的元素和str2指向的元素依然相等的,str1和str2继续向后面走一步。
接着比较str1指向的元素和str2指向的元素,两者不相同。故开始第二次查询,str1回到string字符串的第二个元素,str2回到strCharSet字符串的第一个元素。
比较str1指向的元素和str2指向的元素,两个元素不相等,第二次查询结束,开始第三次查询。str1指向string字符串的第三个元素,str2指向strCharSet的一个元素。
就这样执行下去,直到在某一次查询中能够在string字符串查询到整个strCharSet字符串,那么就返回该次查询中str1的开始指向位置,如果查询完到string字符串,还是没有遇到这种情况,那么直接返回NULL。
记下来,我用BF算法的代码实现strstr函数。
#include<stdio.h>
#include<assert.h>
char* my_strstr(const char* str1,const char* str2)
{
const char* s1 = str1;
const char* s2 = str2;
const char* p = str1;
assert(str1 != NULL);
if(*str2 == '\0')
{
return (char*)str1;
}
while(*p)
{
s1 = p;
s2 = str2;
while(*s1 != '\0' && *s2 != '\0' && *s1 == *s2)
{
s1++;
s2++;
}
if(*s2 == '\0')
{
return (char*)p;
}
p++;
}
return NULL;
}
int main()
{
char arr1[] = "abcdefabcdef";
char arr2[] = "bcde";
char* ret = my_strstr(arr1,arr2);
if(ret == NULL)
{
printf("%s\n",ret);
}
else
{
printf("%s\n",ret);
}
return 0;
}
由运行结果可以得知,该模拟实现strstr函数能够满足我们的要求。
KMP算法
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模串的局部匹配信息。KMP算法的时间复杂度O(m+n) [1] 。 来自-------百度百科。
KMP算法和BF算法唯一不相同的地方在,主串str1是不会后退的,并且str2也不会回到0号位置。
接下来,我来逐步介绍KMP算法。
1.为什么主串不用后退?
主串不后退的原因是主串在后退的过程中,有时候不能匹配完全,所以KMP算法选择直接不同后退。如下:
假设在第一次排序中,str1来到了主串的下标为2的位置,而str2来到了子串下标为2的元素,此时两者不相同。那么按照BF算法的要求,str1要回到第二个元素,而str2要直接回到子串下标为0的位置,但是在KMP算法中看来,就算str1来到第二个元素也就是主串下标为1的位置,str2来到子串下标为0的位置,两者也是不相等的。
所以在kMP算法中,str1直接不后退,str2回到相应的位置。
2.str2回退的位置。
str1和str2分别指向主串和子串的字符,开始依次比较主串和子串里面的元素。
主串的元素和子串的元素一直相等,直到比较到主串和子串的下标为5的元素,两个元素才不相等。按照KMP算法,str1就不再后退,str2就要后退到特定的位置。
那么,str2回退到子串的哪个元素呢?
观察主串和子串可以得知,ab这两个连续字符出现的频率很高,子串中的3号位置的ab与主串的1号位置的ab匹配一小段成功后出现了失败,所以子串中的3号位置的ab两个字符开始与主串的2号位置的ab两个字符开始比较,那么str2只需要回到子串中的下标为2的元素。(因为子串的3号位置和主串的2号位置是相同的,只需要查询子串后面的元素是否相等,所以str2回到子串中下标为2的元素。)
那么现在怎么让str2每次都知道自己要回到哪个元素呢?这个就要引出next数组。
3.引出next数组
KMP算法的精髓就是next数组:也就是用next[str2] = k来表示,不同的str2对应一个k值,这个k就是将来str2匹配不成功时,str2要移动到的位置。
k值的求解规律:
1、规则:找到匹配成功部分的两个相等的真子串(不包含本身),一个以下标 0 字符开始,另一个以 str2-1 下标字符结尾。
2、不管什么数据 next[0] = -1;next[1] = 0;(有的解释是next数组第一个元素是0,第二个元素是1,但是我们这里不这样,如果要更换的话,等到求出next数组所有元素再全部加一即可);
如:对子串为”ababcabcdabcde”的字符数组, 求其的 next 数组。
我先画出该字符数组和next数组,接下来依次求出next数组的每一个元素。
由k的求值规律2可以得知,next[0] = -1,next[1] = 0,所以,直接在next数组上面写上这两个值。
接下来,我来求next[2]的k值(str2指向子串下标为2的元素)。
由求k值的规律1可以得知,我必须找两个相等的真子串,一个必须以下标为0开始,另一个要以str2-1下标结束。
在上面求next[2]的k值时,我来寻找两个相同的字符串,一个是以下标为0开头也就是a,另一个是以下标为为str2-1也就是1结束也就是b,显然,不存在着两个相同的字符串,所以next[2] = 0;
接下来,我来求next数组的下标为3的元素(str2指向子串下标为3的元素)。
我依然要找两个相等的真子串,一个要以下标为0也就是a开始,另一个要以下标为str2-1也就是下标为2的a元素结束,那么这两个串分别是a与a。长度为1,所以next[3] = 1。
接下来,我继续求next[4]的k值(str2指向子串下标为4的元素)。
我依然要找到两个相等的真子串,一个要以下标为0的元素也就是a开始,另外一个要以下标为str2-1也就是b结束,那么,这两个真子串分别是ab和ab,长度为2,所以next[4] = 2。
按照这样计算下去,这个next数组的所有元素如下:
接下来,我来举另一个求next数组的典型例子。
在”abcabcabcabcdabcde”的子串下,求其的 next 数组?
按照前一个讲解求next数组的方法,我们可以求到了next数组下标6的元素,接下来,我来求next[7]的元素(str2指向子串下标为7的元素)。
我依然要找出两个相同的真子串,一个要以下标为0的元素也就是a开始,另外一个要以下标为str2-1的元素也就是a结束,那么这两个真子串分别是abca和abcd,如图:
在这里会不会有人在疑惑,为什么下标为3的a元素既存在于第一个真子串,又存在于第二个真子串。我们要注意的是这两个真子串中,第一个真子串是不是以下标为0的元素也就是a开始的,另一个真子串是不是以下标为str2-1,也就是a结束的,答案是肯定的,那么这个就满足了我们求k值的规则,所以下标为3的a元素既存在于第一个真子串,又存在于第二个真子串,是不影响的,所以next[7] = 4。
接下来,我来求next[8]的值(str2指向子串下标为8的元素)。
我依然要找到子串的两个真子串,一个要以下标为0的元素也就是a开头,另外一个要以下标为str2-1的元素也就是b结束,那么这两个真子串分别是abcab、abcab。
那么,next[8] = 5。在下标为3到下标为4的ab既存在于第一个真子串,又存在于第二个真子串的原因,前面已经有提到过。
接下来,我来求next[9]的值(str2指向下标为9的元素)。
我依然要找到两个子串中的两个真子串,一个要以下标为0的元素也就是a开始,另外一个要以下标为str2-1的元素也就是c结束,那么在不细心的情况下,我们可能会这样找:
第一个真子串是abc,以下标为0的元素也就是a开始,第二个真子串是abc,以下标为8的元素也就是c结束,并且子串的长度为3,所以next[9] = 3。
如果这样求,那就是错误了。
下面才是正确求法:
第一个真子串是以下标为0的元素也就是a开始的,第二个元素是以下标为str2-1的元素结束的,并且真子串长度为6,与上面的长度为3的真子串相比,我们肯定要的是真子串长度为6的,所以next[9] = 6。
按照这几种方法求下去,next数组的所有元素为:
直到现在,next数组的求解基本方法和特殊案例我都已经讲解了。
4.探讨p[i]与p[i+1]的k值关系
仔细观察前面所求的next数组的元素可以得知,如果next数组的元素是有递增的情况话,那么每一个的元素都是上一个的元素加一,那么这里面是否可以引出一个简便求next数组的元素的方法呢?
假设在next[i] = k的前提下,p[0]…p[k-1] = p[x]…p[i-1](即p字符数组中,下标为0的位置到下标为k-1的位置的字符串与下标为x的位置到下标为i-1的位置的字符串相同)。如下图:
上面p字符串中的next数组元素都已经被求出,在i是8的情况下,next[i] = next[8] = 3 。由next数组中next[i] = k的公式可以得知,p字符串中下标为8的k值是3,所以我分别标出了i的位置、i-1的位置、k的位置、k-1的位置。
现在已经满足了next[i] = k的情况下,我来解释p[0]…p[k-1] = p[x]…p[i-1]。
我找出p字符数组中的下标为0的位置,到下标为k-1的位置的字符串,也就是abc。接下来就是下标为x的位置到下标为i-1的位置,i-1的位置前面早已经确定了,那么x的位置呢?观察可以发现,当x是5的时候,下标为x的位置到下标为i-1的位置的字符串与前面的abc字符串相同。
则在next[i] = k的前提下,p[0]…p[k-1] = p[x]…p[i-1]。
x的值可以被推导。
我们依然选择这个字符串来讲解。由图片可以得知,p[0]…p[2]的字符串与p[5]…p[7]的字符串长度相等,所以k-1-0 = i-1-x,则x = i - k。
总结:在next[i] = k的前提下,p[0]…p[k-1] = p[i-k]…p[i-1]。
如果p[i] == p[k]时,那么p[0]…p[k] = p[i-k]…p[i]。
如图:
我还是举上面那个例子,p[i] = p[k] = a,满足条件。
由图片的绿色标记可以得知,p[0]…p[k] = p[i-k]…p[i],x已经在前面可以证明为i-k。所以可以证明,如果p[i] == p[k]时,那么p[0]…p[k] = p[i-k]…p[i]。
现在观察i、i+1的next数组的元素,next[i+1] = next[i]+1
那么,讲了这么多,到底有什么用呢?我们可以反向推导。
当p[0]…p[k-1] = p[i-k]…p[i-1],next[i] = k。
当p[0]…p[k] = p[i-k]…p[i],所以next[i+1] = k + 1。
那么在程序实现的时候,我们就可以利用好这个规律,快速求下一个元素的next数组元素。
当p[i] != p[k]。
由图片可以得知p[i] != p[k] ,(注意,k = next[i])所以我们可以发现i+1的next数组元素为1,i的next数组元素为2,并不是i+1的next数组元素是i的next数组元素加一。
那么在这种情况,我们应该怎么求下一个next数组的元素呢?
我们采用k值回退的情况
如上图,k值现在是2,来到了p数组中下标2的位置,但是p[i] = a,p[k] = c,两者不相等,所以k值查看现在所在的p数组元素的next元素大小,为0(如图中圆圈的位置)。
所以k值回退到0的位置。
此时,p[i] = a,p[k] = a,两者相等,所以next[i+1] = k+1 = 1(注意k值已经回退到0的位置了)。
回退的特殊情况。
如果上面的k值在0的位置,p[i] 依然不等于 p[k] ,那么p要回退到-1的位置,此时就不需要再比较p[i],和p[k]了,也没办法比较,因为下标为-1对于p数组已经是越界了,直接相加,next[i+1] = k + 1 = 0。
KMP算法的代码实现。
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<assert.h>
void Getnext(int* next,const char* str2)
{
int lenstr2 = strlen(str2);
int i = 2;//从第二个元素开始
int k = 0;//上一个元素的k值
next[0] = -1; //next数组规则
next[1] = 0;
while(i < lenstr2)
{
if((k == -1) || (str2[i-1] == str2[k])) //k==-1进入循环是应对k值回退的情况
{
next[i] = k + 1; //这里比较的是i-1和i,前文介绍的是i和i+1,道理相同
i++;
k++;
}
else
{
k = next[k]; //k值回退
}
}
}
int KMP(char* str1,char* str2,int pos) //str1代表主串,str2代表子串,pos代表从主串pos位置开始寻找
{
int i = pos;//主串开始的位置
int j = 0; //子串开始的位置
int lenstr1 = strlen(str1);
int lenstr2 = strlen(str2);
int* next = (int*)malloc(sizeof(int)*lenstr2);//动态开辟空间,为next数组做准备
assert(next != NULL);//检验next数组
Getnext(next,str2);//求next数组的元素
while(i < lenstr1 && j < lenstr2)
{
if((j == -1) || (str1[i] == str2[j])) //j等于-1进入循环是因为k值可能为-1(k后退的特殊情况)
{
i++;
j++;
}
else
{
j = next[j];//j可能等于-1,应对方法在if语句中
}
}
free(next);//释放空间
if(j >= lenstr2)
{
return i - j;
}
else
{
return -1;
}
}
int main()
{
char arr1[] = "abcdef";
char arr2[] = "cdef";
printf("%d\n",KMP(arr1,arr2,0));
return 0;
}
运行结果如下:
由上面的图片解析可以得知,arr2确实是从arr1下标为2开始找到,所以该程序是没有问题的。
next数组的优化
对于next数组存在着一个小缺陷,如下:
如上面的p数组中,我求出了next数组的所有元素。假设在5号位匹配失败了,那么回退到一步还是a,再退一步还是a,那么我们是否可以设计一步回退到不是相同元素的位置或者最后一个元素呢?这就要引入我们的nextval数组了。
求解nextval数组元素的规律如下:
当j = 1时,nextval[0] = -1。
当j > 1时,如果p[j] 不等于p[k],nextval[j] = next[j];如果p[j] 等于p[k],nextval[j] = nextval[k]。
按照规则,nextval数组第一个元素为-1。
当下标为1时,k值也就是next值为0,p[1] = p[0] = a,所以nextval[1] = nextval[0] = -1。
当下标为2时,k值也就是next值为1,p[2] = p[1] = a,所以nextval[2] = nextval[1] = -1。
按照这种方法,我们直接求到下标为7的元素。
当下标为8时,k值也就是next数组的值为7,p[8]等于b,p[7]等于a,两者不相等,所以nextval[8] = next[8] = 7。
nextval代码改进
void Getnextval(int* nextval,const char* str2) //需要注意的是,该代码一直使k值等于next[i]
{
int lenstr2 = strlen(str2);
int i = 0;//从第一个元素开始
int k = -1;//上一个元素的k值
nextval[0] = -1;
while(i < lenstr2)
{
if((k == -1) || (str2[i] == str2[k])) //k==0进入循环是应对k值回退的情况
{
i++;
k++; //如果str[k]与str[i]相等,那么k加一,为后面做准备
if(str2[i] != str2[k] && i != lenstr2) //当i为3时,i+1为4,nextval[4]已经越界,因为nextval的下标只有0、1、2、3四个下标
{
nextval[i] = k; //如果p[j]不等于p[k],nextval[j] = next[j]
}
else if(str2[i] == str2[k] && i != lenstr2) //当i为3时,i+1为4,nextval[4]已经越界,因为nextval的下标只有0、1、2、3四个下标
{
nextval[i] = nextval[k]; //如果p[j]等于p[k],nextval[j] = nextval[k]。
}
}
else
{
k = nextval[k]; //k值回退
}
}
}
nextval的KMP算法全部代码
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<assert.h>
void Getnextval(int* nextval,const char* str2) //需要注意的是,该代码一直使k值等于next[i]
{
int lenstr2 = strlen(str2);
int i = 0;//从第一个元素开始
int k = -1;//上一个元素的k值
nextval[0] = -1;
while(i < lenstr2)
{
if((k == -1) || (str2[i] == str2[k])) //k==0进入循环是应对k值回退的情况
{
i++;
k++; //如果str[k]与str[i]相等,那么k加一,为后面做准备
if(str2[i] != str2[k] && i != lenstr2) //当i为3时,i+1为4,nextval[4]已经越界,因为nextval的下标只有0、1、2、3四个下标
{
nextval[i] = k; //如果p[j]不等于p[k],nextval[j] = next[j]
}
else if(str2[i] == str2[k] && i != lenstr2) //当i为3时,i+1为4,nextval[4]已经越界,因为nextval的下标只有0、1、2、3四个下标
{
nextval[i] = nextval[k]; //如果p[j]等于p[k],nextval[j] = nextval[k]。
}
}
else
{
k = nextval[k]; //k值回退
}
}
}
int KMP(char* str1,char* str2,int pos) //str1代表主串,str2代表子串,pos代表从主串pos位置开始寻找
{
int i = pos;//主串开始的位置
int j = 0; //子串开始的位置
int lenstr1 = strlen(str1);
int lenstr2 = strlen(str2);
int* nextval = (int*)malloc(sizeof(int)*lenstr2);//动态开辟空间,为next数组做准备
assert(nextval != NULL);//检验next数组
Getnextval(nextval,str2);
while(i < lenstr1 && j < lenstr2)
{
if((j == -1) || (str1[i] == str2[j])) //j等于0进入循环是因为k值可能为-1(k后退的特殊情况)
{
i++;
j++;
}
else
{
j = nextval[j];//j可能等于-1,应对方法在if语句中
}
}
free(nextval);
if(j >= lenstr2)
{
return i - j;
}
else
{
return -1;
}
}
int main()
{
char arr1[] = "abcdef";
char arr2[] = "cdef";
printf("%d\n",KMP(arr1,arr2,0));
return 0;
}
运行结果如下:
今天,我的strstr函数,BF算法,KMP算法已经讲完。后面的KMP算法比较困难,大家可以先收藏,慢慢理解。关注点一点,下期更精彩。