优质博文:IT-BLOG-CN
一、题目
给你两个字符串haystack
和needle
,请你在haystack
字符串中找出needle
字符串的第一个匹配项的下标(下标从0
开始)。如果needle
不是haystack
的一部分,则返回-1
。
示例 1:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:sad
在下标0
和6
处匹配。
第一个匹配项的下标是0
,所以返回0
。
示例 2:
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:leeto
没有在leetcode
中出现,所以返回-1
。
1 <= haystack.length, needle.length <= 104
haystack
和needle
仅由小写英文字符组成
二、代码
暴力解法: 我们可以让字符串needle
与字符串haystack
的所有长度的子串均匹配一次。为了减少不必要的匹配,我们每次匹配失败即立刻停止当前子串的匹配,对下一个子串继续匹配。如果当前子串匹配成功,我们返回当前子串的开始位置即可。如果所有子串都匹配失败,则返回−1
class Solution {public int strStr(String haystack, String needle) {if (haystack.length() < needle.length()) {return -1;}// 注意:这里是剩余长度不能小于needle的长度for (int i = 0; i + needle.length() <= haystack.length(); i++) {boolean flag = false;for (int j = 0; j < needle.length(); j++) {if (haystack.charAt(i + j) != needle.charAt(j)) {flag = false;break;}flag = true;}if (flag) {return i;}}return -1;}
}
时间复杂度: O(n×m)
,其中n
是字符串haystack
的长度,m
是字符串needle
的长度。最坏情况下我们需要将字符串needle
与字符串haystack
的所有长度为m
的子串均匹配一次。
空间复杂度: O(1)
。我们只需要常数的空间保存若干变量。
Knuth-Morris-Pratt 算法: Knuth-Morris-Pratt
算法,简称KMP
算法,由Donald Knuth
、James H. Morris
和Vaughan Pratt
三人于1977
年联合发表。Knuth-Morris-Pratt
算法的核心为前缀函数,记作π(i)
,其定义如下:对于长度为m
的字符串s
,其前缀函数π(i)(0≤i<m)
表示s
的子串s[0:i]
的最长的相等的真前缀与真后缀的长度。特别地,如果不存在符合条件的前后缀,那么π(i)=0
。其中真前缀与真后缀的定义为不等于自身的的前缀与后缀。
我们举个例子说明:字符串aabaaab
的前缀函数值依次为0,1,0,1,2,2,3
。
【1】π(0)=0
,因为a
没有真前缀和真后缀,根据规定为0
(可以发现对于任意字符串π(0)=0
必定成立);
【2】π(1)=1
,因为aa
最长的一对相等的真前后缀为a
,长度为1
;
【3】π(2)=0
,因为aab
没有对应真前缀和真后缀,根据规定为0
;
【4】π(3)=1
,因为aaba
最长的一对相等的真前后缀为a
,长度为1
;
【5】π(4)=2
,因为aabaa
最长的一对相等的真前后缀为aa
,长度为2
;
【6】π(5)=2
,因为aabaaa
最长的一对相等的真前后缀为aa
,长度为2
;
【7】π(6)=3
,因为aabaaab
最长的一对相等的真前后缀为aab
,长度为3
。
有了前缀函数,我们就可以快速地计算出模式串在主串中的每一次出现。
如何求解前缀函数
长度为m
的字符串s
的所有前缀函数的求解算法的总时间复杂度是严格O(m)
的,且该求解算法是增量算法,即我们可以一边读入字符串,一边求解当前读入位的前缀函数。
为了叙述方便,我们接下来将说明几个前缀函数的性质:
【1】π(i)≤π(i−1)+1
:依据π(i)
定义得:s[0:π(i)−1]=s[i−π(i)+1:i]
。将两区间的右端点同时左移,可得:s[0:π(i)−2]=s[i−π(i)+1:i−1]
。依据π(i−1)
定义得:π(i−1)≥π(i)−1
,即π(i)≤π(i−1)+1
。
【2】如果s[i]=s[π(i−1)]
,那么π(i)=π(i−1)+1
。依据π(i−1)
定义得:s[0:π(i−1)−1]=s[i−π(i−1):i−1]
。因为s[π(i−1)]=s[i]
,可得s[0:π(i−1)]=s[i−π(i−1):i]
。依据π(i)
定义得:π(i)≥π(i−1)+1
,结合第一个性质可得π(i)=π(i−1)+1
。
这样我们可以依据这两个性质提出求解π(i)
的方案:找到最大的j
,满足 s[0:j−1]=s[i−j:i−1]
,且s[i]=s[j]
(这样就有s[0:j]=s[i−j:i]
,即π(i)=j+1
。
注意这里提出了两个要求:
【1】j
要求尽可能大,且满足s[0:j−1]=s[i−j:i−1]
;
【2】j
要求满足s[i]=s[j]
。
由π(i−1)
定义可知:s[0:π(i−1)−1]=s[i−π(i−1):i−1](1)
那么j=π(i−1)
符合第一个要求。如果s[i]=s[π(i−1)]
,我们就可以确定π(i)
。否则如果s[i]≠s[π(i−1)]
,那么π(i)≤π(i−1)
,因为j=π(i)−1
,所以j < π(i−1)
,于是可以取(1)
式两子串的长度为j
的后缀,它们依然是相等的:s[π(i−1)−j:π(i−1)−1]=s[i−j:i−1]
。
当 s[i]≠s[π(i−1)]s[i]\neq s[\pi(i-1)]s[i]=s[π(i−1)] 时,我们可以修改我们的方案为:找到最大的 jjj,满足 s[0:j−1]=s[π(i−1)−j:π(i−1)−1]s[0:j-1]=s[\pi(i-1)-j:\pi(i-1)-1]s[0:j−1]=s[π(i−1)−j:π(i−1)−1],且 s[i]=s[π(i−1)]s[i]=s[\pi(i-1)]s[i]=s[π(i−1)](这样就有 s[0:j]=s[π(i−1)−j:π(i−1)]s[0:j]=s[\pi(i-1)-j:\pi(i-1)]s[0:j]=s[π(i−1)−j:π(i−1)],即 π(i)=π(i−1)+1\pi(i)=\pi(i-1)+1π(i)=π(i−1)+1)。
注意这里提出了两个要求:
【1】j
要求尽可能大,且满足s[0:j−1]=s[π(i−1)−j:π(i−1)−1]
;
【2】j
要求满足s[i]=s[j]
。
由π(π(i−1)−1)
定义可知j=π(π(i−1)−1)
符合第一个要求。如果s[i]=s[π(π(i−1)−1)]
,我们就可以确定π(i)
。此时,我们可以发现j
的取值总是被描述为π(π(π(…)−1)−1)
的结构(初始为π(i−1)
)。于是我们可以描述我们的算法:设定π(i)=j+1
,j
的初始值为π(i−1)
。我们只需要不断迭代j
(令j
变为π(j−1)
)直到s[i]=s[j]
或j=0
即可,如果最终匹配成功(找到了j
使得s[i]=s[j]
),那么π(i)=j+1
,否则π(i)=0
。
复杂度证明
时间复杂度部分,注意到π(i)≤π(i−1)+1
,即每次当前位的前缀函数至多比前一位增加一,每当我们迭代一次,当前位的前缀函数的最大值都会减少。可以发现前缀函数的总减少次数不会超过总增加次数,而总增加次数不会超过m
次,因此总减少次数也不会超过m
次,即总迭代次数不会超过m
次。
空间复杂度部分,我们只用到了长度为m
的数组保存前缀函数,以及使用了常数的空间保存了若干变量。
如何解决本题
记字符串haystack
的长度为n
,字符串needle
的长度为m
。我们记字符串str=needle+#+haystack
,即将字符串needle
和haystack
进行拼接,并用不存在于两串中的特殊字符#
将两串隔开,然后我们对字符串str
求前缀函数。因为特殊字符#
的存在,字符串str
中haystack
部分的前缀函数所对应的真前缀必定落在字符串needle
部分,真后缀必定落在字符串haystack
部分。当haystack
部分的前缀函数值为m
时,我们就找到了一次字符串needle
在字符串haystack
中的出现(因为此时真前缀恰为字符串needle
)。
实现时,我们可以进行一定的优化,包括:
1、我们无需显式地创建字符串str
。为了节约空间,我们只需要顺次遍历字符串needle
、特殊字符#
和字符串haystack
即可。
2、也无需显式地保存所有前缀函数的结果,而只需要保存字符串needle
部分的前缀函数即可。特殊字符#
的前缀函数必定为0
,且易知π(i)≤m
(真前缀不可能包含特殊字符#
)。这样我们计算π(i)
时,j=π(π(π(…)−1)−1)
的所有的取值中仅有π(i−1)
的下标可能大于等于m
。我们只需要保存前一个位置的前缀函数,其它的j
的取值将全部为字符串needle
部分的前缀函数。
3、我们也无需特别处理特殊字符#
,只需要注意处理字符串haystack
的第一个位置对应的前缀函数时,直接设定j
的初值为0
即可。
这样我们可以将代码实现分为两部分:
1、第一部分是求needle
部分的前缀函数,我们需要保留这部分的前缀函数值。
2、第二部分是求haystack
部分的前缀函数,我们无需保留这部分的前缀函数值,只需要用一个变量记录上一个位置的前缀函数值即可。当某个位置的前缀函数值等于m
时,说明我们就找到了一次字符串needle
在字符串haystack
中的出现(因为此时真前缀恰为字符串needle
,真后缀为以当前位置为结束位置的字符串haystack
的子串),我们计算出起始位置,将其返回即可。
class Solution {public int strStr(String haystack, String needle) {int n = haystack.length(), m = needle.length();if (m == 0) {return 0;}int[] pi = new int[m];for (int i = 1, j = 0; i < m; i++) {while (j > 0 && needle.charAt(i) != needle.charAt(j)) {j = pi[j - 1];}if (needle.charAt(i) == needle.charAt(j)) {j++;}pi[i] = j;}for (int i = 0, j = 0; i < n; i++) {while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {j = pi[j - 1];}if (haystack.charAt(i) == needle.charAt(j)) {j++;}if (j == m) {return i - m + 1;}}return -1;}
}
时间复杂度: O(n+m)
,其中n
是字符串haystack
的长度,m
是字符串needle
的长度。我们至多需要遍历两字符串一次。
空间复杂度: O(m)
,其中m
是字符串needle
的长度。我们只需要保存字符串needle
的前缀函数。