力扣28. 找出字符串中第一个匹配项的下标
题目来源(力扣):
https://leetcode.cn/problems/find-the-index-of-the-first-occurrence-in-a-string/description/
题目描述:
给你两个字符串s1和s2,要求在s1中找出s2第一次出现的位置。
如果找不到,则返回-1
基本思路:
题目的数据规模不大(10^4),因此可以使用朴素做法得到答案
先说朴素的做法,2层循环,直接从头到尾在s1中枚举起点,依次与s2比对,找到答案就直接退出
代码如下
朴素做法
class Solution {
public:
int strStr(string s1, string s2) {
int ans = -1;
for (int i = 0; i < s1.size(); i++)
{
bool ok = 1; // 判断是否是s2的前缀
for (int j = 0; j < s2.size(); j++)
{
if (s1[i + j] != s2[j])
{
ok = 0;
break;
}
}
if(ok){//找到目标位置
ans = i;
break;
}
}
return ans;
}
};
时间复杂度
时间复杂度为O(n*m),n、m分别为串s1、s2的长度,也常常记为O(n^2)
进阶-KMP算法 (以下内容较多,耐心看完应该有所收获)
作为一个算法学习者,无论如何都要熟悉并且能够独立写出KMP算法
1. KMP算法的引入
注意到朴素算法中,我们在不断地枚举s1的起点i,每次i位置匹配失败后,下一次匹配的位置从i+1开始
并且不断地从头到尾重新比对s2
实际上,对于s1 = "aabaabaafa",s2="aabaaf"
第一次匹配,起点i=0:
0 1 2 3 4 5 6 7 8 9
a a b a a b a a f a
a a b a a f
显然位置5匹配不成功
我们发现s2已经完成匹配的部分(通过肉眼观察可知)
0 1 2 3 4
a a b a a
它的前缀aa(01)和后缀aa(34)是对应相同的
而s2已经匹配的字符个数为j=2,接下来只需要继续匹配s2[2-5],而无需从s2[0]开始匹配s2[0-5],如下:
0 1 2 3 4 5 6 7 8 9
a a b a a b a a f a
a a(b a a f)
这就是KMP算法的思想:
当某次匹配失败后,s1中的指针i保持不变,而是根据“已有的匹配信息”和“串s2本身的特点”,将s2中的指针j的位置进行修改,继续进行匹配,直到完成整个s2的匹配
刚才的例子中,这里的 已有的匹配信息 和 串s2本身的特点 就是:
1、已经完成了aabaa的匹配
1、s2中,aabaa的最长公共前后缀的长度为2
(注意为2,而不是3。前缀、后缀都是“从前往后”看的,长度为3的话,前缀aab不等于后缀baa)
2. 前缀表——实现KMP算法的核心
算法的核心问题就是,匹配失败时,如何调整s2中j的位置
只要解决这个问题,KMP算法基本上就完成了
由此,需要引入前缀表,
对于s2而言,它记录的是s2中最长公共前后缀的长度 或者叫做s2中最长相等前后缀的长度
对于s2在s1中的匹配过程而言,它可以快捷的告诉我们j应该调整到什么位置
即前缀表一次性解决了2个问题(或者说解决了KMP的整个核心问题)
前缀表:
以s2=aabaaf为例,它的前缀依次为
a
aa
aab
aaba
aabaa
aabaaf
如果s2长度为m,则它有m-1个子串构成前缀
这m-1个串的最长相等前后缀及其长度分别为
前缀串 | 最长相等前后缀 | 最长相等前后缀的长度 |
---|---|---|
a | null | 0 |
aa | a | 1 |
aab | null | 0 |
aaba | a | 1 |
aabaa | aa | 2 |
aabaaf | null | 0 |
(最长相等前后缀不包括串本身,因此"a"的最长相等前后缀不是"a"而是不存在、"aa"的最长相等前后缀是"a"而不是"aa")
一般在kmp算法中 ,将最长相等前后缀的长度这个数组[0,1,0,1,2,0]提取出来,称作next数组,也就是这里所谓的前缀表
而kmp算法就是通过next数组来解决问题的
当s2在s1中进行匹配时,例如s1=aabaabaaf,s2=aabaaf
当完成了aabaa的匹配,而f不匹配b时,此时j=5,
由next[]数组可知知aabaa最长相等前后缀为2,即next[4]=2
因此更新j=next[j-1],
即j=next[5-1]=2
0 1 2 3 4 5
a a b a a f
从刚才的j=5修订为j=2处,再继续进行匹配
而s1中的i=5,保持不变
0 1 2 3 4 5 6 7 8
a a b a a b a a f
比对s1[i=5] 与 s2[j=2],如果相等,则s1、s2继续向后匹配
如果不相等,则继续更新j=next[j-1],直到j为0
即,得到了next[]数组,kmp算法就迎刃而解了
由刚才的例子分析可知,整个算法分为2步:
1、根据s2自己的特点,得到next[]数组
2、在s1中匹配s2,如果遇到不匹配的情况,利用刚才得到的next[]数组修改s2中的匹配位置j,继续进行匹配;如果s1[i]与s2[j]相等,则j正常向后移动
3、重复步骤2,直达成功匹配或者遍历完s1数组
在代码层面,由于不同的人习惯的next[]方式不同,我这里给出的是符合刚才的描述的代码,即当s2中位置j不匹配时,根据next[j-1]修改j的情况
(可以改写为s2中位置j不匹配时,查看next[j]的情况,只是代码有细微差别)
我的代码如下
KMP算法代码实现
为了便于观看,将代码拆分为next数组求解以及字符串匹配过程2个部分
1、next数组求法
首先,给出有点冗余,但是好理解的写法,具体看注释
int next[10003]={0};
void getnext(string s)
{
int j = 0; // j为s2前缀的末尾
for (int i = 1; i < s.size(); i++) // i为s2后缀的末尾
{
if (s[i] == s[j]) // 如果前缀末尾和后缀末尾相等
{
next[i] = j + 1; // 更新next[i]的值
j++; // 前缀末尾后移
}
else
{ // 如果前缀末尾和后缀末尾不相等
while (j > 0 && s[i] != s[j]) // 注意 1. j>0 2. 这里要使用while循环,进行不断地回退,直到s2[i]==s2[j] 或者回退到j=0起始位置了
j = next[j - 1];
if (s[i] == s[j]) // 回退完毕之后,如果相等,更新next[i]的值,并更新前缀末尾
{
next[i] = j + 1;
j++;
}
else next[i] = 0; // 回退完毕之后,如果不相等,说明最长公共前后缀长度为0 //可以不写,因为默认值就是0,但是写出来更加清晰
}
}
}
搞懂以上代码以后,可以进行一定程度的写法优化,如下
void getnext(string s)
{
int j = 0; // j为s2前缀的末尾
for (int i = 1; i < s.size(); i++) // i为s2后缀的末尾
{
while (j > 0 && s[i] != s[j]) // 不相等则调整j的位置
j = next[j - 1];
if (s[i] == s[j])
{ // 如果(本身就相等或者调整过位置后)相等
next[i] = j + 1;
j++;
}
//else next[i] = 0;
}
}
代码量骤然减少,原因在于:
先判断了是否s2[i]!=s2[j],
如果不相等,则执行操作1、调整j的位置,直到s2[i]==s2[j]或者j为0
再执行操作2、如果s2[i]==2[j],修改next[i]=j+1且j向后移动;(如果不相等则next[i]=0,可以不写,因为默认值就是0)
到此,next数组就求完了
看似简洁的代码背后隐藏着严密的逻辑思维和深刻的理论依据
2、字符串匹配
同理,先给出冗余但是好理解的代码
int kmp(string s1, string s2)
{
int j = 0; // j为s2中的匹配位置
int ans = -1;
for (int i = 0; i < s1.size(); i++) //i为s1中的指针
{
if (s1[i] == s2[j]) // 如果s1[i]和s2[j]相等
{
j++; // s2中的匹配位置后移
if (j == s2.size()) // 说明整个s2完成
{
ans = i - j + 1; // 得出答案 i-j+1 (即s1中s2的起始位置)
break; // 提前退出循环
}
}
else // 如果s1[i]和s2[j]不相等
{
while (j > 0 && s1[i] != s2[j]) // 调整s2的匹配位置 (注意s1中的i从来没有回退过,这和朴素算法中完全不同)
j = next[j - 1]; // 还是注意 1、j>0 2、.这里要使用while循环,不断地回退,直到s1[i]==s2[j],或者j==0
if (s1[i] == s2[j]) // s2中的指针j进行调整后,如果s1[i]和s2[j]相等 ,以下代码和刚才一样
{
j++;
if (j == s2.size())
{
ans = i - j + 1;
break;
}
}
// else j=0;//如果回退到j=0了,仍然不相等,说明s1[i]和s2[0]也不相等,所以s2的匹配位置要置为0
}
}
return ans;
}
搞懂以上代码以后,可以进行一定程度的写法优化,如下
int kmp(string s1, string s2)
{
int j = 0; // j为s2中的匹配位置
int ans = -1;
for (int i = 0; i < s1.size(); i++) //i为s1中的指针
{
while (j > 0 && s1[i] != s2[j]) // 不相等,先调整j的位置
j = next[j - 1];
if (s1[i] == s2[j]) //如果(本身就相等或者调整位置后)相等
{
j++;
if (j == s2.size())
{
ans = i - j + 1;
break;
}
}
}
return ans;
}
优化思路和求解next数组时高度相似,不在过多描述
由此,得到完整的KMP算法
这里给出最简洁的版本,并且去除几乎所有注释,
如果读者能够完全看懂并且能够自己写出,那么kmp算法就算是大成了~
KMP完整代码
class Solution {
public:
int next[10003];
//求解next数组
void getnext(string s){
int j=0;
for(int i=1;i<s.size();i++){
while(j>0&&s[i]!=s[j])
j=next[j-1];
if(s[i]==s[j]){
next[i]=j+1;
j++;
}
}
}
//字符串匹配
int kmp(string s1,string s2){
int j=0;
int ans=-1;
for(int i=0;i<s1.size();i++){
while(j>0&&s1[i]!=s2[j])
j=next[j-1];
if(s1[i]==s2[j]){
j++;
if(j==s2.size()){
ans=i-j+1;
break;
}
}
}
return ans;
}
int strStr(string haystack, string needle) {
getnext(needle);
return kmp(haystack,needle);
}
};
时间复杂度
kmp算法的时间复杂度为O(n+m),其中m为s2的长度(也叫匹配串的长度),n为s1的长度(也叫模式串的长度)
生成next数组的时间复杂度为O(m)
匹配过程时间复杂度为O(n)
因此总时间复杂度为O(n+m)
补充1.
朴素算法,匹配失败时,i固定回退到i+1,j固定回退到0
KMP算法,匹配失败时,i不发生回退,j回退到特定位置
因此KMP算法遥遥领先~
补充2.
请注意,1、next数组求解与*2、字符串匹配这2个过程中的i和j**的含义是不同的,如果你搞混了请重新阅读,尤其是代码中的注释
补充3.(重要)
可能你会疑惑在1、next数组求解过程中,关于j的调整为何使用while而不是if
while (j > 0 && s1[i] != s2[j]) // 不相等,先调整j的位置
j = next[j - 1];
这个可以结合例子来说明:
假设s2="aahaahaab",完整下标、字符串、next数组如下,
0 1 2 3 4 5 6 7 8
a a h a a h a a b
0 1 0 1 2 3 4 5 0
以i=8时求解next[8]为例.
对于i=8时,已知的next[0-7]数组应该为
0 1 2 3 4 5 6 7 8
0 1 0 1 2 3 4 5
此时,
i=8,为后缀末尾;
j=5,为前缀末尾;
s2[i]=s2[8]=b
s2[j]=s2[5]=h
s2[i]!=s2[j]
因此第一次调整j
j=next[j-1]=next[4]=2
调整j后,j=2
而此时
s2[i]=s2[8]=b
s2[j]=s2[2]=h
s2[i]!=s2[j]
因此第二次调整j
j=next[j-1]=next[1]=1
调整j后,j=1
而此时
s2[i]=s2[8]=b
s2[j]=s2[1]=a
s2[i]!=s2[j]
因此第三次调整j
j=next[j-1]=next[0]=0
调整j后,j=0
而此时
s2[i]=s2[8]=b
s2[j]=s2[0]=a
s2[i]!=s2[j]
而j=0,不再进行调整,退出while(j > 0 && s1[i] != s2[j])
由此可见,j的调整是连续的过程,直到遇见s2[i]==s2[j]或者j为0才停止
例子中就进行了3次连续的调整(while),而不是1次调整(if)
因此使用while而不是if
在2、字符串匹配过程中,关于j的调整使用while而不是if的情况同理