目录
提示:这一章在串的匹配上会做出自己的理解,其余的排列,插入,结构等操作则不会做出说明。
前言
提示:对于朴素的模式匹配和KMP以及改良的KMP算法做出对比,分析时间复杂度,也会给出比较好的视频资源,做辅助讲解(可能别人的视频比我讲的更好)
提示:以下是本篇文章正文内容,下面案例可供参考
一、串的模式匹配是什么?
串在程序中常被指做字符串,但是像一些字符数组或者是由多个字符或者符号组成的有限序列都可以叫串,是一个宽泛的概念。
在说完串以后,就该谈谈什么叫串的模式匹配了:
在串中寻找与规定的子串相同的位置这种定位操作称为模式匹配
例如在ababaab
寻找字=子串aab
在串中的位置,这里返回首字母位置:5(规定一下串的首字母下标为1)
二、朴素模式匹配
1.思想
给定模式串T在串S中进行匹配,如下:
由于当i=3
时,子串T中的字符与串S中的字串不相等,则此时需要将i回溯此时j也需要回溯:
因为模式串T的首字母就跟串S中的第二个元素不一样,因此直接就能判断是匹配不上的,则需要将i++
,再与模式串T进行匹配,一直进行到n次:
在这里我们首先要清楚一共做了多少次判断,
在第一次匹配中,一共做了3次判断:S[i]=T[j];i=1,2,3;j=1,2,3
在第二次匹配中,一共做了1次判断即S[i]!=T[j];i=2;j=1
……
在第n次匹配中,一共做了1次判断即S[i]!=T[j];i=6;j=1
总的判断一共是8次;
匹配规律:
- 当子串与串的对应元素相同时:i++,j++,例如第一次匹配的时候i和j一直累加到3;
- 当匹配到一半的时候发现某个元素判断为错误后,i回溯到上一次匹配的位置+1;在进行匹配判断;如第二次匹配,第一次匹配(i初始为1)失败以后i=i(上一次匹配)+1, 在第二次匹配中的具体则为: i=1+1;此时子串需要从头开始判断即j=1;
- 当一开始开始就判断为错位,此时i++;j=1如第三次,第四次…
代码思想:
- 判断为相同串S中i++,子串T中j++,进行下一次判断;
- 判断不同则匹配失败将i回溯至S串在上一次匹配时i+1的位置;
- 匹配成功后返回串S中,首字母的位置;
- 当最后一次匹配失败以后则返回0 ;
这里给一个成功匹配的案例,根据上面的思想推一下:
2.代码
代码如下(朴素匹配):
/*返回子串T在串S中第pos个字符之后的位置,不存在则为0*/
int Index(string S, string T, int pos)
{
int i = pos; //一般为1,也可以指定从串S的第pos个位置开始匹配
int j =1; //子串T的下标
while(i<S.length() && j<T.length()){ //在规定长度内循环
if(S[i] == T[j]){
i++; //如果本位判断为相同则进行下一位的判断
j++; //如果本位判断为相同则进行下一位的判断
}else{
i = i-j+2; //匹配失败将i回溯至S串在上一次匹配时i+1的位置
j = 1; //T子串直接回溯到首位
}
}
if(j > T.length()){
return i-T.length();//返回子串T在串S中第pos个字符之后的位置
}else{
return 0; //匹配失败
}
}
3.分析
在朴素的匹配模式中,i和j在匹配失败的情况下进行下一次匹配需要进行回溯,在回溯的时候i是根据上一次的回溯+1(第一次i=1),而子串S的回溯则铁定是从首位j=1开始的,这种朴素的匹配模式的时间复杂度是非常高的,因为: 在最好的情况下:一开始就匹配成功(匹配成功和判断成功需要分清)
O ( 1 ) O(1) O(1) 的时间复杂度。
或者是在串S的长度为n,子串T的长度为m。每次的首字母就不一致,此时进行下一次匹配,则时间复杂度为:
O
(
n
+
m
)
O(n+m)
O(n+m) 其中n是在串S中匹配失败的次数,m是成功匹配时需要判断的次数,如举例中第一次到第n次时的情况。
但是事情永远要想的最坏:比如串S=gggggggggggggggggggggggggggggggc
子串T=ggggc
,根据前面分析中的思想,应该是i=1
时需要判断5次,i=2
时需要判断5次…这个时候的时间复杂度为
O
(
(
n
−
m
+
1
)
∗
m
)
O((n-m+1)*m)
O((n−m+1)∗m) 其中
(
n
−
m
+
1
)
(n-m+1)
(n−m+1)是失败匹配时花费的次数,×m是每次需要判断m次
三、KMP算法
KMP算法则是更具人脑的思维去判断如在S=gggggggggggc
中找到T=ggc
这个子串的在S串中的下标pos,我们一看就知道pos = S.length()-3
,因为前面的在子串的最后一个字符是匹配不上(这里举的例子其实有失妥当,因为这个涉及改进后的KMP算法)。这里有很多书和视频中讲的都是将子串T进行分析,我这里也不举什么子串各个字符都不相同,部分相同,全部相同等等的例子了,他们的思想都是找子串在匹配失败时的字符前面最长相同的部分(这部分肯定和串S能够匹配上),根据子串中相同的一段的长短给定一个数值k,而这个数值有两个含义:
- 对串S而言,不在进行回溯,也就是在第u次匹配中失败后,i的累加值保持不变,子串进行回溯而回溯的位置就是k。
- 对于子串T而言,因为子串中有相同的部分,需要找到最长的那一段(因为在匹配判断的时候,最长的那一段也会在串S中出现),找到以后需要进行对齐(串S和子串T对齐即进行回溯),对齐的位置(这里指的就是k)就是最长的那一段。
举个例子:
这里2到3有一个跳跃,在2圈出来的两个部分i=3字符判断不相等,则i++,j++,在第3步时,i=4,第4步时,根据第3步匹配判断的继续,i++,j++;当i=10,j=7时判断为不相同,此时i=10保持不变,子串j进行回溯,回溯的的位置是子串T当前字符判断为不相同的前面的部分(就是j=7)以前的部分(1<=j<=6)这个部分最长相同部分(这个有要求,必须是前缀和和后缀相同的串)
前缀:包含首字符但不包含尾字符;
后缀:包含尾字符但不包含首字符;
拿这里的第3步为例:当前字符之前的串的前后缀的相似度
前缀一定包含T[1]*****
一定不包含T[6]
后缀一定包含******T[6]
但是一定不包含T[1]
这里能找到的只有T[1] = T[6]
因此k=1
那么在第4步回溯对齐的位置就是j=k,对应i=10;
通过上面的例子可以清楚的知道,相比朴素的模式匹配,KMP算法减少了很多不必要的判断(第3步到第4步就体现出来了)。内在思想就是利用了相等具有传递性的这个特性。另外,我们把需要对齐的数构建成一个数组的形式,并命名为next,有的书也叫match数组,得到k值得核心公式为:
n
e
x
t
[
j
]
=
{
0
,
当
j
=
1
时
M
a
x
{
k
∣
1
<
k
<
j
,
且
′
p
1
…
…
p
k
−
1
′
=
′
p
j
−
k
+
1
…
…
p
j
−
1
′
}
此
集
合
不
为
空
1
,
其
他
情
况
next[j]= \begin{cases} 0,当j=1时\\ Max\{k|1<k<j,且'p_1……p_{k-1}'='p_{j-k+1}……p_{j-1}'\}此集合不为空\\ 1,其他情况 \end{cases}
next[j]=⎩⎪⎨⎪⎧0,当j=1时Max{k∣1<k<j,且′p1……pk−1′=′pj−k+1……pj−1′}此集合不为空1,其他情况
这里说一下第二个部分,意思就是上面阐述的当前字符之前的串的前后缀的相似度最大的部分
next数组的长度与子串T的长度相同,取值按上面公式取值,如:
例如在
j=5时:
与串S匹配判断为不相同,此时有前缀
′
p
1
p
2
′
'p_1p_2'
′p1p2′等于后缀
′
p
3
p
4
′
'p_3p_4'
′p3p4′且这是最长的。
j=6时:
与串S匹配判断为不相同,此时有前缀
′
p
1
p
2
p
3
′
'p_1p_2p_3'
′p1p2p3′等于后缀
′
p
3
p
4
p
5
′
'p_3p_4p_5'
′p3p4p5′且这是最长的。
有了这个数组,在进行子串匹配的时候就能减少很多不必要步骤
1. 思想
与朴素模式匹配法不同,KMP算法不再回溯串S(等式具有传递性这个性质导致了回溯串S不在具有必要性)
例如串D位置的元素与子串d位置的元素不相等,但是在ABC三个位置的元素与子串abc三个位置的元素对应相等,而子串中a位置与c位置的元素又相同(红底相同)而C位置的元素与子串c位置的元素又相同,则子串a位置的元素与C位置的元素相同,在匹配中我们直接把a位置与C位置进行对齐,减少其它没有必要的匹配判断。(这里指的元素可以是串或者单个字符,另外一定要明确指出时前缀和后缀中最大的相似部分)
根据匹配数组next就可以进行对齐,再进行匹配即可。
2.代码
/*match()函数是对模式串T的自身关于在与串S匹配失败后
对匹配判断不相同的字符之前的串的前缀与后缀的最大相似程度的判断例如
上面举例子中的第三步,但是我们可以人为的将模式串T中的所有位置的
当前字符之前的串的前后缀的相似度全部求出来,直接调用*/
void match(string T, int next[T.length()])
{
int i,j;//i为遍历子串T的时候的下标
//j为写入next数组的位置以及写入的值
i=1;
j=0;
next[1] = 0; //第一个字符匹配判断为不相同,而它前面的子串长度为0
//因此对齐位置也是为0
while(i<T.length())
{
if(j==0 || T[i]==T[j]){ //T[i]后缀的单个字符 T[j]前缀的单个字符
i++; //下一位
j++; //下一位
next[i] = j; //将k值写入next[j]位置,这里j就是k;
}else{
j = next[j]; //在i和j不断前进之后
//字符之前的前缀与后缀不满足最大相似程度
//则进行回溯
}
}
}
int Index_KMP(string S,string T,int pos)
{
int i = pos; //从pos位置开始匹配
int j =1; //子串T的下标
int next[T.length()]; //创建next数组
match(T,next); //分析T子串,得到next数组
while(i<=S.length() && j<=T.length()) //循环
{
if(j==0 || S[i]==T[j]){ //匹配判断为相同元素或者是回溯到j==0时
i++; // 下一位
j++; //下一位
}else{
j = next[j]; //子串回溯到合适的位置
}
if(j>T.length()){//如果j的取值大于T本身的长度
return i-T.length(); //返回匹配到S与T相同的子串的第一个字符
}else{
return 0; //在串S中没有找到与T相同的子串;
}
}
}
这里有三个值得研究的地方:
if(j==0 || T[j]==T[i])
,这里判断的是j==0
而不是其它的数
因为next[1]=0
在回溯到最后肯定会存在i++
(实在找不到最大相似部分,子串/模式串T需要向后移动) 而j=0
时则会执行i++
同时在判断体内执行了j++
从而T[j]代表的前缀子串至少是从位置1开始的子串
j = next[j]
表示i位置的字符之前的前缀与后缀不满足最大相似程度时,j需要返回的位置。next[i] = j;
表示最大的相似程度。
如下图所举的例子:
next数组中存放的是子串T的自身元素之前前缀串与后缀串的最大相似程度
3.分析
对于match()函数而言m=T.length()
只存在一个while(i<T.length())
循环所以时间复杂度为
O
(
m
)
O(m)
O(m)对于Index_KMP()函数而言主要是while(i<=S.length() && j<=T.length())
循环,该循环中时间复杂度主要是与串S的长度有关,时间复杂度为
O
(
n
)
O(n)
O(n),因此时间复杂度为
O
(
n
+
m
)
O(n+m)
O(n+m)
三、改良KMP算法
如果串S='cccclcgcccc'
和串T=cccccgcc
根据KMP算法,首先做匹配,求出next数组,match(T,next);可以得到next[]为:
i=j=5
时S[5] != T[5]按照KMP算法,需要回溯子串T,此时j=next[j]即 j=4;
就是2这一步,但是这一步客观地来讲是不需要的,对于步骤3而言也是如此,因为他们都有一个共同特点T[1]=T[2]=T[3]=T[4]=T[5]
而S[5]!=T[5]
因此S[5] != T[i] 其中i=1,2,3,4,5
,发生了4次不必要的判断,因此改良的next数组就是将匹配过程中存在相同字符的子串T进行修改,这里将next[i]=0,i=1,2,3,4,5即可。
在其它子串T中,我们将nextval[i] = nextval[j]
即可。
例如上面的T=cccccgc
匹配数组:
2.代码
void good_match(string T,int *nextval)
{
int i = 1;
int j = 0;
nextval[1] = 0;
while(i<T.length())
{
if(j==0 && T[i]==T[j]){//T[j]前缀的单个字符
//T[i]后缀的单个字符
i++;
j++;
if(T[i] != T[j]){ //判断当前字符与前缀字符不相同
nextval[i] = j;
}else{
nextval[i] = nextval[j]; //将前缀字符的nextval值赋值给nextval[i]
}
}else{
j = nextval[j]; //回溯
}
}
}
3.分析
时间复杂度有所减少,但是与前面的KMP算法类似都是 O ( n + m ) O(n+m) O(n+m)就不过多分析
总结
KMP算法原理其实很简单,如果上述理解困难,可以看看这几位大神的视频。
王道计算机考研
【浙江大学】数据结构