要完善一个String字符串类,那么实现查找子串的功能是必不可少的,实现子串查找可以使用朴素算法,每次匹配一个字符后向右移动一个位置,这样执行下来效率是比较低的,所以就有了KMP算法,它能够准确的知道当前字符不匹配后字符串应该向右移动多少位,由于刚接触KMP算法,所以很多还明白的不是很透彻,在此记录在学习KMP算法过程中的一些理解。
先看一张利用KMP算法得出的一张字符匹配图:
我们稍微对图中的流程进行分析:
0、题目是要在一长串字符BBC ABCDAB ABCDABCDABDE中匹配一个子串ABCDABD.
1、第一步是子串的第一个字符和母串的第一个字符匹配,匹配失败,子串整体右移1位。
2、重复移动操作后,进入到第二步,能够匹配前六个字符,但是第七个却匹配失败,这是子串整体向右移动4位(为什么是4?)。
3、移动四位后再次挨个匹配字符,发现在第三个字符处匹配失败,此时右移2位(为什么是2?)
4、移动两位后在第一个字符处就匹配失败,然后子串右移1位。
这里为什么移动那么多位数源自前人的一些伟大的发现:匹配失败时右移位数与子串本身相关,与母串无关;移动位数 = 已匹配字符数 - 对应的部分匹配值;对应部分匹配值来自子串的唯一存在的一个部分匹配表。
现在我们先给出上面子串的部分匹配表,验证一下移动位数的计算方法是否正确:
现在根据这个表来计算一下:
图中可以看出前六位是匹配成功的,在第七个字符匹配时失败,根据查表可以知道匹配成功6个字符对应的部分匹配值为2,所以移动位数就是6-2 = 4.
那么问题来了,部分匹配表是怎么获得的?
在讲解部分匹配表之前我们需要知道它是由匹配成功字符个数对应的部分匹配值构成的。
在解释部分匹配值的概念前介绍一下子串中前缀和后缀的概念:
前缀:除开最后一个字符以外,其余字符串的全部含头部字符的组合。如:abcd的前缀是:a、ab、abc。在所有组合中,a需为起始字符。
后缀:除开第一个字符以外 ,其余字符串的全部含尾部字符的组合。如:abcd的后缀是:d、cd、bcd。在所有组合中,d需为结尾字符。
知道了前缀和后缀,那么应该知道部分匹配值其实是由子串的前缀和后缀的共有组合的最长组合的元素个数确定。
通过列举我们可以比较容易的找出部分匹配值,但是在编程中我们应该如何编码?
下面介绍实现关键:
1、我们将部分匹配表记做PMT,通过观察我们可以发现一个字符匹配成功的部分匹配值始终为0,即PMT[1] = 0,因为前缀和后缀都为空,所以共有组合最长为0,也即是部分匹配表中下标为0的部分匹配值为0.
2、从2个字符匹配成功开始递推:假设PMT[n] = PMY[n-1]+1(再次强调PMT[n]的值就是最长共有元素组合的长度)
当假设不成立时,PMT[n]在PMT[n-1]的基础上减小。
现在进行一个递推实例:
建议先理解各个概念再看递推分析。
在讲解之前给出两条规则:
规则1:当前需要求出的ll值由历史ll值推导出来。
规则2:当可选的ll值为0时,直接比对首尾字符,若相等则 ll = 0+1 = 1;不相等则 ll = 0.
使用部分匹配表(KMP算法)
设母串的下标为i,子串的下标为j
编程实现部分匹配表和KMP算法
属于String类成员函数。
int* String::make_pmt(const char* p)//建立部分匹配表
{
int len = strlen(p);//决定部分匹配表的长度
int* ret = reinterpret_cast<int*>(malloc(sizeof(int) * len));//指向部分匹配表
if(ret != NULL)
{
int ll = 0;//部分匹配值
ret[0] = 0;//长度为1的字符串的ll值为0
for(int i = 1; i < len; i++ )//从长度为2的字符串开始计算,直到长度为len
{
while((ll > 0) && (p[ll] != p[i]))//挑选出一个可用的部分匹配值,根据部分匹配表的顺序位置挑选,直到部分匹配值为0,此时直接匹配首尾即可
{
ll = ret[ll - 1];//找重叠部分的ll值,此时的ll值也就相当于重叠部分的长度,减一是为了匹配部分匹配表的下标
}
if(p[ll] == p[i])//新加进来的字符不能作为种子,找到种子并扩展一位后直接比对扩展的字符
{
ll++;
}
ret[i] = ll;//在部分匹配表中存储部分匹配值
}
}
return ret;
}
int String::kmp(const char* s, const char* p)
{
int ret = -1;
int sl = strlen(s);
int pl = strlen(p);
int* pmt = make_pmt(p);
if((pmt != NULL) && (pl > 0) && (pl <= sl))
{
for(int i = 0, j = 0; i < sl; i++)
{
while((p[j] != s[i]) && (j > 0))//匹配失败的情况
{
j = pmt[j - 1];//j的新值为ll值。
}
if(p[j] == s[i] )//理想情况,匹配成功子串下标就后移一个。
{
j++;
}
if(j == pl)//匹配完最后一个字符后表示匹配完成
{
ret = i + 1 - pl;//通过差量来确定起始位置
break;
}
}
free(pmt);
}
return ret;
}
程序过程在语句注释中比较详细,若是将分析放在外面可能还难于理解。
kmp函数返回的 是子串在母串中的位置,若查找不到返回-1.
通过分析朴素算法查找子串我们知道他的算法复杂度为O(mn),而经过kmp算法优化后子串查找算法为O(m+n)
所以得出部分匹配表是提高子串查找算法的关键
最后再说明一遍部分匹配值定义为前缀和后缀最长共有元素的长度,即ll值。
在分析原理中我们是通过递推的方法产生的部分匹配表。
通过确定子串移动位数来提高查找效率。
多看几遍。
感谢狄泰唐老师