前文
字符串匹配,其实就是string的findIndex(pattern)方法。暴力法就是从string的每个点位开始匹配pattern,如果不一样就从下个点位开始匹配,最坏情况是O(m*n),比如每次都匹到最后一个字符才发现不一样。
kmp(是三个发明者的首字母)可以从头匹到尾不回头。这个算法有两个步骤,分别要遍历一遍string和pattern,所以复杂度是O(m+n)。
一、遍历string
假设现在的情况是,我匹配进pattern,但是匹到?和!的地方不一样了。假设恰好我就是知道(tip:这个是可以提前算出来的,下面说)此时pattern前面这一子串里有一节相同的前缀和后缀;由于我们是匹配到这的,string和pattern的后缀也一定是一样的,所以这个前缀和string的后缀也是一样的。那么接下来的匹配,我就可以把前缀搬过来,直接匹配?和前缀后的下一个字符了。这个就是kmp的原理。
那也许中间也有地方能匹配呢?这不会漏吗。比如下图中,抛开后缀不谈,恰好中间有一段前缀2相同,我们知道,之所以说它是前缀,就是因为后面有字符不一样,比如下图中的a和b,既然后面都不一样了,那这个前缀也就没用了。那假设a和b也恰好一样,那它又变成了一个前缀,后面总有不一样的。如果真后面都是一样的,它反而就变成一个后缀了,又回到了上面的情况,所以大可放心。
二、遍历pattern
那么要实现上面的关键就在于,对于pattern每个位置,我们都需要知道它前面子串的最长公共前后缀(的长度即可),即一个长度为n的数组。实话说,也可以暴力点,但为了线性复杂度,也依然是从头到尾遍历一遍。用两个指针i=1、j=0框一个子串:
-
一开始,j=0,如果ij指向的字符不相同,说明没有公共前后缀,只有i+1。
-
如果相同,那就是有一个公共字符,ij都+1,记录j为i前面子串的公共前后缀长度。
-
比较tricky的是,如果在j不为0的时候,匹配到不相同的(如下面两张图),该怎么办呢?第一想法可能是让j直接回到0重新匹配,如图1;但在图2里就不行,此时长度为6时不相同,并不能直接置为0(+1),实际是aa=2,也就是说,虽然这个长串不行,但可能这里边还有短串行,所以要缩小范围继续尝试。
算法实际过程是倒查,把j放到j前一位的位置上,即j=dict[j-1],并且每次要重新比较j+1和i是否相同,不相同还要继续这个操作,除非j=0。比如这里要先把j放到dict[j-1]=dict[4]=2,比较b和a,不同;再放到dict[j-1]=dict[1]=1,此时a相同,j最终为1+1=2。
我想了很久这个倒查是什么含义呢?视频里也没有讲。从结果倒推一下,这个倒查dict[j-1]其实是找了j-1(上一轮)的最大公共前后缀。我们的目的是,当前缀aabaab和后缀aabaaa不能匹配时,找到最大公共的前缀的前缀 和后缀的后缀。如果我们把它俩拆看看成aabaa+b和aabaa+a,我们要找的其实是最大公共的第一个aabaa的前缀和第二个aabaa的后缀(就是这里很tricky),而这就是上一轮的最大公共前后缀dict[j-1]。
这个语言实在很难形容......举个例子,aabaa+b和aabaa+a,现在等号左边都相同,但跟着的字符不同;我们只能尝试缩小加号左边,再看看跟着的字符是否相同,只是缩小的时候,前缀的aabaa是往左缩的,后缀的aabaa是往右缩的,它们会变得不一样,但我们要找公共串嘛,肯定得是一样的,所以我们就希望它们缩到一个一样的时候......所以这就是找aabaa的最大公共前后缀了嘛!比如现在缩到aa,aa+b和aa+a还是不同,只能再缩,变成a+a和a+a,此时相同即可停止。
www.bilibili.com/video/BV18k...
www.youtube.com/watch?reloa...
code
js
var strStr = function(haystack, needle) {
const m = haystack.length
const n = needle.length
const dict = new Array(n).fill(0)
let j = 0
for (let i = 1; i < n; i++) {
while(needle[i] !== needle[j] && j > 0) {
j = dict[j - 1]
}
if (needle[i] === needle[j]) {
j++
}
dict[i] = j
}
j = 0
for (let i = 0; i < m; i++) {
while(haystack[i] !== needle[j] && j > 0) {
j = dict[j - 1]
}
if (haystack[i] === needle[j]) {
j++
}
if (j === needle.length) {
return i - needle.length + 1
}
}
return -1
};
最后代码长这样,看起来还是蛮简单的。
第一个循环是计算字典,这是几个case合并的结果,可以按case写出来再合并。第二个循环是遍历字符串,公共前后缀可能不止一对,先从最大的前缀开始查。
......
可以看到这两个循环其实是一样的东西,都是因为前缀可能不止一对,先从最大的开始倒查,最后如果相同则+1。这样说来,实在是太简单了,妙啊。
又试了一遍,已经可以轻松默写了......