今天偶然间刷到一题,在目标字符串中查找模式串的起始位置,第一想法就是公共库带的indexOf这类的API,如果是自己实现的话就是经典的KMP算法,但算法实现已经完全模糊了,估计一开始也没掌握,难得再遇,所以打算记录下我关于KMP算法的理解
KMP算法是一个经典的字符串查找算法,全称是Knuth-Morris-Pratt,取自三个发表者的名字
算法背景
假设当前有一个T目标字符串和一个P模板字符串,我们希望在T中查找到P这个模板串的位置
算法思想理解
暴力求解的话就是直接二重循环进行查找
- 假设遍历T串的下标为ti,
- 遍历P串的下标为pi
- 当
T[ti + pi]
和P[pi]
匹配失败时,只是将P串向后移动一位(ti+1)其实有点浪费,毕竟在冲突位之前的匹配区域内都是遍历过的,那么是否可以根据前面匹配区域的特性来决定是否可以将P向右多移几位
举个🌰
T = ‘ABCDABXABCDABDE’
P = ‘ABCDABD’
可以看出在第一次匹配中,P串的最后一位匹配失败
此时按二重暴力的逻辑是P串右移动一位重新进行匹配,但其实下标从[ti,pi - 1]
这个范围内都是匹配上的,根据这一段匹配子串的前后缀可以决定是否可以将P串向右滑动更多的距离
- 假设P可以向右滑动长度为x的距离则
-
当匹配串前y个和后y个相同时,可以直接移动到后y个的起点位置,同时应当取最大y值的情况,避免遗留,保持最小有效移动
取匹配串的前缀集合,上例中为{A, AB, ABC, ABCD, ABCDA}, 和后缀集合{B, AB, DAB, CDAB, BCDAB},前后缀集合交集中长度最大的匹配项位置即是P串要移动到的开头对对齐位置
假设交集串的起始位置为i,交集串长度为len,则对齐后,ti = i,,pi = len
其中len的可以根据适配时的位置下标和交集后缀串的起始位置退出,同理知道长度后也可以推出i的位置
取前后缀集合时应当都是真前后缀集合,因为取到本身时就相当于无移动
到这里基本就是kmp算法的实现思路,核心就是利用前面已匹配上的子串的相同的前后缀,使得子串移动时能进行一次最小的有效移动
但为了不每次都去重新求最大的相同前后缀串,所以出现了next数组的预处理,预处理的遍历对象为P, 毕竟匹配上的前串必然是P的前串。
next[i]
应当为[0, i - 1]
范围内最长前后缀串中后缀的起点即
那么有了next数组后当出现不匹配时,len=pi - next[pi]; pi=len; ti = next[pi]
, 当没有重复公共前后缀时,相当于P串直接跳过这个区域,ti = pi + 1; pi = 0;
算法代码
1、next数组赋值
为了符合常规我们采取记录len的形式赋值next,next[i] = [0, i - 1]区域内最长交集后缀长度
,同时当i==0
时赋值-1
当我们知道next[0 .. i]范围内的值时,则能递推出next[i + 1]的值
int k = next[i]; // [0, i - 1] 范围内的最长交集后缀
表示0~(k - 1)的前缀子串和(i - k) ~ (i - 1)的后缀子串是相等的
此时如果p[k] == p[i]那么next[i + 1] = k + 1;比较好理解
但值p[k] != p[i]时情况就有点复杂
首先因为p[k] != p[i],所以next[i + 1]的值必然是小于k + 1
在建立在p[0, k-1]和p[i-k, i-1]这两段相等的基础上当p[k] != p[i]时
递归求出p[0, k-1]这一段的最长相交前后缀为x,x=next[k], (0~k-1段区域)
对于该x如果此时p[x] == p[i]那么p[i]=x+1;否则继续向下试探下一个x
关于k = next[k]这种试探方法,可以通过上图来理解,
在图1中可以看到在进行p[i]和p[k]的匹配时失配了,那么求出(0~k-1)段的最长交叉缀长度x(=next[k]),该区域如图2的绿色区域所示,绿色区域1,2,3,4的子串是相等的,那么这个时候去匹配p[x]和p[i],若相等,则next[i]=x+1,代表的最长相交前后缀区域就是【绿块1+x块】和【绿块4+i块】
若不匹配则继续想下试探绿块区域内的前后缀
int* getNext(string p) {
int len = p.length();
int *next = new int[len];
next[0] = -1;
int i = 0;
int k = -1;
while(i < len - 1) {// 因为每次循环都是对i+1的赋值
//这个if包含了next[1]=0和递推规律
if (k == -1 || p[k] == p[i]) {
i++;
next[i] = k + 1;
k = next[i]; //保持前一个值的k
} else {
k = next[k];
}
}
return next;
}
2、查找算法
有了next数组后我们就知道当我们在i位置失配时前面的[0,i-1]匹配串的最大相交缀串
根据前面的移动后使得P前缀对齐T的后缀的思路
我们设两个指标变量,延续前面假设的ti和pi,其中ti表示T中正在进行匹配区域的起点,而pi是P中正在进行试探匹配的点,而和pi对于匹配的下标为(ti + pi)
按位匹配时T和P的下标分别为T[ti+pi] EqualTo P[pi];
当pi==P.length()时匹配完成,ti为匹配到串的起点
当T[ti+pi] == P[pi]时,pi++试探下一个位置
当T[ti+pi] != P[pi]时,因为T[ti~pi-1]==P[0~pi]
所以由k = next[pi]获取T[ti~pi-1](即P[0~pi])的最大相交缀串长度
那么下一次匹配段的开头ti=(ti+pi)-k; //(ti+pi为T上当前游标位置)
pi=k; //因为(0~k-1)这一段是交缀区域无需重新匹配
代码
int kmp(string t, string p) {
if (p.length() == 0) {
return 0;
}
int *next = getNext(p);
int ti = 0, pi = 0;
while((ti + pi) < t.length() && pi < p.length()) {
if(t[ti + pi] == p[pi]) {
pi++;
} else {
int k = next[pi];
if (k==-1) { // k==-1的情况只有在0-0的位置不匹配时出现
ti++;
} else {
ti = ti + pi - k;
pi = k;
}
}
if (pi == p.length()) {
return ti;
}
}
return -1;
}
一开始本来只是想简单记录下kmp算法找回下当初学习的记忆,但当我想尽可能清晰的解释kmp算法时,发现我可能根本没有理解。
就像getNext()的代码乍一看很简单,基本看过后就可以敲出来,但真要问k=next[k]是为什么又很难解释清楚,而解释不清楚有时候也是自己关于算法的思路理解不清晰,借着这篇博客也算是对kmp进行了重游吧