力扣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个过程中的ij**的含义是不同的,如果你搞混了请重新阅读,尤其是代码中的注释

补充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的情况同理

posted @ 2024-10-25 10:36  HB_Computer  阅读(19)  评论(0)    收藏  举报