目录
1.字符串函数strstr是什么?
首先,strstr函数是一个标准库里面的一个函数,隶属于字符串函数大类,使用函数strstr之前我们需要引用头文件 #include <string.h>。
其次,strstr函数需要传入两个地址参数,这两个地址都是 const char *类型的指针,从左到右分别代表一个主串和一个子串。strstr函数的返回值是一个char*类型的指针,它会在主串中寻找子串,如果找到子串,返回子串在母串中第一次出现的位置的指针,否则就返回一个空指针NULL。
这里举一个例子:
#include <stdio.h>
#include <string.h>
int main()
{
char str1[] = "hello world";
char str2[] = "world";
char str3[] = "held";
//由于数组名代表的就是这个数组的首元素地址,所以下面使用传入数组名字表示传入的首元素地址
char* ret1 = strstr(str1,str2);//创建一个char*指针用来存放strstr函数在母串str1中寻找子串str2的结果
char* ret2 = strstr(str1,str3);//创建一个char*指针用来存放strstr函数在母串str1中寻找子串str3的结果
if(ret1!=NULL)
printf("%s\n",ret1);
else
printf("没找到\n");
if(ret2!=NULL)
printf("%s\n",ret1);
else
printf("没找到\n");
return 0;
}
运行一下结果:
可以看到,ret1接收了在str1中寻找str2的返回值,并且通过得到的地址打印出来了从第一次出现的位置到终止符'\0'之间的字符串;而ret2接受到了str1中并没有找到str3,所以返回了一个空指针NULL,这就是strstr函数的大概使用方法。
2.使用BF算法模拟实现strstr函数
BF算法概念:
BF算法,即暴力(Brute Force)算法,是一种普通的模式匹配算法。BF算法的思想是将目标串的第一个字符与模式串的第一个字符进行匹配,如果相等,则继续比较目标串的第二个字符和模式串的第二个字符;如果不相等,则比较目标串的第二个字符和模式串的第一个字符,依次进行比较下去,直到得出最后的匹配结果。
简单的说,BF算法就是一个对字符串进行按位匹配操作的一种字符串查找算法,它的空间复杂度不高,但是它的时间复杂度很高,也就是说它是一个逻辑层面相对简单但是它的耗时是较高也是较为低效的。不过它对我们后面理解更高级的算法具有很大的参考意义。
2.1BF算法使用偏向指针的方式的实现
首先我们要模拟实现strstr函数,就要先给定一个函数,这个函数我们就暂且叫它my_strstr吧,我们要在函数的形参部分传入两个指针,分别代表母串和子串的首元素地址,最后我们需要返回一个指针,如果找到,就返回子串在主串中第一次出现的位置的指针,否则就返回一个空指针NULL。
BF算法的核心就是暴力求解和循环遍历,因此效率相对较低,但是也因为这样,BF算法就更加利于小白入门和理解,在这个基础上再去扩展视野。
首先,我们需要得到一个主串和子串,这里为了便于理解我们先举一个例子
这里,我们把主串命名为str1,子串命名为str2,我们再将每一个元素都命名排序,像下面这样:
当然,这里还要加上\0 作为终止符,这个是我们运行程序的结束标志和实现关键。
首先,我们要定义两个指针s1和s2,s1最开始指向str1的[0]下标,s2指针指向str2的[0]下标,这样我们就可以开始循环遍历了。
BF算法遍历的规则是:
当s1和s2指向的对应下标元素字符相同的时候,我们就可以将两个指针都往后挪一位
如果s2(子串指针)能够指向str2后的终止符\0,就说明遍历完成,主串中含有对应的子串元素,与此同时我们的指针回溯,返回得到子串str2[0]对应的str1中的元素地址。
当然,并不是每次的遍历都这么顺利,如果匹配的过程中s1和s2指向了两个不相同的元素,我们的s2就要回归到子串的首元素也就是起始位置去;而s1则要回到这次循环遍历起始位置的后一位,比方说s1从下标[3]开始遍历,直到下标[6]发现不匹配,那么s1就要回到下标[3]的下一个位置,也就是下标[4],然后在进行字符串匹配,直到找到或者s1指向\0表示s1中不存在子串集合
这就是BF算法的基本原理,主要就是暴力循环遍历求解,然后在不管哪种情况下,都涉及到一个动作:回溯。就是s1指针需要回到起始和s2匹配的位置,要么是上一次遍历成功返回指针,要么是遍历失败s1要跳到起始位的下一个元素再匹配。
所以,我们要怎么做到 “回溯” 这个动作呢?其实很简单,我们只要再定义一个指针跟随指针s1遍历循环更新就好了。比如说s1匹配失败,再来一次的时候,这个指针+1,然后下一次s1还可以通过这个指针来找到起始位置,这个指针只会根据s1的起始位置变化而变化,换句话说,这个指针就是我们用来得到s1起始位置标记的一个指针。
为了方便理解,我们把这个指针定义为start ,类型是char * ,这样我们就可以开始我们的模拟实现了。
char* BF1(char* str1, char* str2)
{
char* s1 = str1;//传入数组名的原因是数组名代表的就是首元素地址
char* s2 = str2;
char* start = s1;//定义start指针指向s1的起始位置
}
首先我们定义的函数叫做BF1(加个1是后面还有另外一种实现方法,便于区分) ,这里我们定义这个函数的返回值是char*,后续如果找到对应字串就返回这个子串在主串的首元素地址,否则返回空指针(和strstr函数的实现一样) , 然后就是三个我们会用到的指针,也都定义出来并赋一个初始值。
while (*s1 == *s2 && *s1 && *s2)
{
s1++;
s2++;
}
然后就是内部实现了,我们要定义一个while循环遍历查看是否每一项都对应相同,注意这里while循环的条件是解引用之后的*s1和*s2比较,不要写成了两个指针的大小比较了。然后就是匹配成功之后,s1和s2都要+1,遍历到下一个字符上去。
*s1 && *s2的作用就是防止这个s1和s2扫到字符串后面的\0还继续往下比较,毕竟‘\0’和'\0'也是一样的,这样程序就死循环了,所以我们就要设这个初始条件,让s1和s2任意一个指向\0就跳出循环,再判断是匹配成功还是匹配失败。
if (*s2 == '\0')
return start;
else if (*s1 == '\0')
return NULL;
跳出循环就做判断,如果s2指向的是终止符就说明遍历成功了,s1指向的是终止符则说明匹配失败,这里有一个小小的细节,我们把s2的判断放在s1的前面,因为s1和s2同时指向终止符的话应该是匹配成功了,所以我们要优先判断s2而不是s1,如果直接判断s1就会直接认定程序匹配错误。
//如果都不是,则说明匹配失败,执行s1回溯,s2归零
s1 = ++start;//先执行start+1,再把start的值赋值给s1
s2 = str2;//s2归零
这里就实现了s1回溯并加1(包括起始点+1)和s2归零,但是我们会发现,像我们上面到这里似乎逻辑很通顺,但是我们的执行只能执行一次。所以,我们就要借助循环来得到我们想要的效果——多次判断直到得到结果为止。
鉴于我们在每一次都有判断,所以我们只要给一个while循环就好了,像这样:
while (1)
{
while (*s1 == *s2 && *s1 && *s2)
{
s1++;
s2++;
}
if (*s2 == '\0')
return start;
else if (*s1 == '\0')
return NULL;
//如果都不是,则说明匹配失败,执行s1回溯,s2归零
s1 = ++start;//先执行start+1,再把start的值赋值给s1
s2 = str2;//s2归零
}
当然,为了安全,我们还要在while循环,或者说,是在进入函数之后就判断传入的是不是空指针,防止我们给s1,s2赋成了空指针,所以调用一下assert断言,如果任意一个字符串为空直接报错,那么整体的函数就是这样的:
char* BF1(char* str1, char* str2)
{
assert(str1 != NULL && str2 != NULL);
char* s1 = str1;//传入数组名的原因是数组名代表的就是首元素地址
char* s2 = str2;
char* start = s1;//定义start指针指向s1的起始位置
while (1)
{
while (*s1 == *s2 && *s1 && *s2)
{
s1++;
s2++;
}
if (*s2 == '\0')
return start;
else if (*s1 == '\0')
return NULL;
//如果都不是,则说明匹配失败,执行s1回溯,s2归零
s1 = ++start;//先执行start+1,再把start的值赋值给s1
s2 = str2;//s2归零
}
}
简单测试一下代码就可以得到结果。
2.2BF算法偏向数组的方式实现
可能有人会疑问,为什么了解了指针实现BF算法还要再使用另外一种方式实现呢?那是因为后面的KMP算法就是基于这种实现方式的,所以我们就需要在简单的地方实现思维的扩展,使用指针的方式能够更好理解BF这种暴力遍历的算法,使用数组能更好理解KMP算法,在这两者之间,我们要找一个中间量来实现这个转换,所以我们可以用BF算法作为跳板,这样会有利于我们对KMP算法的了解,后面如果想要用指针来实现KMP算法的时候,有了这样的理解,我们也可以更加得心应手。即使是我们用不上这个思路,多了解多的方式可以让写代码更加灵活。
废话不多说,我们在前面已经详细了解了BF算法的基本实现,所以我们这里可以简单了解一下怎么使用数组的方式来实现BF算法。
类似于指针的方式,我们仍然要建立两个变量,这里我们使用整数 i 和 j 来实现这种效果,底层原理仍然是暴力遍历,只是我们把这个指针变成了一个变量而已:
char* BF2(char* str1, char* str2)
{
assert(str1 != NULL && str2 != NULL);
int i = 0;
int j = 0;
int start = 0;
while (1)
{
while (str1[i] == str2[j] && str1[i] && str2[j])
{
i++;
j++;
}
if (str2[j]=='\0')
return &str1[start];
else if (str1[i]=='\0')
return NULL;
//如果都不是,则说明匹配失败,执行 i 回溯,j 归零
i = ++start;//先执行start+1,再把start的值赋值给i
j = 0;//j归零
}
}
其实整体的框架还是一样的,这里我们就可以得到这样的实现,其实也可以加深一下我们对BF算法的理解,这样一来,我们就可以使用这样的数组形式得到我们的算法实现。
当然,这里很多地方可以更改,比如说start起始标志的建立,我们可以换成i和j之间的差值计算,也就是 i = i - j + 1 ;j = 0;i - j 是什么呢?其实就是 i 和 j 共同走过的路径长度正好就是 j 现在的坐标,比如说 j 在 [ 3 ] 的时候说明 j 从 [0]走到了[3] ,那么 j 向前走的步数正好就是3步,i 与 j 又是齐头并进的,所以 i 也是走了3步,这个时候 i 想要回溯其实就很简单了,只要 i - j 就回到了最初的起点,然后再+1就得到了更新之后的位置,这样我们就节省了start 创建的空间。当然,这样的空间似乎在我们看来好像没什么意义甚至难以理解,但是这样节约空间的思想在过去储存空间低下或者是现在的MCU上运用的还是有很大的优化意义的。(即使是一点点)
还有,就是主体的死循环while(1)其实也可以换成更加灵活的for循环,while对于循环的实现很容易因为条件错误等原因导致程序崩溃,而for循环更有利于借助变量进行调试,这也是我们可以优化的地方。当然,这里就不做过多了解了。下面直接进入KMP算法
3.使用KMP算法实现strstr模拟
3.1什么是KMP算法?什么又是next数组?
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)。
KMP算法就是典型的通过减少无用遍历,让遍历次数尽量少的一种算法。KMP算法和BF算法不同就在于当匹配失败之后,主串对应的 i 不回退了,让 j 回退到特定的位置,这个位置可能是起始位置,也可能不是起始位置。
KMP算法中还有一个概念叫做next数组,next数组是子串的每个元素对应的回退的指定位置,也就是我们通过一定的计算方法得到next数组,这样我们就得到了每个子串元素匹配失败后回退的位置,下面我们就开始了解一下next数组的求法。
3.2如何计算next数组
3.2.1next数组总介
next数组求法就是:
1)在子串中当下标为 0 时,对应的next 数组值就是 -1 ;当下标为1时,对应的next数组值就是0
2)找到子串中两个以首元素为起始字符,以 j - 1 的元素为结束字符的尽量长的两个相同的字符串,这字符串的长度就是 j 项对应的匹配失败回退的坐标。(第一个字符串包含首项,第二个字符串包含 j - 1项)
第一个可能还比较好理解,就时两个固定值,主要是第二个,怎么理解呢?这里就举几个例子:
这个数组arr里面,我们要写出 j 的对应next数组,要怎么求呢?首先,我们要找到两个字符串,这两个字符串首字符都为首元素对应的字符 a ,最后一个字符都为 j - 1也就是我们所求的前一个字符—— 字符 b ,我们要找到这么两个尽量长的字符串。
以 a 为起始,以 b 为结束,最长的当然就是从0到6,当然,这样的话就只有一个字符串了,就不符合要求了,所以我们只能找更小的字符串。次之的就是arr[0]...arr[1]和arr[5]...arr[6],那么这个长度就是2,所以arr[7]对应的next数组中的值就是2 。
为了更加生动形象的理解,这里画个图来表示一下这个匹配的过程:
在这张图里,就有 i 和 j 匹配前面几个都成功,但是到了[6]的时候匹配失败,这个时候按照BF算法的话,就要我们把 j 重置为0,把 i 跳到起始点+1也就是[1]位置,如果这样的话,我们就要重新匹配一些明明就不可能匹配成功的项,但是在我们的KMP算法里,我们 i 按兵不动,以 j 为分割线,找到 j 前面的两个符合条件的字符串,从[0]...[5],主串和子串都是一一对应的,找到那两个相同的字符串,我们就得到了 j 要回到的坐标,这样就省了很多力气
至于为什么跳转到的是[2]位置,刚好是串长度,这是因为 j 回退之后有一部分是不需要和主串匹配的,我们以那部分相同的字符串作为比较的“主要力量”,把它们后面的当作"随从",“主要力量”是匹配的上的。
假设我们匹配一次失败之后,我们让主串的第二个"主要力量"和子串的第一个"主要力量"相对齐,这样我们就不需要再比较这“主要力量”,转去比较它们的“随从”就好了。
j 就像这样回退到了 [2],错位一看,刚好,我们省去了那两段相同的部分的比较,还有那部分一定没有办法和现有字符串比较的部分[2]和[3],像这样得到回退的坐标就是next数组。
3.2.2next数组手动求解
我们要计算这样的next数组,先要了解怎么手算:
这里给两个例题:
1) a b c e f a b c a c a b
2) a b c a b c a b c a b c a c b
1)首先第一个字符串 a b c e f a b c a c a b
下标为[0]的a对应的值就是-1,
下标为[1]的b对应的值就是0,其它的就按照寻找最长相同字符串来定:
[2]的c前是字符串“ab”,并没有满足条件的俩串,所以值为0;
[3]的e前是字符串"abc",也是0;
[4]的 f 前是字符串"abce",结果为0;
[5]的a前字符串是“abcef”,结果也是0;
[6]的b前是"abcefa",有两个字符串”a“和”a“满足条件,值为1;
[7]的c前是字符串”abcefab“,有两个字符串”ab“和"ab"满足条件,值为2;
[8]的a前有字符串"abcefabc",有"abc"和"abc"满足条件,值为3;
[9]的c前是字符串"abcefabca",找最长的且为以a开头,以a结尾的两个字符串,并且包括首字符和尾字符,那么只有"a"和"a"是满足条件的,所以值是1;
[10]的a前是字符串"a b c e f a b c a c ",找最长的且为以a开头,以c 结尾的两个字符串,并且包括首字符和尾字符,那么没有任何字符串符合条件,所以值是0;
这里重点讲一下,可能有人会觉得前面的abc和abc就是两个相同的字符串啊,所以画个图表示一下:
看的出来如果这种情况下没有跳转到起始位置,那么就一定会经历匹配失败再跳回去重来的情况,所以我们规定的就是要两个字符串中,一个包含首字符,一个包含尾字符。
[11]的b前面是a b c e f a b c a c a,以a为起始a为结束的两个最长的相同字符串只有a和a,所以值是1;
那么我们就可以得到这个字符串的next数组就是 next[ ] = { -1,0,0,0,0,0,1,2,3,0,0,1 };
2)然后就是另外一个字符串a b c a b c a b c a b c a c b
它的next数组就是next[]={ -1,0,0,0,1,2,3,4,5,6,7,8,9,10,0 };
至于后面为什么是0111111211.12121.310.一直递增的,这里就可以举个例子,比如[9]的a前面是”a b c a b c a b c“,
这里以a开头,以c结尾,并且包含首字符和尾字符的最长字符其实是abcabc,用图表示就是:
它们只是有两个部分重叠了,并没有打破原来的规则,所以是合法的。两个字符串,所以[9]的next数组值就是6,其他的也是大概这样的原理。
3.2.3用C语言求解next数组逻辑
要求到next数组我们还要找到这个数组的规律
比如字符串”a b c e f a b c a c a b“,它的next数组是: next[ ] = { -1,0,0,0,0,0,1,2,3,0,0,1 };
字符串”a b c a b c a b c a b c a c b“,它的next数组是: next[ ] ={ -1,0,0,0,1,2,3,4,5,6,7,8,9,10,0 };
这两个next数组都有一个规律:部分递增,并不存在跳跃式的增长
我们假设有next[ j ] == k (k>0),求next[ j+1 ]
既然有next[j]>0,那么就说明我们前面就出现了我们需要的两个匹配的字符串,也就是说我们由这个条件可以推导出arr[0]...arr[k-1] == arr[j-k]...arr[j-1](这里的j-k是由两个字符串长度相等计算出的)。
1)情况1:arr[ j ] == arr[ k ]
这个时候求next[ j+1 ] ,我们先知道 在next[ j ] == k 时有 arr[0]...arr[k-1] == arr[j-k]...arr[j-1] ,arr[0]...arr[k-1] == arr[j-k]...arr[j-1] 正好就是next[j] 有正值的条件,所以arr[0]...arr[k-1] == arr[j-k]...arr[j-1] 也可以反推回next[j] == k(两个相等字符串的长度)
当arr[k]==arr[j],把arr[k]和arr[j]加在等式arr[0]...arr[k-1] == arr[j-k]...arr[j-1]两边就得到了新的等式:arr[0]...arr[k] == arr[j-k]...arr[j] 这个时候我们就可以推出next[j+1] == k+1 ,也就是next[j+1] ==next[ j ]+1
综上所述,我们就可以得到结论:
当next[ j ] == k 时,如果arr[ j ] == arr[ k ] ,就有next[j+1] == k+1
2)情况2:arr[ j ] != arr[ k ]
同样的,我们的初始条件还是next[ j ] == k (k>0),所以我们就可以使用初始条件推导的结果: arr[0]...arr[k-1] == arr[j-k]...arr[j-1]
像上面这种情况,arr[k] != arr[ j ] 我们就不能把arr[ k ] 和arr [ j ] 加在等式 arr[0]...arr[k-1] == arr[j-k]...arr[j-1] 的两边,这个时候我们无法直接得到next[ j+1 ]和前面的等式的区别, 就要让k再次回溯,再次比较两个arr[ k ] 和arr[ j ] :
这样我们就得到了arr[k] == arr[ j ],这样我们就可以再使用上面的结论 next[j+1] == k +1,只是新的k == 0,所以next [ 9 ] == 1 ;
假设arr[ k ]还是不等于arr[ j ] ,那么k再根据k所在位置的next值再回退,就是回退到 -1 的位置,然后arr[ k ] = -1+ 1= 0 ,这就是很多位置0的由来了。
总结:不管什么时候,只要是知道了next[ j ] == k ,要求next[ k+1 ],就比较arr[ j ]和根据next[ j ] 而回溯得到的arr[ k ]是否相等,如果相等,next [ j + 1 ] == k+ 1;如果不相等,k就再回退,也就是 k = next[ k ] (赋值操作),得到一个根据k回溯得到的k,再比较新的arr[ k ] 和arr[ j ]是否相等,如果相等,就得到next[ j+1] == k+1(新的k);否则k继续回溯,直到找到arr[ k ] == arr[ j ]或者直到k = - 1 为止
3.2.4C语言求解next数组的代码
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void getnext(int len,int* next, char* str2)
{
int j = 0;
int k = 0;
for (j = 0; j < len; j++)
{
if (j == 0) next[j] = -1;//如果为首项就为-1
else
{
k = next[j - 1];
while (1)
{
if ( str2[k] == str2[j - 1] || k == -1)//两种停止条件
{
next[j] = k + 1;
break;
}
else//不结束,进入下一次循环
{
k = next[k];
}
}
}
}
}
int main()
{
char str2[] = "abcfabcad";
int len = strlen(str2);
int* next = (int *)(malloc(len*4));
getnext(len, next, str2);
int i = 0;
for (i = 0; i < len; i++)
{
printf("%d ", next[i]);
}
free(next);
return 0;
}
打印一下结果得到next数组:
按照上面的逻辑,我们就可以很轻松的得到next数组,然后我们就可以把这个运用到KMP算法里面去。
3.3KMP算法实现
接下来我们就可以开始写KMP算法的函数模拟实现strstr函数了。
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <assert.h>
void getnext(int len,int* next, char* str2)
{
int j = 0;
int k = 0;
for (j = 0; j < len; j++)
{
if (j == 0) next[j] = -1;//如果为首项就为-1
else
{
k = next[j - 1];
while (1)
{
if ( str2[k] == str2[j - 1] || k == -1)//两种停止条件
{
next[j] = k + 1;
break;
}
else//不结束,进入下一次循环
{
k = next[k];
}
}
}
}
}
char* KMP(char* str1, char* str2)
{
assert(str1 != NULL && str2 != NULL);
int len = strlen(str2);
int* next = (int*)(malloc(len * sizeof(int)));
getnext(len, next, str2);
int i = 0;
int j = 0;
char* ret = NULL;
while (1)
{
do
{
i++;
j++;
} while (str1[i] == str2[j] && str1[i] != '\0' && str2[j] != '\0');//这里进来先加一防止j重置之后变为-1
if (str2[j]== '\0')//如果str2[j]为'\0'表示遍历成功
{
ret = &str1[i] - j ;
break;
}
else if(str1[i]== '\0')//如果str1[i]为'\0'表示遍历失败,返回空指针
{
ret = NULL;
break;
}
else//表示匹配错误,i不动,j回退到next位置
{
j = next[j];
}
}
free(next);
return ret;
}
int main()
{
char str1[] = "aaaaabcabcabc";
char str2[] = "aaabc";
char* ret = KMP(str1, str2);
printf("%s\n", ret);
return 0;
}
就这样就做好了KMP算法模拟实现strstr,由于我不想写了,所以nextval的优化实现有时间再更吧!
欢迎大家指出问题!