解决什么问题
在了解一个算法的开始,首先我们需要很清楚的知道该算法的目的才对,就跟我们去了解一个函数或者方法,得需要知道其输入输出以及function。
kmp算法是针对字符串,它是用来从一个字符串中找到目标字符串。
针对这个问题,首先我们来想一下暴力的解法,因为大部分算法都是去改进暴力解法,取一个巧。从一个字符串中寻找目标字符串。
比如
S; abcabccabcaaabcabcd
T: abcabcd
上面的例子便是要去从S中寻找T的位置,返回找到的S的索引位置。
我们暴力的解法其实很好去想。
就是遍历S的每个字符,每次都是与T的字母逐个进行对比,这种肯定能找的到,当然时间复杂度也很高。很容易就分析出来,时间复杂度是O(n*m),n是S的长度,m是T的长度。
那么kmp算法就是在该暴力解法的基础上进行“些许”的改进,排除掉一些重复的比较,从而使得整体的时间复杂度降低。降低到O(n+m)。
kmp为什么更快
明白算法解决什么问题之后,也知道问题的一般暴力解法之后,我们需要进一步想一个问题,为什么该算法能够更快,哦,是因为该算法相较暴力解去除掉了很多的重复情况(或者采取了一种讨巧的不同于暴力解的方法,不过也可以理解成就是不考虑很多情况)。
那么kmp算法去除掉哪些重复情况呢?
下面我们来看一下。
S; abcabccabcaaabcabcd
T: abcabcd
我们用暴力解法来看,遍历到S的第一位,下面我们发现第一位与T的第一位相同,那么往后比较,比着比着发现T的第七位与S的第七位不同,这时候,暴力解法会干什么呢?
暴力解法会怎么做呢?
很明显:
S; abcabccabcaaabcabcd
T: abcabcd
将T往右移动一位,然后继续挨个比价。
但是很明显,这样比较肯定是不符合的,只能再往右移动一位,又不对,只能再往后比较一位,哦终于发现,当移动到下面这样,第一位终于对上了
S; abcabccabcaaabcabcd
T: abcabcd
然后我又呆呼呼的去比较第二位,嗯相同,不错,第三位相同不错。第四位不同,完了,又要重来。
所以暴力解法从第一次匹配失败之后总共移动了多少次?数一数,移动了3次,然后比较了3次,发现不对。一共操作了6次。
然而,我们人来看,明眼人一眼就看出来,当我第一次匹配失败,直接把T往右移动三格,T的前三位和S对应的三位不是直接相同吗?你暴力解要6次,我移动一次不就好了?
确实是这样,这里一步就能做到的事,暴力方法用了6步。然而问题来了,我们如何一步就做到如此?
仔细观察我们第一次匹配失败的情形:
S; abc"abc"cabcaaabcabcd
T: "abc"abcd
看我用引号标记的这些部分,它们是不是相等呢,是的,于是我便将T往后移动,使得引号标记的绿色部分对齐,因为它们相等,我们直接从引号绿色部分之后的一个字母开始匹配(比较),从而节省了开销。
S; abc"abc"cabcaaabcabcd
T: "abc"abcd
以上便是kmp之所以更快的原因,但是我们想一下,为什么我们能想到引号绿色部分能够相等呢?
我们在进行第一次匹配的时候,是这样进行匹配的,a与a一样,b与b一样,c与c一样,a与a一样,b与b一样,c与c一样,嗯?abc,好像在哪里见过,对的。我们已经匹配上的这一段里首尾有相同的部分。那么因为T也能够匹配的上,所以T中也是首尾有相同的部分,那么,我便可以直接将T的头部拉到S之前匹配部分的尾部。
如果上面的还不好理解,下面我们用一个更清晰的示意图来理解。
在紫色箭头处匹配失败。
蓝色和黄色的区域是相等的,即已经匹配成功的部分,首部和尾部是有一块相等(最大)的地方,那么自然而然,S的黄色能和T的黄色区域直接匹配的上。我们移动T,使得黄色和黄色对齐,然后直接从紫色简单重新开始匹配
以上便是kmp算法为什么更快的原因,该算法发现,由于我们有时候目标字符串有一定的特性,所以它不用每次都从头开始寻找,而是直接将首尾相等的部分对齐,从而节省时间开销。
但问题来了,上面讲的是我们人工寻找的逻辑,具体算法中如何来实现这种发现已匹配部分的首尾相等部分呢?
用next数组。下面我们就来讲next数组。
next数组
由于我们每次已经匹配的部分都是字符串T的从0号位开始的子串,我们也不知道匹配何时会失败,但无论什么时候失败,我们都需要知道当前已匹配部分首部与尾部相等的部分。
那么这个思路就简单了,反正都是从0号位开始嘛,那么我对每个位置都求一个子串,然后求这些子串的首尾相等部分的长度。
就用上面的例子好了。
T : a b c a b c d (首尾相等的部分最长要小于子串长度)
子串: 已匹配部分的首尾相等部分长度
0-0: a 0
0-1: a b 0
0-2: a b c 0
0-3: a b c a 1
0-4: a b c a b 2
0-5: a b c a b c 3
0-6: a b c a b c d 0
由此,我们便知道了对于子串a,ab,abc,很明显找不到他们首尾相等的部分,在kmp算法中,我么只能老老实实的一步一步往右移动。
而对于abcabc这个就舒服了,当匹配到这里失败了,我们发现它的首尾相等部分长度为3,则我们可以直接往右移动3格,并且T的前三个字母我们不用匹配,直接用之前匹配失败的S的那个字母与T的第四个字母进行匹配即可。
这里0-6的这个子串没有意义,因为它不可能出现,它出现,则说明已经匹配成功了。
所以next数组实际上存储了当前匹配情况下的首尾公共部分的长度,当匹配失败时,我们可以通过查询next数组next[i]值,直接往右移动next[i]个单位。
例如如果我匹配了abcabc失败,则我直接往右移动3个单位。如果我匹配了abcab失败,则直接往右移动2个单位。
当然,上面的next数组还不是最终的next数组(其实是prefix table),实际上我们要进行一点处理。因为0-6其实没有意义,所以我们将0-6的删去,把整体的数组往后移动一格,然后首尾置为-1,从而形成了最终的next数组(实际上只是为了编程的方便)
最终的next数组就是[-1,0,0,0,1,2,3]
。
讲完了next数组是什么之后,又有一个很重要的问题要解决了,就是我们如何生成next数组,我们人工的进行分析子串当然容易,但是计算机不行啊,它哪会像咱们这样数,如果暴力的解法来生成next数组的话,就会很慢,反而会导致最终的kmp算法更慢,还不如暴力解,所以我们需要一个很快的生成next数组的办法。下面我们就来讲一讲如何生成next数组。
如何生成next数组
这里我们的思想是这样的:
我们当前的首尾相等部分的长度取决于前一次的首尾相等部分长度。
比如上一次首尾相等长度是2,abcab。(这里字符起始是0号位)
那么我这一次是abcabc(新多了最后一个字符c),我只需要去比较该子串中第2号位字符与我最新的字符c是不是相等,然后一看,果然相等,那么此时,我们的首尾相等长度 = 上一次首尾相等长度+1。
而当我上一次的首尾相等长度是0?,那么这一次我就直接比较第0号位字符与最新的字符是否相等,如果相等,则当前长度 = 0+1,如果不是则还是0。
如此一看,很简单,但事实并不是如此简单。
考虑一下,如果上一次长度是i,而这一次第i号位的字符与最新的字符不相等。此时怎么办?直接长度为0?不是的,我们看一个例子。
首尾相等部分长度长度
上一次: (a b c d a b c)(a b c d a b c) 7
这一次: (a b c d a b c)(a b c d a b c) d ?
看这个例子,这一次的首尾相等部分长度为多少?我们知道肯定小于7,但是多少呢?是0吗,肯定不是。我们可以人工的找到:
首尾相等部分长度长度
上一次: (a b c d a b c)(a b c d a b c) 7
这一次: (a b c d)a b c a b c d (a b c d) ?
所以肯定不为0,但是,这是我们人眼找到的,真正如何构建算法找到呢?
这里我们有这样几个事实:
- 我们新的相等部分长度一定比之前长度更小。
- 新的相等一定在之前的相等内部。
对于这两个事实,我们做这样的解释。对于1,如果新的相等部分长度大于之前的,显然不对。而对于2,如果新的相等部分不在之前相等部分的内部,我们就从头部来看嘛,新的相等部分不在之前相等部分的内部,则代表新的相等部分长度>之前相等部分长度,则这与事实1矛盾,显然不对。
基于这两个事实,我们进行寻找。
我们要找新的相等部分只需要在之前相等部分的内部寻找。
于是,我们只看后半截,我们去寻找后半截里的 首尾相等部分,如上图蓝色与黄色所示,二者相等。
然后我们知道,这里的长度是3,那么我们用后半截的3号位d字符与我们新的字符d进行比较,所以我们这里的首尾相等部分长度为4。
这里为啥不考虑前面呢?这是因为前半截和后半截是完全相等的呀,我们用这样一个图能更直观的理解。
因为相等,所以黄色部分与黄色部分首尾对应,又因为相等里的子相等,所以黄色部分与蓝色部分也相等,所以首尾黄色部分相等。
当然,找到了子相等部分,也有可能这里的相等部分长度不为4,比如我这里将最后的d改为e,那么显然就不是4了。
不过无妨,我们一直这样迭代下去就可以了。
至此,next数组如何构建的逻辑就讲明白了。下面结合代码来看一看,代码走一遍基本就没啥问题了。
java代码实现与分析
这里的代码我已经做了很详细的注释
public class kmp {
/**
* 构建prefix table,也就是求目标字符串子串的首尾相等部分
* @param pattern
* @return
*/
public int[] setPrefix(char[] pattern){
int len = pattern.length;
int[] prefix = new int[len];
for(int i=1; i<len; i++){
int k = prefix[i-1];//获取前一个子串的最长首尾相等部分长度
//同时k刚好是相等子串首部的后一个,需要判断的当前一个
while(pattern[i]!=pattern[k]&& k!=0){
//如果不等于的话就一直找,找的逻辑是相等部分的首部相等部分,如果不是,继续寻找,这个要想一下是为什么
k = prefix[k-1];
//来想想为什么是k = prefix[k-1]
//其实蛮好理解的,如果不等于,那么出去i点,前面相等的部分一定在当前的相等部分内部,也就是说在相等部分的内部还存在子相等
//这个子相等才是当前点i需要的子相等
//那么就去寻找首部相等部分里的子相等,因为首部相等部分里的 子首部相等 与 子尾部 相等,那么同理,尾部相等部分 中的子首部也对称与它的子尾部 相等,所以首部相等部分里的子首部 与 尾部相等部分 里的子尾部相等
//从而就找到了一个更小的相等部分
//那么再来想一个问题,有没有比这个子首部更大的子首部呢?肯定没有,如果有的话,最大想等的又要修改了,所以,这已经是最大的了。
}
if(pattern[i]==pattern[k]){
//如果找到了,则直接在基础上加1即可
prefix[i] = k+1;
}
else{
//如果找不到,则直接命名为0
prefix[i] = 0;
}
}
return prefix;
}
/**
* 对prefix table 进行一个后移,然后初值赋值为-1,从而就获得了真正的next数组
* @param prefix
* @return
*/
public int[] movePrefix(int[] prefix){
for(int i = prefix.length-1; i>0; i--){
prefix[i] = prefix[i-1];
}
prefix[0] = -1;
return prefix;
}
/**
* kmp算法
* @param pattern
* @param text
*/
public void kmpSearch(char[] pattern,char[] text){
//获取netx数组
int[] prefix = setPrefix(pattern);
prefix = movePrefix(prefix);
//进行kmp查询
//text[i] len(text) = M
//pattern[j] len(pattern) = N
int i = 0, j = 0, M = text.length, N = pattern.length;
while(i<M){
if(j>=N){
//为了排除j>=N导致数组越界的问题
j = 0;
}
if(j == N-1 && text[i] == pattern[j]){
System.out.println("found pattern at :"+String.valueOf(i-j));
//当找到第一个后,还得继续进行匹配
j = prefix[j];
if(j==-1){
//排除AA中找A的问题
j++;
}
}
if(text[i] == pattern[j]){
i++;
j++;
}
else {
j = prefix[j];
if(j == -1){
//当移动到-1时
i++;
j++;
}
}
}
}
public static void main(String[] args) {
kmp demo = new kmp();
char[] pattern = {
'A','B','A','B','C','A','B','A','A'};
// char[] pattern = {'A'};
char[] text = {
'A','B','A','B','A','B','C','A','B','A','A','B','A','C','A','B','A','B','C','A','B','A','A'};
// char[] text = {'A','A'};
demo.kmpSearch(pattern,text);
}
}
参考资料
https://blog.csdn.net/yearn520/article/details/6729426
https://www.bilibili.com/video/BV1Px411z7Yo
感谢