本文是对LeetCode上官方对KMP算法的解释的个人理解与消化,对应链接:
https://leetcode-cn.com/problems/implement-strstr/
.前言
我愿称LeetCode.0028为算法题里的扫地憎
题目描述:
实现 strStr() 函数。
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回 -1 。
当 needle 是空字符串时,我们应当返回什么值呢?这是一个在面试中很好的问题。
对于本题而言,当 needle 是空字符串时我们应当返回 0 。这与 C 语言的 strstr() 以及 Java 的 indexOf() 定义相符。
示例:
示例 1:
输入:haystack = "hello", needle = "ll"
输出:2
示例 2:
输入:haystack = "aaaaa", needle = "bba"
输出:-1
示例 3:
输入:haystack = "", needle = ""
输出:0
常见的字符串匹配算法包括暴力匹配、Knuth-Morris-Pratt 算法、Boyer-Moore 算法、Sunday 算法等,本文将讲解Knuth-Morris-Pratt 算法,其他以后找机会补充。
.KMP算法(Knuth-Morris-Pratt)
一、算法核心:前缀函数π(i)
在长度为m的字符串中,设有前缀函数π(i),i满足:0≤i<m表示子字符串S[0 :i]的最长的相等的真前缀与真后缀的长度。特别地,如果不存在符合条件的前后缀,那么 π(i)=0。其中真前缀与真后缀的定义为不等于自身的的前缀与后缀。
举例:
对于字符串 aabaaab,其对应的前缀函数值为:0,1,0,1,2,2,3。
解释:
π(0)=0,因为 a 没有真前缀和真后缀,根据规定为 0(可以发现对于任意字符串 \pi(0)=0π(0)=0 必定成立);
π(1)=1,因为 aa 最长的一对相等的真前后缀为 a,长度为 1;
π(2)=0,因为 aab 没有对应真前缀和真后缀,根据规定为 0;
π(3)=1,因为 aaba 最长的一对相等的真前后缀为 a,长度为 1;
π(4)=2,因为 aabaa 最长的一对相等的真前后缀为 aa,长度为 2;
π(5)=2,因为 aabaaa 最长的一对相等的真前后缀为 aa,长度为 2;
π(6)=3,因为 aabaaab 最长的一对相等的真前后缀为 aab,长度为 3。
有了前缀函数,我们就可以快速地计算出模式串在主串中的每一次出现。
二、求解前缀函数
长度为 m 的字符串 s 的所有前缀函数的求解算法的总时间复杂度是严格 O(m) 的,且该求解算法是增量算法,即我们可以一边读入字符串,一边求解当前读入位的前缀函数。
名词表:
为了方便解读,个人先列出几个后面内容比较常用的短式:
S[0 : π(i)-1] 子字符串S[0 : i]的最大长度前缀
S[i-π(i)+1 : i] 子字符串S[0 : i]的最大长度后缀
S[π(i)-1] 子字符串S[0 : i]的最大长度前缀的最后一个字符
S[π(i)] 子字符串S[0 : i]的最大长度前缀的最后一个字符的再后一位字符
关于前缀函数的几个性质:
性质(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
当且仅当S[π(i−1)]即S[π(i)-1]等于S[i]可取等号。
反证法:
对于字符串S[0 : i],设:
原最大长度前缀的右端左移一位S[0 : π(i)-2]
为S1;
原最大长度后缀的右端左移一位S[i-π(i)+1 : i-1]
为S2;
原最大长度前缀的最后一个字符S[π(i)-1]
为ch1;
字符串最后一个字符S[I]为ch2;
假设:
若ch1 != ch2,且π(i)≥π(i−1)+1,则说明:
对于字符ch1,有后继字符串S3满足:S3 = S2,且S3有后继字符ch3 = ch2;
对于字符串S2,有前序字符ch4满足:ch4 = ch1,前ch4有前序字符串S4满足S4 = S1;
这样,得:
S1 + ch1 + S3 + ch3 =
S4 + ch4 + S2 + ch2;
得:
S1 + ch1 + S3 =
S4 + ch4 + S2;
根据π(i)定义,前缀不得取字符串本身,显然,S1 + ch1 + S3的长度要大于S1,与π(i)的定义冲突,
假设不成立,π(i)≤π(i−1)+1正确。
性质(2):如果 s[i]=s[π(i−1)],那么π(i)=π(i−1)+1
根据前缀函数定义,有:
S[0 : π(i-1)-1] = S[i-π(i-1)+1 : i-1]
因为S[π(i-1)] = S[i],则有:
S[0 : π(i-1)] = S[i-π(i-1) : i]
根据性质1,:π(i)≤π(i−1)+1,得当且仅当S[π(i−1)]即S[π(i)-1]等于S[i]可取等号,则:
:π(i)=π(i−1)+1
根据两性质,求解前缀函数π(i)可转换为:设字符串中有字符S[j],找到最大的j,且满足:
1、在字符串S[0 : i-1]中,s[0 : j−1] = s[i−j : i−1]
;
2、S[j] = S[i];
显然,这里指的是找出S[0 : i-1]的最大前缀π(i−1),且希望前缀与后缀新加入的字符相等S[j] = S[π(i−1)]= S[i],j = π(i−1)。
当S[j] = S[i],根据性质2,则可得知π(i)=π(i−1)+1。
重点:当S[j] != S[i]时。
当S[j] != S[i]时,此时我们需要改变策略,希望在字符串S[0 : i-1]中的前缀S[0 : π(i−1)-1]中找到尽可能大的字符S[j_2],使其满足:
1、在字符串S[0 : π(i−1)-1]中,S[0 : j_2-1] = S[π(i−1)-j_2 :π(i−1)-1]
,即希望字符串S[0 : i-1]中的前缀也有类似的结构使前缀的前后部分相同;
2、S[j_2]=S[i],即希望满足条件1的前缀的后继字符等同于整个字符串后面加的字符S[I];
解惑:为什么要在字符S[0 : i-1]的前缀找出类似结构的子前缀?
解答前先看看暴力匹配的方法:
我们可以让字符串 needle 与字符串haystack 的所有长度为 m 的子串均匹配一次。
为了减少不必要的匹配,我们每次匹配失败即立刻停止当前子串的匹配,对下一个子串继续匹配。
如果当前子串匹配成功,我们返回当前子串的开始位置即可。如果所有子串都匹配失败,则返回 -1。
这个方法的结果是一旦匹配字符串中的某个字符不相等时,整个匹配字符串都需要重新匹配,其结果是造成时间复杂度为O(m*n)
而在前缀函数中,若已经有:
S[0 : π(i−1)-1] = S[i-π(i-1)+1 : i-1]
,且S[0 : j_2-1] = S[π(i−1)-j_2 :π(i−1)-1]
,
那么有:S[0 : j_2-1] = S[i-j_2+1 : i-1]
即最大前缀的子前缀依旧满足前后缀相同的要求。
这样,及时S[j] !=s[i],我们也不需要从新匹配,而是找到当前最近的子前缀,并重新判断剩余部分即可。
其中,满足S[0 : j_2-1] = S[i-j_2+1 : i-1]
的j_2可规约为j = π(π(i−1)-1)
不难发现这是一个迭代的过程,只需要不断迭代 j_x(令 j_x 变为π(j_x−1))直到 s[i]=s[j_x] 或 j_x=0即可,如果最终匹配成功(找到了 j_x 使得 s[i]=s[j_x]),那么 π(i)=j_x+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 即可。
这样我们可以将代码实现分为两部分:
第一部分是求 needle 部分的前缀函数,我们需要保留这部分的前缀函数值。
第二部分是求 haystack 部分的前缀函数,我们无需保留这部分的前缀函数值,只需要用一个变量记录上一个位置的前缀函数值即可。当某个位置的前缀函数值等于 m 时,说明我们就找到了一次字符串 needle 在字符串 haystack 中的出现(因为此时真前缀恰为字符串 needle,真后缀为以当前位置为结束位置的字符串 haystack 的子串),我们计算出起始位置,将其返回即可。
代码:
int strStr(string haystack, string needle)
{
int n = haystack.size(), m = needle.size();
if(m==0)
{
return 0;
}
//匹配字符串的前缀函数
vector<int> pi(m);
//理所当然的要一个一个字符读入
//首字符的前缀函数值为0
for(int i=1,j=0;i<m;i++)
{
//当当前字符不匹配则进行迭代
//在前缀找子前缀
while (j>0&&needle[i]!=needle[j])
{
j = pi[j - 1];
}
//若当前读取的前缀的后继字符匹配字符串当前最后的字符
//则pi(i)的值等于pi(i-1)+1
if(needle[i]==needle[j])
{
j++;
}
pi[i] = j;
}
//与需匹配的字符串匹配
for(int i=0,j=0;i<n;i++)
{
//当当前haystack字符不匹配
//返回至最近可匹配子前缀
while (j>0&&needle[j]!=haystack[i])
{
j = pi[j - 1];
}
if(needle[j]==haystack[i])
{
j++;
}
//当前缀长度对于匹配字符串长度时表示匹配已完成,已经找到拼接字符串str=needle+#+haystack的前后缀
//可返回该后缀的开始字符位置
if(j==m)
{
return i - m + 1;
}
}
//如果轮询完整个字符串依旧没有长度为m的后缀则表明没有匹配子字符串
return -1;
}