楔子 - 暴力搜索
我们知道:对于字符串匹配,最简单也是最容易理解的BF(暴力破解)的方式,就是对于给定的字符串,索引从头到尾,一个个地去与目标匹配的字符串进行匹配。
从1-2-3-4-5-6-7,查找1-2-3-5
那么我们会如此去匹配:
1-2-3-4-5-6-7
1-2-3-5
5不匹配,往后走一步(0是为了排版方便,仅代表占位):
1-2-3-4-5-6-7
0-1
不匹配,我们继续往后走,直到走到剩余字符串长度和匹配字符串长度相等。
在分析暴力匹配的时候,根据空间复杂度等考虑,很容易会产生一个联想:
-
如果我在匹配之前,先分析目标的字符串,那么是否在匹配工作开始后,我们就可以根据目标的特征,在一次匹配失败后跳过一些位置的检查?
-
还是上面的例子:
1-2-3-4-5-6-7,查找1-2-3-5
那么我们会如此去匹配:
1-2-3-4-5-6-7
1-2-3-5
到这一步我们已经知道前面4个数都不匹配了,也不能作为下一个匹配的开始位置,那么可以跳过,继续往下执行判断:
1-2-3-4-5-6-7-0
0-0-0-0-1-2-3-5
此时长度已经超出末尾了,我们结束匹配,并得出没有字符串匹配的结论。
-
假设这个基于直觉的判断是有效的,我们需要明确定义相应的步骤的可执行方案:
- 如何确定已走过的那些数,它是不匹配的?
- 随后,匹配失败后我们从哪里来跳过?
如果可以用逻辑化的语言来描述这两个步骤,那么我们的假设就可以转化为可以实现的算法,来改进暴力搜索这个较慢的算法。
-
BF的改进 - KMP
因为很多KMP算法有一些看不懂,参照别人的博客,希望自己来推导一次,这样记忆得更加深刻。
其中有一些地方我实际上确实看不懂,比如:
- next数组为什么在发生i,j不匹配情况时,我们是取next[i-1]来进行判断的?
从头开始
我们先从第一个字符的下一次比较开始。
还是上面的例子:
1-2-1-2-3-1-5-1-2-3-5(str),查找1-2-3-1-5(pattern)
此时我们最直观的改进方式就是:
-
找到pattern中,第二个和首字符相同的字符,例如上面的
1-2-3-1-5
第二个和首字符相同的字符就是第四个位置的1。
这样子,如果出现了匹配到第四个字符,第五个字符不相同的情况例如:
1-2-3-1-2-3-1-5-1-1-1-1-1
1-2-3-1-5
我们在第一次进行匹配的时候匹配到标粗的部分,因为我们知道pattern中第四个字符是和第一个字符相同的,同时第一次验已经验证了str中第四个字符和第一个字符相同,那么我们第二次在str中进行模式的匹配,只要从str的第四个字符开始:
1-2-3-1-2-3-1-5-1-1-1-1-1
1-2-3-1-5 对于这一改进可以很清楚地知道,在字符串第一个字符在后面出现的频次不高的情况下,这个方法确实可以提高我们搜索的效率,伪代码描述如下:
int strStr(String str,String pattern){
int idx = 0;
for(int i =1;i<pattern.length();i++){
if(pattern.charAt(i) == pattern.charAt(0)){
idx = i;
break;
}
}
//compare
for(int comp = 0;comp<str.length-pattern.length();comp++){
...
for(int j =0 ;j<pattern.length();j++){
if(str.charAt(comp+j)!=pattern.charAt(j) && j>idx){
//下次我们就可以前进j步,-1是因为上面for循环里的j++
comp+=j-1;
}
//...其他处理
}
}
}
结论1
现在我们验证了我们方法至少是可行的,即:
- 通过对pattern进行一次顺序的检查,就可以在匹配进行到若干位置时,根据检查结果,跳过一些不需要检查的点,例如上述的头检查,就可以跳过第一个字符到下一个第一个字符出现的位置。
但如果是以下的情况呢?
1-1-1-1-1-1-1-1-1-1-1-1-1-5
1-1-1-1-5
这种情况我们的改进就无效了,因为我们总需要从下一个字符开始比较。我们能否利用从头字符开始检查进行优化的经验,来让这种改进更加有效呢?
记录和开头连续的最长公共真子串
我们继续上面的例子进行优化:
1-1-1-1-1-1-1-1-1-1-1-1-1-5
1-1-1-1-5
第一次匹配,匹配到第五个字符发现不同了。我们可以用非逻辑化的特定情况的语言,来进行描述:
- 我们既然发现都是1,那么我们直接找下一个是5的是不是就可以了?
1-1-1-1-1-1-1-1-1-1-1-1-1-5
1-1-1-1-5
对于模式字符串,我们可以知道:
- 除了最后一个5,前面都是1
那么,我们在匹配的时候,只要l读到几个1例如上文,我们将读到1的次数写下来就可以了,那么上面的字符串我们只需要每次都判断下一个字符是否是5就可以:
1-1-1-1-1-1-1-1-1-1-1-1-1-5
1-1-1-1-5
不匹配,进行下次判断,此时只比较后面的5:
1-1-1-1-1-1-1-1-1-1-1-1-1-5
0-1-1-1-1-5
。。。。。
这样子我们就可以更多地利用一次匹配中所得到的信息。
总结一下我们目前为止的目标以及我们认为可行的改进方式:
- 目标:尽可能地利用我们在一次匹配中所得到的信息。
- 改进方式:分析我们的模式字符串,进行到某些位置之后,如果发生了不匹配的情况,我们就可以根据模式字符串的特征,跳过一些下一次不再需要检测的字符。
描述我们的改进方案
我们如此定义:
匹配工作进行到模式中的索引i+1的位置发生了不匹配情况(说明到i位置是相同的匹配情况),下一次str保持在匹配不成功的那个位置上(如果第一个就不匹配,我们就让str下一次匹配的开始位置往下走一步),同时从模式的第j索引位置开始进行匹配工作,关系为KMPF,则:
KMPF(i) = j
此时我们的KMPF(i,j)可以描述为:
我们在第i个位置作为结束,下一次开始时我们从模式的第j个字符开始进行匹配
将描述转化为函数
我们的描述实际上是很简单的:
我们在str的第i个位置匹配失败结束,下一次开始时我们从模式的第j个字符开始进行匹配
因为我们期望在O(length(pattern))的时间复杂度中,一次完成我们函数的所有结果,因此通常我们需要一个数组来记录我们的结果,我们记这个数组为next数组。
在实现中我们需要考虑一些额外的情况:
- 是否能利用到上一步推到出来的过程?
- 能利用到多少?
- 如何求出我们下一步的j
我们考虑以下的example作为模式串:
aabaaab
分析我们实现上已知和未知的相关问题:
-
已知:既然我们求的是连续真子串,那么我们下一次的匹配如果跟上一次一样都匹配上了,那么直接加一填到结果数组中就可以了。
-
未知:如果我们匹配失败了,那么我们将要填入数组的值不一定为0,需要怎么得出该值?
例如我们对例子求F:
aabaa-a-b
当我们的F进行到标注的a位置时,容易知道:
-
上一步得到的F结果为2,标出的a需要和b进行比较,此时比较结果为不同。
我们该如何进行下一次计算?
- 置为0/1肯定是错误的,从字符串中我们知道此时F(a)应该为2才对。
我们回顾一下,我们对于KMPF()的定义:
我们在str的第i个位置匹配失败结束,下一次开始时我们从模式的第j个字符开始进行匹配
事实上等价于:
在第i个位置,往前数j个字符,和开头的j个字符相同
回到上面的问题,既然我们在aa-b-aa-a-b这个地方发生了不匹配,发生不匹配的位置设x=5,y=2,那么其实我们可以推出:
- 从(x-y)到(x-1),即索引(3,4)的位置上,和(0,y-1)即(0,1)是相同的
既然此时我们发生了不匹配的情况,我们根据已知结果:str(x-y,x-1 )= str(0,y-1),可以得知:
-
如果把str[y]替换成str[x],此时str[y] = str[x],此时(0,y)和(x-y,x)的字符是相同的
-
替换到原文中,就是:
aa-b-aa-a-b,第一个加粗的b替换为加粗的a:
aa-a-aa-a-b
这样子我们在原来b的位置上换成了a,那么aaa (str(0-2)) = aaa(str(3-5))
-
那么,替换之后,我们可以通过计算KMPF(y+1),来求解KMPF(x+1)的大小
-
为什么可以这么做?
-
回顾一下我们的定义:KMPF(x) = 数组中的[x-1]
-
因为我们使用这一个数组的目的就是:为了我们可以减少匹配次数
-
因为KMPF(y),指的是y-1这个节点上,这一子串和前面开始的多少个字符相同
-
既然子串(x-y,x)和(0,y)在y节点上不相同,但(x-y,x-1)和(0,y-1)相同,那么我们当然希望我们依然可以用到之前推导出的结果,那么如果:
str(x) =str(KMPF(y+1))
那么说明从 (0,y) 的某一个地方(记索引位置为z)开始, (z,y-1) 和 (x-y+z,x-1) 相同且和开头的(0,y-z-2) 的地方是相同的,而且str(y-z) = str(x)相同,说明(0,y-z)和(x-y+z,x)相同,因此KMPF(x+1) 可以通过KMPF(y) 来推导。
-
-
KMPF(y)可以通过KMPF(y-1)推知,因此我们此时只需要关注KMPF(y-1)指向的元素即可。
-
如果依旧发生了不匹配的情况,我们设置新的x,y变量,重新计算即可,直到y=0。
完成我们的KMPF算法
-
至此我们已经完成了我们所希望得到的这个函数了,它的具体描述如下:
方法目标:我们在第i个位置作为结束,下一次开始时我们从模式的第j个字符开始进行匹配
方法回溯过程:通过匹配以及回溯,构建我们的next数组。(见上述)
完整的算法如下:
public static int[] arr = null;
public static int KMPF(String str, int k){
if(arr==null){
arr = new int[str.length()];
arr[0] = 0;
for (int i = 1; i < str.length(); i++) {
int nxt = arr[i-1];
while(str.charAt(nxt)!=str.charAt(i) && nxt!=0){
nxt = arr[nxt-1];
}
if(str.charAt(nxt) == str.charAt(i)){
nxt++;
}
arr[i] = nxt;
}
}
//防止k=-1的时候溢出
if(k<0) return 0;
return arr[k];
}
调用我们的方法实现匹配
我们的KMPF(i) = j描述为:
在i的位置上,往前数j个字符,和开头的j个字符相同。
拟出调用KMPF进行字符串匹配的代码如下:
public static int strStr(String str,String pattern) {
if("".equals(pattern)) {
arr = null;
return 0;
}
int fx = 0,j;
for (int i = 0; i < str.length(); i++) {
for(j = fx;j<pattern.length();j++){
if(i == str.length()){
arr = null;
return -1;
}
if(str.charAt(i)!=pattern.charAt(j)){
fx = KMPF(pattern,j-1);
if(j!=0){
i--;
}
break;
}
i++;
}
if(j == pattern.length() && str.charAt(i-1) == pattern.charAt(j-1)){
arr = null;
return i-j;
}
}
arr = null;
return -1;
}
注意此时我们加上了一些限制条件,来判断是否是正常退出匹配条件,以及字符串中是否越界。
在方法的基础上,可以进一步做优化使得代码更加简练。
总结
KMP算法的求解实际上是我们从头开始这一个简单优化思路的延申,核心思想都是:
- 不改变判断顺序(即从头到尾),在匹配失败的时候尽可能地利用那些已匹配成功的部分的信息,避免多次重复地判断
KMP算法的核心实现,难点在于KMP中next数组(就是实例代码中的arr数组) 这一相关信息求解,核心点在于:
- 从前一步推导后一步的过程 - 这个过程实际上用动态规划的思路会更好理解一些
- 模式字符串匹配失败的时候,回溯的节点为next[i-1] ,这一点也是我之前一直没有搞懂的地方