串之BF、KMP算法完美图解
讲这两算法之前,我们首先了解几个概念:
串:又称字符串,是由零个或多个字符组成的有限序列,如S="abcdef"。
子串:串中任意个连续的字符组成的子序列,称为该串的子串,原串称为子串的主串。如T="cde",T是S的子串。子串在主串中的位置,用子串的第一个字符在主串中出现的位置表示,T在S中的位置为3。
模式匹配:模式串的定位运算称为串的模式匹配或串匹配。
假设有两个串S,T,设S为主串,也称正文串,T为子串(S包含T),也称为模式串(与S进行匹配的串)。在匹配前,T称为模式串更为合适,是因为T有可能不是S的子序列。
在主串S中查找与模式T相匹配的子串,如果查找成功,返回匹配的子串第一个字符在主串中的位置。最笨的办法就是穷举所有S的所有子串,判断是否与T匹配。
时刻注意:不管是BF算法还是KMP算法,如果第一次比较就匹配了,程序自然就结束了。故关键是在出现不匹配时,如何确定下一轮谁与谁进行比较,即主串 i 的值Si与子串 j 的值Tj。
例如:S="abaabaabeca",T=" abaabe",求子串T在主串S中的位置, 其中用 i,j 分别表示S和T中正在进行匹配字符的位置。
1. 从 S 串第1个字符开始: i=1, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第2步;
2. 从 S 串第2个字符开始:即 i 退回到 i- j+2 (i-(j-1)+1) 的位置,其中 j-1 的含义是不等之前比较的次数, 简称增量,i-增量意味着 i 回到初值,+1便是回溯到初值的下一位,
即 i=2, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第3步;
3. 从 S 串第3个字符开始:即 i 退回到 i- j+2的位置,即 i=3, j=1,比较两个字符是否相等,如果相等,则 i++, j++;如果不等则执行第4步;
4. 从 S 串第4个字符开始:即 i 退回到 i- j+2的位置,即 i=4, j=1,比较两个字符是否相等,如果相等,则 i++, j++;由于此时 T 串比较完了,执行第5步;
5. 需要返回子串T在主串S中第一个字符出现的位置,j( i ) 移动了T.length次,即 i - T.length=10-6=4。因已匹配,不需要+1回溯到一开始位置的下个位置了。
其代码如下:
int Index_BF(SString S, SString T) //返回模式串T在主串S中第一次出现的位置。因返回的是位置,不是状态,虽都是int,但不用Status,另说清谁是模式串,谁是主串。
{
int i=1,j=1,sum=0; // i 指示主串S中进行匹配字符的位置,j 指示主串T中进行匹配字符的位置;sum用于累计比较次数;
while (i<=S.length&&j<=T.length)//看上面例子分析得出,匹配只有成功和不成功两种情形,换成计算机语言也就是if else;
{
sum++;
if (S.ch[i]==T.ch[j]) //看上面例子,不管那一轮只要成功,那么有i++ ; j++
{
i++;
j++;
}
else //看上面例子,不管那一轮只要失败,那么有i回溯到初值的下一位; j 始终是第1个和Si,即j=1;
{
i=i-j+2; //i=i-(j-1)+1,看上面例子中的解释;
j=1;
}
}//循环完也只有两种情形,要么匹配,要么失败,换成计算机语言仍就是if else;
cout<<"BF一共比较了"<<sum<<"次"<<endl;
if(j>T.length)
return (i-T.length); //返回位置. 比较成功的次数是T.length, i从初值也后移了T.length,i-T.length回到初值
else
return 0;
}
上述算法称为 BF(Brute Force) 算法,Brute Force的意思是蛮力,暴力穷举。其时间复杂度最坏达到O(n*m),n,m分别为S、T串的长度。
实际上,完全没必要从S的每一个字符开始,暴力穷举每一种情况,Knuth、Morris和Pratt对该算法进行了改进,称为KMP算法。
我们再回头看刚才的例子:
从S串第1个字符开始:i=1,j=1,比较两个字符是否相等,如果相等,则i++,j++;按照BF算法,如果不等则i退回到i-j+2的位置,即i=2,j=1。
其实 i 不用回退,让 j 回退到第3个位置,接着比较即可(KMP算法);
是不是像T串向右滑动了一段距离?为什么可以这样?为什么让 j 回退到第3个位置?而不是第2个,第4个?
因为T串中开头的两个字符 和 i 指向的字符前面的两个字符一模一样噢,那 j 就可以回退到第3个位置继续比较了,因为前面两个字符已经相等,不用比较了,如下图。
问题来了, 那我们怎么知道T串中开头的两个字符和 i 指向的字符前面的两个字符一模一样?难道还要比较?
分析发现: i 指向的字符前面的两个字符 和 T串中 j 指向字符前面的两个字符一模一样,因为它们一直相等,才会i++,j++走到后面的位置,不仅主串字串这两个字符一样,ij之前所有字符也一样(S1S2……Si-1=T1T2……Tj-1)。
也就是说,我们不必判断T串中开头的两个字母和S串中 i 指向的字符前面的两个字符是否一样,只需要在T串本身比较就可以了。即T′的前缀T1T2和T′的后缀Tj-2Tj-1比较即可(T':T1T2……Tj-1):
判断T′="abaab"的前缀和后缀是否相等,找相等前缀后缀的最大长度 l 。
长度为1的:前缀"a",后缀:"b",不等×
长度为2的:前缀"ab",后缀:"ab",相等√
长度为3的:前缀"aba",后缀:" aab",不等×
长度为4的:前缀"abaa",后缀:"baab",不等×
注意:前缀和后缀不可以取字符串本身,如果取了,不就是原地踏步吗。串的长度为5,前缀和后缀长度最多达到4(l <5)。
相等前缀后缀的最大长度为l =2,则 j 就可以回退到第l +1=3个位置继续比较了( i 不变)。
现在我们可以写出通用公式,next[j]表示 j 可以回退的位置,T′="T1T2…Tj-1",则:
那么我们很容易求出T="abaabe"的next[j]数组:
解释1:
j=1:根据公式next[1]=0;
j=2:T′="a",没有前缀和后缀,next[2]=1;
j=3:T′="ab",前缀为"a",后缀为"b",不等,next[3]=1;
j=4:T′="aba",前缀为"a",后缀为"a",相等且l=1;前缀为"ab",后缀为"ba",不等,next[4]=l+1=2;
j=5:T′="abaa",前缀为"a",后缀为"a",相等且l=1;前缀为"ab",后缀为"aa",不等;前缀为"aba",后缀为"baa",不等,因此next[5]=l+1=2;
j=6:T′="abaab",前缀为"a",后缀为"b",不等;前缀为"ab",后缀为"ab",相等且l=2;前缀为"aba",后缀为"aab",不等;前缀为"abaa",后缀为"baab",不等,取最大长度2,因此next[6]=l+1=3。
这样找所有的前缀和后缀比较,是不是也是暴力穷举?那怎么办呢?Look……
用动态规划递推一下(数学归纳法,已知n=1成立, 假设n成立,来证明n+1是否成立):
首先大胆假设,已知next[j]=k(求next[j+1]=?),其当前含义是:下一次匹配时, j=next[j]=k, 后判Si==Tk?,意味着子串中,j之前有k-1个字符前后缀匹配,才会有next[j]=k-1+1=k:
那么next[j+1]=? 回想 P97 图4.8,已知 next[j](为表述简洁,不妨令k=next[j]), 如何求next[j+1]? 思考 j=5及j=6:
考察以下两种情况:
-
-
tj=tk:那么 next[ j+1]=next[j]+1=k+1,即,在j+1前,相等前缀和后缀的长度比next[j]=k多1,eg:j=5。
-
-
-
-
tj≠tk:当两者不相等时,我们又开始了这两个串的模式匹配,找 tj 与 next[k] 位置的 tk′比较 (j不变,k变,往前回溯到k'=next[k])。注:程序中的处理,只需要把 next[k] 赋值给 k,即 k=next[k],不用新变量k'。
-
-
如果tj=tk' 则 next[j+1]=next[k]+1=k'+1;
如果tj≠tk',则继续向前找next[k′](k''=next[k']),如果还不相等,继续向前找next[k′'](k'''=next[k'']),直到找到next[1]=0,停止,此时next[j+1]=next[1]+1=0+1=1,即从第一个字符开始,j=1, eg: j=6。
求解next步骤(当前j,求j+1):
首先,第一位的next值直接赋0,第二位的next值直接赋1;
其次,后面求解每一位的next值时(不妨令求j+1),都要前一位(T.ch[ j ])与其next值对应位(T.ch[ next[ j ] ])进行比较。若相等,则该位的next值就是前一位的next值加上1(next[ j ]+1);若不等,继续重复这个过程(T.ch[ j ]==T.ch[ next[ next[ j ] ] ]),直到找到相等某一位,将其next值加1即可。如果找到第一位也都没有找到,那么该位的next值即为1。 
求解next[]的代码实现如下:
void Get_Next(SString T,int next[])//求模式串T的next[]函数值,其实是知next[1],求next[2],依次……,也即假设已知next[j], ++j后求得next[j]即未我所求;
{
next[1] = 0; //初值
int k, j;
j = 1; // j 代表的是后缀末尾的下标, 假设next[j]已知,则 ++j 后,next[j]即是所求;
k = 0; // k代表的是前缀结束时的下标,也就是 j 前有k个字符的前缀T1T2...Tk等于后缀Tj-m+1...Tj
while (j < T.length)
{
if (k == 0 || T.ch[k] == T.ch[j]) //1. j>=2时, 怎么求next[j+1]?next[3]有前缀后缀,相等->,不等->看上面例子2. 当j=1时,怎么求next[j+1]?前缀个数为0->不等<==>k==0后++j;++k;next[j] = k;
{
++j;
++k;
next[j] = k; //++j后才是我们想要的next[j],++k后才是我们想要给next[j]赋的值;一定要先++
}
else //匹配失败的情况,就要进行回溯,下一轮tj与tk'比较,j不动,k'=next[k];
k= next[k];
}
}
用上述方法再次求解求出T="abaabe"的next[]数组:
解释2:
1. 初始化时next[1]=0,j=1,k=0,进入循环,判断满足k==0,则执行next[++j]=++k,即next[2]=1,此时j=2,k=1;
2. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[2]≠T.ch[1],则执行k=next[k],即k=next[1]=0,此时j=2,k=0;
3. 进入循环,判断满足k==0,则执行next[++j]=++k,即next[3]=1,此时j=3,k=1;
4. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[3]=T.ch[1],则执行next[++j]=++k,即next[4]=2,此时j=4,k=2;
5. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[4]≠T.ch[2],则执行k=next[k],即k=next[2]=1,此时j=4,k=1;
6. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[4]=T.ch[1],则执行next[++j]=++k,即next[5]=2,此时j=5,k=2;
7. 进入循环,判断满足T.ch[j]==T.ch[k],T.ch[5]=T.ch[2],则执行next[++j]=++k,即next[6]=3,此时j=6,k=3;
8. j=T.length,循环结束。
- 结果是不是和穷举前缀后缀一模一样,解释1(穷举)同解释2(归纳)的结果?
有了next[]数组,就很容易进行模式匹配KMP,当S.ch[i]≠T.ch[j]时,j回溯到next[j]的位置 继续和 S.ch[i]比较即可。
这样求解非常方便,但也发现有一个问题:当S.ch[i]≠T.ch[j]时,j退回到next[j],然后S.ch[i]与T.ch[k]比较。这样的确没错,但是如果T.ch[j]=T.ch[k], 这次比较就没必要了,因为我们刚知道S.ch[i]≠T.ch[j]啊,
那么肯定S.ch[i]≠T.ch[k],完全没必要再比了 (S.ch[i]≠T.ch[j]=T.ch[k]<==>S.ch[i]≠T.ch[k]). 再向前找下一个next[],即找可k'=next[k]的位置,继续比较就可以了。本来应该和第k个位置比较呢,相当于跳到了k的上一个位置k',减少了一次无效比较。
求解next-val步骤( 当前j,求j ):
- next-val数组第一个值直接赋0;
- next-val第二数:模式串第二个字符为B,对应的下标数组第二个数是1,那就是将模式串的第1个字符和B相比较,A!=B,所以直接将下标数组第二个数1作为next-val数组第二个数的值;
- 第三个数:模式串第三个字符为A,对应下标数组第三个数为1,取其作为下标,找到模式串第1个字符为A,A=A,那取next-val的第一个数做为next-val第三个数的值,也就是0.
位置 |
1
|
2
|
3
|
4
|
5
|
6
|
7
|
---|---|---|---|---|---|---|---|
模式串 |
A
|
B
|
A
|
C
|
A
|
B
|
C
|
next数组 |
0
|
1
|
1
|
2
|
1
|
2
|
3
|
nextval数组 |
0
|
1
|
0
|
2
|
0
|
1
|
3
|
修改next[]程序:
求解nextval[]的改进代码实现如下:
void Get_Nextval(SString T,int nextval[])//求模式串T的nextval[]函数值,在next函数的基础上只需改动tj=tk相等时的情形,让nextval[j]=nextval[k]而不是k ;
{
nextval[1] = 0; //初值
int k, j;
j = 1; // j 代表的是后缀末尾的下标, 若next[j]已知,则 ++j 后,next[j]即是所求;
k = 0; // k代表的是前缀结束时的下标,也就是 j 前有k个字符的前缀T1T2...Tk等于后缀Tj-m+1...Tj
while (j < T.length)
{
if (k == 0 || T.ch[k] == T.ch[j]) //1. 当j=1时,怎么求next[2],也即next[++j]?前缀个数为0,特殊值法知next[2]=1<==>k==0;2. j>=2时,已知next[j], 怎么求next[j+1]?eg. aaaab
{
++j;
++k;
if(T.ch[k]==T.ch[j])
nextval[j]=nextval[k]; //相等了意味着Si无需和Tk在比较了,回溯到k的上一个位置k'=next[k],让Si和Sk'比较
else
nextval[j]=k; //k其实就是next[j]. 当T.ch[k]!=T.ch[j],相当于nextval[++j]=++k,也即nextval[j]=next[j]=k,此情形同next[]函数;
}
else//匹配失败的情况,就要进行回溯,下一轮tj与tk'比较,j不动,k'=next[k];
k= nextval[k];
}
}
/***KMP及改进算法***/
int Index_KMP(SString S,SString T,int next[])//利用非空模式串T的next函数求T在主串S中的位置的KMP算法,形式上近同BF算法
{
int i=1,j=1,sum=0;//i-->S,j-->T,sum-->循环次数
while(i<=S.length&&j<=T.length)
{
sum++;
if(j==0||S.ch[i]==T.ch[j]) // 继续比较后面的字符
{
i++;
j++;
}
else
j=next[j]; // 利用next函数确定下一次模式串中第j个字符与T.ch[i]比较,i不变
}
cout<<"KMP一共比较了"<<sum<<"次"<<endl;
if(j>T.length) // 匹配成功
return i-T.length;
else
return 0;
}
总结:
BF算法中:当主串和子串不匹配的时候,主串和子串你的指针都须要回溯,因此致使了该算法时间复杂度比较高为 O(n*m) ,空间复杂度为 O(1) 。注:虽然其时间复杂度为 O(n*m) 可是在通常应用下执行,其执行时间近似 O(n+m) 因此仍被使用。
KMP算法:利用子串的结构类似性,设计next数组,在此之上达到了主串不回溯的效果,大大减小了比较次数,可是相对应的却牺牲了存储空间,KMP算法时间复杂度为 O(n+m) 空间复杂度为 O(n)。
————————————————
在原文的基础上,加上自己的理解给大家解释下,原文链接:https://blog.csdn.net/rainchxy/article/details/78130155
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现