字符串匹配问题
给定一个母串和一个子串,在母串中找出子串出现的第一个位置 (字符串下标从0开始)。如果不存在,则返回 -1。
当子串是空字符串时,我们应当返回0值。这与C语言的 strstr() 以及 Java的 indexOf() 定义相符。
我们用LeetCode的28题来检验我们模板的正确性。
题目链接:https://leetcode-cn.com/problems/implement-strstr/
一、暴力法
暴力法的思想很简单,就是用母串的每一位作为起点,依次和子串匹配。(字符串下标从0开始)
如下代码,要注意几个问题:
1.因为题目给的母串和子串名字过于繁琐,所以用引用来修改一下,母串为 s ,子串为 t,并且母串的长度为m,子串的长度为n 。
2.我们用 i 作为母串的指针,以 i 为起点依次和子串匹配;用 j 作为子串的指针。
3. 在匹配失败时,我们需要将母串的指针 i 回溯到 i+1 的位置(因为匹配中 i 增加了 j 所以就是 i-j+1 的位置),子串指针 j 则需要回溯到0。
4. 当 j==n 时,那么就说明匹配到了一个结果,可以返回答案。如果匹配过程中没有return,说明母串中未出现子串,返回-1。
5. 由于 i<m 是while循环的判断条件,而 j 到了n又会直接返回答案,所以不存在指针越界的问题。
class Solution {
public:
int strStr(string haystack, string needle) {
string& s=haystack; //母串
string& t=needle; //子串
if (t=="") return 0;
int m=s.size(); //母串长度
int n=t.size(); //子串长度
int i=0; //母串指针
int j=0; //子串指针
while(i<m){
if (s[i]==t[j]){ //匹配
i++;
j++;
if (j==n) return i-n;
}else{ //不匹配
i=i-j+1;
j=0;
}
}
return -1;
}
};
复杂度分析:O(m*n)
这里用两个比较极端的例子来分析暴力法:
例子1: 母串:aaaaaaaaaaaabaaaa
子串:baaa
由于在匹配的过程中,母串的字符'a'和子串的'b'一直不相等,所以母串指针 i 会持续++;只有遇到母串的那个正确的字符'b'时,j 才动起来,并找到了答案。
这种情况是我们最理想的情况,此时暴力法是很快的,因为母串的指针 i 并没有回溯,而是持续的 i++;是线性增长的,这种理想情况是时间复杂度是O(m+n)。但是现实中,往往理想的情况很少出现,再看第二个例子。
例子1: 母串:aaaaaaaaaaaabaaa
子串:aaab
当遇到这种情况时,我们再用暴力法去匹配,会发现完蛋了,每次指针 i 和 j 都同时增长半天了,眼看就要成功了,但是由于最后一位不相等, i 回溯到了 i+1 的位置,j 回溯到了0。一夜回到解放前,只好从头再来。这种回溯使得暴力法效率低下,所以暴力法的时间复杂度最坏的情况下是O(m*n),也就是两个字符串的长度之积,这也是我们常说的暴力法的复杂度。
通过上面两个小例子对暴力法的时间复杂度的分析,我们很容易感受到,暴力法之所以慢,是因为母串和子串匹配半天然后失败又回溯的问题。母串的枚举变量 i 明明向后匹配了很多位,却因为失配又回到了 i+1的位置,几乎是从头再来。所以我们要想办法设计算法,充分利用母串和子串已经匹配的部分,来指导匹配失败时,我们应该从哪里继续匹配(而不是傻傻的从头再来),这正是KMP算法要做的事情。
二、KMP算法
1.引入例子
从上述的分析中我们得出,暴力法之所以慢,是因为匹配过程中的失败,导致了母串的指针 i 回溯到 i+1的位置。
KMP算法的思想就是利用匹配过程中所获得的信息,来指导匹配失败时的回溯。
这样说有些空洞,来看一个例子:
母串 s="abbaabbbabaa"
子串 t="abbaaba"
在这个例子中,我们依然从第0位开始匹配:(字符串下标从0开始)
abbaabbbabaa
abbaaba
到第6位时我们发现匹配失败(此时i=6 j=6),如果按照暴力法继续匹配的话,则是把B串向后移一位,重新从第一个字符开始匹配(也就是i=1 j=0重新匹配),如下:
abbaabbbabaa
abbaaba
且慢!!!真的必须从头再来吗?
既然我们的指针已经走到了第6位,那么我们也就知道了母串和子串的前6个字符是匹配的,我们能否利用这个已知信息来指导我们后续的匹配工作,而不是再一步步的移动子串呢?
比如说,我们能不能在上面匹配失败后直接跳跃到如下图的情况继续匹配?
abbaabbbabba
abbaaba
如果可以,那我们就会省去很多不必要的匹配,节省很多时间。
2.前缀后缀
上一环节我们想要通过实现直接跳跃来加速字符串的匹配,那么这个直接跳跃是否可行呢?如果可行,跳跃到哪里合适呢?
答:直接跳跃是可行的,匹配失败发生后,母串的指针 i 不动,只需将子串指针 j 移动到已匹配串的最长相同前后缀的下一位即可继续匹配。(这个结论,看完下面的例子就懂了)
这里要先说明下前缀、后缀的概念:
前缀:指的是包含首字符的子串,如 abcdef 的前缀有:a,ab,abc,abcd,abcde
后缀:指的是包含结尾字符的子串,如abcdef的后缀有:f,ef,def,cdef,bcdef注意:这里我们所说的前缀后缀不包括字符串本身。
那么最长相同前后缀也很容易理解了,就是在所有前缀后缀中相同的,并且最长的串。比如串 abbaab,他的最长相同前后缀就是ab。
为什么只需将子串指针移动到已匹配串的最长相同前后缀的下一位即可继续匹配?
在引入例子中,匹配失败时,母串的指针 i 和子串的指针 j 如下左图所示。前6个字符是已经匹配的部分,也就是字符串abbaab,我们可以求出他的最长相同前后缀是ab。这个最长相同前后缀ab说明了子串的前缀ab和后缀ab是相同的(粉色框所示),母串的前缀中ab和后缀ab也是相同的(橙色框所示)。所以最终发现,他们都相同的,都是ab这个串,如下右图,所以我们可以直接把子串后移,使子串的前缀ab和母串的后缀ab对齐(也就是子串指针移动到已匹配串的最长相同前后缀的下一位),然后继续匹配之后的字符就可以了。
所以我们总结下就是:利用子串和母串已经匹配的部分,我们可以得知子串的前缀和母串的后缀是相同的,我们下一步匹配时,只需移动子串的指针至最长相同前后缀的下一位,与母串继续匹配即可。所以我们只需要求出子串的最长相同前后缀就可以用来指导字符串匹配了。
到这里我们可能会产生一个疑问,那么怎么保证移动不会让我们错失正确答案呢?
假如在母串被跳跃过的部分中有个起点,恰好可以和子串匹配,那么我们不就错失正确答案了吗?
其实,这种情况是不会发生的,我们用的最长相同前后缀这个条件就限制了这种情况的发生。
详细证明请看:反证法证明:为什么KMP算法不会跳过(漏掉)正确的答案。
3.next数组的定义
通过第2部分前缀后缀的分析,我们现在得到了一个结论,我们可以利用子串的最长相同前后缀来指导字符串匹配,加速匹配。那么现在的问题就是求子串的最长相同前后缀的长度。因为我们不知道子串与母串的匹配过程中在哪一位会失配,所以我们得求出子串的所有位的最长相同前后缀。
我们定义一个数组next,next[i]表示前i-1的字符串的位最长相同前后缀的长度(字符串下标从0开始)。
因为next[0]比较特殊(i-1位也就是0-1是负的,没有意义了呀),所以我们特殊规定 next[0]=-1.
用一个例子再来强化一下next数组的定义。
假如子串为 a b a a b b a b a a b,则其next数组如下:
子串 a b a a b b a b a a b next -1 0 0 1 1 2 0 1 2 3 4 下标 0 1 2 3 4 5 6 7 8 9 10 把这个next数组翻译出来就是:
next[0]=-1 前面没有字符串了,也就更没有什么最长相同前后缀,为特殊情况,置为-1(后面会介绍其特殊性)。
next[1]=0 前0位的字符串(也就是a)的最长相同前后缀的长度是0(没有)。
next[2]=0 前1位的字符串(也就是ab)的最长相同前后缀的长度是0(没有)。
next[3]=1 前2位的字符串(也就是aba)的最长相同前后缀的长度是1(也就是a)。
.....
next[9]=3 前8位的字符串(也就是abaabbaba)的最长相同前后缀的长度是3(也就是aba)。
next[10]=4 前9位的字符串(也就是abaabbabaa)的最长相同前后缀的长度是4(也就是abaa)。
next数组的定义我们现在很清楚了,为什么这样定义呢?怎么用这个next数组呢?
根据定义,next存储的值是前i-1位的最长相同前后缀的值,而我们存这个值的目的是为了指导字符串匹配的操作,如果我们用求出next的子串去匹配母串,失配时我们会惊喜的发现,next[i]的值恰好也就是失配时,子串指针应该回溯的下标位置。也就是说,next[i]不仅仅表示前i-1位的字符串的最长相同前后缀的长度,也表示当子串的第 i 位子串与母串失配时,子串应该回溯的位置。
现在还不懂没关系,等看完下面的运用,就可以深刻的理解:next数组为了统筹位置和长度这两个量,所以next[i]表示的是前i-1的,而不是前i位的。
4.next数组的运用
我们先不探讨next数组的求法,假设已经求出了子串的next数组,先通过一个例子来看如何运用next数组匹配。
假设母串是 A= abaabaabbabaaabaabbabaab
子串是 B= abaabbabaab(这个子串就是上面我们已经求得next数组的串)
首先我们还是从0开始匹配:
此时,我们发现,A的第5位和B的第5位不匹配(注意下标从0开始),此时 i=5,j=5,现在next数组要出马来指导我们的下一步操作了,那么我们看next[ j ]的值:next[5]=2; 也就是说前4位已匹配的字符串的最长相同前后缀长度是2,并且我们确实也惊喜的发现,这个2也恰恰是我们子串指针 j 要回溯的位置。所以我们直接指针 i 不变, j = next[ j ] ;继续匹配就可以了,具体请看图:
然后再接着匹配:
我们又发现,A串的第13位和B串的第10位不匹配,此时i=13,j=10,那么我们看next[ j ]的值:next[10]=4 ,这个4代表的前9位的字符串最长相公前后缀的长度是4,也代表着这时候子串指针 j 需要回溯到4这个位置了。继续:
这时我们发现A串的第13位和B串的第4位依然不匹配
此时i=13,j=4,那么我们看next[ j ]的值:next[4]=1,所以我们直接从B串的第1位继续匹配:
但此时B串的第1位与A串的第13位依然不匹配
此时,i=13,j=1,所以我们看一看next[1]的值: next[1]=0,这说明已经没有相同的前后缀了,这时直接把B串向后移一位,直到发现B串的第0位与A串的第i位可以匹配(在这个例子中,i=13)
再重复上面的匹配过程,我们发现,匹配成功了!
这就是KMP算法的过程。
最后来一个完整版的动图:
至此,我们完整的应用了一遍next数组。
总结下就是:
当子串和母串匹配时:其指针i++,j++;然后看看时候找到了答案。
当子串和母串不匹配时:用next数组指导匹配,母串指针不动,j =next[ j ] 。(也就是说要利用next数组的最长相同前后缀,指针 j 没必要从头开始)。
特殊情况:当然 j =next [ j ]这个调用是有限制,当 j==0 时, 其next调用和普通位置的next调用意义不一样的:普通位置表达的是下一步j要去的位置,j==0的时候说明现在子串的第0位与母串的 i 位也不匹配了,需要子串后移。这里也更能理解next[0]=-1的设定原因。
子串的后移是通过i++实现的(相对论:母串指针i++,就相当于子串后移了),并且下一次还要再重新比较子串的第0位(所以需要 j ++)。最终我们发现特殊情况和匹配的情况,操作是一样的,可以合起来写,也就是判断语句if (j==-1 || s[i]==t[j]) 。
代码:
母串为s
子串为t
int i=0; //母串指针
int j=0; //子串指针
while(i<m){
if (j==-1 || s[i]==t[j]){ //匹配 或者 第0位也不匹配
i++;
j++;
if (j==n) return i-n; //找到答案
}else{ //不匹配
j=next[j];
}
}
return -1; //没有答案
可以发现,这个代码和暴力法的代码即为相似,不同的就在于KMP算法在不匹配时的回溯借助了next数组。
运用了next数组之后,我想你对于next的这个名字也有了更好的理解,next就是下一步,next[ i ]就表示了第 i 位失配时,指针的下一步去向是 next[ i ]。这也是为什么这个数组叫这个名字的原因,希望以后再次提到next数组时,你的第一反应不再是最开始我们定义的那个前 i-1 位的最长相同前后缀的长度,而应该是第 i 位失配时指针下一步要回溯的下标位置。
5.next数组的求解
在求解next数组之前,我们要牢记:
1.next[ i ]表示了第 i 位失配时,子串指针的下一步去向是 next[ i ]。 可以利用这个来检验自己的next求的对不对。
2.next[ i ]表示前 i-1 位的最长相同前后缀的长度。可以利用这个来帮助理解代码。
我们先把next求解代码贴上:
next[0]=-1; //约定
int i=0; //后位置的指针
int j=-1; //前位置的指针
while (i<n-1) {
if (j==-1 || t[i]==t[j]) {//匹配 或者 移动i
i++;
j++;
next[i]=j;
} else { //不匹配
j=next[j];
}
}
整体的求解思想是用子串自己匹配自己来找自己的最长相同前后缀,有两个指针,一个是靠后位置的 i 指针,一个是靠前位置的 j 指针。通过判断 t[ i ]==t[ j ] 是否成立,我们计算出了 next[ i+1 ]的值。
情况一:匹配的情况
假设我们的指针i j如图所示,那么我们现在要判断 t[ i ]==t[ j ] 是否成立,成立,那么说明next[i+1]可以继承到前面 j 之前的最长相同前后缀(也就是ABA),得出next[i+1]=3+1=4。
体现到代码就是 i++; j++; next[i]=j;
情况二:不匹配的情况
假设我们的指针i j如图所示,那么我们现在还是要判断 t[ i ]==t[ j ] 是否成立,不成立,那么说明next[i+1]不能继承前面 j 之前的最长相同前后缀,那么他的值就是0吗?不对,虽然他不能继承 j 的结果,但是可以进一步去看看 next[ j ],因为继承next[ j ]还是有机会的啊,所以不匹配时执行的是 j =next [ j ]; 对于这个例子,j=3,next[3]=1,然后再一轮的判断发现t[1]==t[8],就可以得出next[9]=2.可以继承AB这一部分.
为什么j=next[ j ]之后,当t[ i ]==t[ j ]成立时, i 还可以继承next[ j ]的最长相同前后缀?
因为 j 的增加一定是和 i 匹配才增加的,所以当遇到一个不匹配时, 说明的是以这个t[ i ]为结尾的字符和之前的最长相同前后缀的结尾字符不同(如图的B和C字符),但是这没关系,只要一直执行 j =next [ j ],一旦发现一个t[ j ]=t[ i ],就说明他俩的之前部分还是相等的。
由于这个继承关系,next数组有一个特性,就是他的增长只能是+1的,比如next[7]=2,那么next[8]最多=3,不可能更大。
特殊情况:
为什么要设置next[0]=-1?
除0外其他位置next[i]=0,仅说明下一步 j 要回到第0位去进行下一轮的判断(也就是接下来要比较t[0]和s[i])。
next[0]=-1,表示的是 j 下一位没有地方可回溯了,因为子串的第0位和母串的第 i 位也不匹配,此时只能移动指针i。
二者的意义是根本不同的,所以我们用不同的值来区分。
所以 j=next[ j ]执行是有限制的,当j==-1时,我们要移动指针i,我们初始化 j = -1 ,恰好可以使得特殊情况和情况一的匹配情况合并起来,执行同样的操作:++; j++; next[i]=j;
6.KMP算法代码
KMP算法就是对next数组的求解和next数组的运用,上面都讲了,那么代码也就容易了。
class Solution {
public:
int strStr(string haystack, string needle) {
string& s=haystack; //母串
string& t=needle; //子串
if (t=="") return 0; //特判空串
int m=s.size(); //母串长度
int n=t.size(); //子串长度
//求解next数组
vector<int> next(n,0);
next[0]=-1; //约定
int i=0; //后位置的指针
int j=-1; //前位置的指针
while (i<n-1) {
if (j==-1 || t[i]==t[j]) {//匹配 或者 移动i
i++;
j++;
next[i]=j;
} else { //不匹配
j=next[j];
}
}
//运用next数组进行匹配
i=0; //母串指针
j=0; //子串指针
while(i<m){
if (j==-1 || s[i]==t[j]){ //匹配 或者 第0位也不匹配
i++;
j++;
if (j==n) return i-n; //找到答案
}else{ //不匹配
j=next[j];
}
}
return -1; //没有答案
}
};
可以发现,next数组的求解和运用的代码及其相似,但是意义是不一样的,不能混淆。
7.next数组的优化
上边的算法还可以优化一下。
来看一个例子:
计算得出子串的next数组应该是[ -1,0,0,1 ]。在如上图所示的失配位置,失配执行 j = next [ j ] 也就是next[4]=1,所以指导我们下一步要比较的是第1位,如下图所示:
不难发现,这一步是完全没有意义的。因为后面的B已经不匹配了,那前面的B也一定是不匹配的,同样的情况其实还发生在第2个元素A上。
显然,发生问题的原因在于P[ j ] == P[next[ j ]]。
所以优化的方法也很简单,我们在进行next数组求解时,多添加一个判断条件即可: if (t[i]==t[j]) next[i]=next[j]; else next[i]=j;
这样求出来的next数组就可以直接跳过那些相同的值,而运用next匹配的部分代码无需修改。
对于上述字符串 ABAB 其优化后的next数组为 [ -1 , 0 , -1 , 0 ]。next[4]不再等于1,也就无需比较相同的部分了。
优化后的代码:
class Solution {
public:
int strStr(string haystack, string needle) {
string& s=haystack; //母串
string& t=needle; //子串
if (t=="") return 0; //特判空串
int m=s.size(); //母串长度
int n=t.size(); //子串长度
//求解next数组
vector<int> next(n,0);
next[0]=-1; //约定
int i=0; //后位置的指针
int j=-1; //前位置的指针
while (i<n-1) {
if (j==-1 || t[i]==t[j]) {//匹配 或者 移动i
i++;
j++;
if (t[i]==t[j]) next[i]=next[j]; //优化
else next[i]=j;
} else { //不匹配
j=next[j];
}
}
//运用next数组进行匹配
i=0; //母串指针
j=0; //子串指针
while(i<m){
if (j==-1 || s[i]==t[j]){ //匹配 或者 第0位也不匹配
i++;
j++;
if (j==n) return i-n; //找到答案
}else{ //不匹配
j=next[j];
}
}
return -1; //没有答案
}
};
8.KMP算法时间复杂度:O(m+n)
假设在母串的长度为m,子串的长度为n,一般认为时间复杂度是O(m+n),也就是计算next数组的时间复杂度是O(n),而匹配的时候是O(m)。
母串的指针 i 是一直递增的,这个相对好理解,但是你会发现子串的指针 j 也会回溯,j 的回溯会不会影响复杂度,使复杂度更高呢?
回答:j 的回溯只会影响搜索主循环次数的上下界([m, 2m]),但是其还是线性时间复杂度。母串的每个字符平均下来最多比较两次。
9. 补充
如果要求子串在母串中出现的所有位置,在利用next数组时,一旦找到匹配(也就是 j == n ),输出即可,此时的指针不需要再修改。
因为string字符串默认有终止符 '\0' ,所以下一次的比较一定不相同,j 会自动回溯的。