实现strStr() —— KMP算法(包含next数组的优化)

目录

KMP算法

KMP算法的应用

前缀表

最长公共前后缀

为什么要使用前缀表

如何计算前缀表

前缀表和next数组

时间复杂度分析

例题28.实现strStr

构造next数组 

使用next数组来做匹配 

前缀表统一减一 C++代码实现

前缀表(不减一)C++代码实现

总结 

拓展:next数组的优化 


KMP算法

 文档讲解:手撕KMP算法

KMP算法是由三维学者发明Knuth,Morris和Pratt,取三位学者名字的首字母,所以叫做KMP算法。

KMP算法的应用

KMP算法主要应用在字符串匹配上。KMP算法的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去匹配。


前缀表

又称为next数组,是KMP算法中最为关键的部分。

前缀表式用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

例如要在文本串 aabaabaafa 中查找是否出现过一个模式串 aabaaf 时:当匹配到第六个字符时,会发现文本串的b和模式串的f不一样。此时如果是暴力匹配,发现不匹配,就要从头开始匹配了;但如果使用前缀表,就不会开始匹配,而是从模式串中的第三个字符b开始继续匹配,因为我们知道前面有两个aa已经匹配了。

由此引出一个问题,前缀表是如何记录的?首先前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,这也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。

由此总结前缀表是:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。


最长公共前后缀

我们首先需要明确,前缀和后缀的概念:

  • 前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串
  • 后缀:指不包含第一个字符的所以以最后一个字符结尾的连续子串

接着前缀表要求的就是相同前后缀的长度,也即一个字符串前缀和后缀能够相同的最大长度。例如:字符串a的最长相等前后缀为0,字符串aa的最长相等前后缀为1,字符串aaa的最长相等前后缀为2,等等。


为什么要使用前缀表

前缀表式如何告诉我们上次匹配的位置,并跳过去的,回顾aabaabaafa与aabaaf匹配的例子。在匹配的过程中在下标5的地方遇到不匹配,模式串指向f

然后跳到了下标2,指向b的位置,继续匹配,这里也能够看出最长相等前后缀的应用:

这是因为:下标5之前这部分的字符串(aabaa)的最长相等的前缀和后缀字符串是“aa”,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。

所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。


如何计算前缀表

显然我们需要通过最长相等的前缀和后缀,来计算前缀表:

如图:

长度为前1个字符的子串a,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

紧接着,长度为前2个字符的子串“aa”,最长相等前后缀的长度为1。

长度为前3个字符的子串aab,最长相等前后缀的长度为0。

以此类推:长度为前4个字符的子串aaba,最长相等前后缀的长度为1。长度为前5个字符的子串aabaa,最长相等前后缀的长度为2。长度为前6个字符的子串aabaaf,最长相同前后缀的长度为0。

那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:

可以看出模式串与前缀表对应位置的数组表示的就是:下标i之前(包括i)的字符串中,有多大长度的相等前缀后缀。

那么当找到不匹配的位置时,我们要看它的前一个字符的前缀表的数值是多少。

为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。所以要看前一位的前缀表的数值。前一个字符的前缀表的数值是2,所以把下标移动到下标2的位置继续匹配。


前缀表和next数组

很多KMP算法的实现都是使用next数组来做回退操作的,那么next数组与前缀表有什么关系呢?实际上next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后欧威next数组的。这主要取决于代码的实现方式,和KMP的原理无关。


时间复杂度分析

其中n是文本串长度,m是模式串长度, 因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),不存在回头路。同时还要单独生成next数组,next数组只与模式串有关,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)。

而暴力解法的时间复杂度是O(n * m),所以KMP在字符串匹配中极大地提高了搜索的效率。


例题28.实现strStr

题目:28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)

构造next数组 

我们定义一个函数getNext来构建next数组, 函数参数为指向next数组的指针,和一个字符串。代码如下:

void getNext(int* next, const string& s)

构造next数组其实就是计算模式串s前缀表的过程。主要有如下三步:

  1. 初始化
  2. 处理前后缀不相同的情况
  3. 处理前后缀相同的情况 

1.初始化:

定义两个指针 i 和 j ,j 指向前缀末尾位置,i 指向后缀末尾位置。然后对next数组进行初始化赋值,第一个位置设为-1,如下:

int j = -1;
next[0] = j;

这里我们采取的是前缀表统一减一的操作,这只是一种实现方式。后续给出 j 不初始化为-1的实现代码。next[i] 表示 i (包括i) 之前最长相等的前后缀长度(其实就是j)

2.处理前后缀不相同的情况

因为 j 初始化为-1,那么 i 就从1开始,进行s[i] 与 s[j + 1]的比较。

for (int i = 1; i < s.size(); i++) {

如果s[i] 与 s[j + 1]不相同,也就是遇到前后缀末尾不相同的情况,就像向前回退。这个地方有个很关键的部分,怎么进行j的回退:next[j] 就是记录者 j(包括j)之前的子串的相同前后缀的长度,那么s[i] 与 s[j + 1] 不相同,就要找j + 1前一个元素在next数组里的值(就是next[j]),这实际上也蕴含了KMP算法。

所以,处理前后缀不相同的情况代码如下:

while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
    j = next[j]; // 向前回退
}

3.处理前后缀相同的情况

如果s[i] 与 s[j + 1] 相同,那么就同时向后移动i 和 j 说明找到了相同的前后缀,同时还要将j(前缀长度)赋给next[i],因为next[i] 要记录相同前后缀的长度:

if (s[i] == s[j + 1]) { // 找到相同的前后缀
    j++;
}
next[i] = j;

最后整体构建next数组的函数代码如下:

void getNext(int* next, const string& s){
    int j = -1;
    next[0] = j;
    for(int i = 1; i < s.size(); i++) { // 注意i从1开始
        while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
            j = next[j]; // 向前回退
        }
        if (s[i] == s[j + 1]) { // 找到相同的前后缀
            j++;
        }
        next[i] = j; // 将j(前缀的长度)赋给next[i]
    }
}

代码构造next数组的逻辑流程动画如下: 图片来源

得到了next数组之后,就可以用这个来进行匹配了

使用next数组来做匹配 

在文本串s里,找是否出现过模式串t 。定义两个下标 j 指向模式串起始位置,i 指向文本串起始位置。j 初始值依然设置为 -1, 因为next数组里记录的起始位置为 -1。

i从0开始,遍历文本串,代码如下:

for (int i = 0; i < s.size(); i++) 

接下来就是s[i] 与 t[j + 1]进行比较。

如果s[i] 与 t[j + 1]不相同,j 就要从next数组里寻找下一个匹配的位置。

while(j >= 0 && s[i] != t[j + 1]) {
    j = next[j];
}

如果s[i] 与 t[j + 1]相同,那么i 和 j同时向后移动,代码如下:

if (s[i] == t[j + 1]) {
    j++; // i的增加在for循环里
}

如果 j 指向了模式串 t 的末尾,那么就说明模式串 t 完全匹配文本串 s 里的某个子串了。由于本题要在文本串字符串中找出模式串出现的第一个位置(从0开始),所以返回当前在文本串匹配模式串的位置 i 减去模式串的长度, 就是文本串字符串中出现模式串的第一个位置。代码如下:

if (j == (t.size() - 1) ) {
    return (i - t.size() + 1);
}

那么使用next数组,用模式串匹配文本串的整体代码如下:

int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
    while(j >= 0 && s[i] != t[j + 1]) { // 不匹配
        j = next[j]; // j 寻找之前匹配的位置
    }
    if (s[i] == t[j + 1]) { // 匹配,j和i同时向后移动
        j++; // i的增加在for循环里
    }
    if (j == (t.size() - 1) ) { // 文本串s里出现了模式串t
        return (i - t.size() + 1);
    }
}

前缀表统一减一 C++代码实现

最后我们得到最终的代码:

class Solution {
public:
    //获得next数组
    void getNext(int* next, const string s) {
        //1.初始化j 和 next[0]
        int j = -1;
        next[0] = j;
        for(int i = 1; i < s.size(); i++) {
            //2.处理s[i] != s[j + 1]的情况
            while(j >= 0 && s[i] != s[j + 1]) {
                j = next[j];
            }
            //3.处理s[i] == s[j + 1]的情况
            if(s[i] == s[j + 1]) {
                j++;
            }
            next[i] = j;
        }
    }
    int strStr(string haystack, string needle) {
        if(needle.size() == 0) return 0;
        vector<int> next(needle.size());
        getNext(&next[0], needle);

        //使用next数组进行匹配
        //1.初始化j
        int j = -1;
        for(int i = 0; i < haystack.size(); i++) {
            //2.处理haystack[i] != needle[j + 1]的情况
            while(j >= 0 && haystack[i] != needle[j + 1]) { //不包含和下标0元素比较的情况
                j = next[j];
            }
            //3.处理haystack[i] == needle[j+1]的情况
            if(haystack[i] == needle[j + 1]) { //包含和下标0元素比较的情况
                j++;
            }
            if(j == (needle.size() - 1)) {
                return i - needle.size() + 1;
            }
        }
        return -1;
    }

前缀表(不减一)C++代码实现

事实上,是否减1,只是KMP算法实现上的不同,如果就直接使用前缀表可以换一种回退方式,找j = next[j - 1] 来进行回退。主要是j = next[x] 这一步最为关键。

在前缀表统一减一中,getNext()函数的实现为:

void getNext(int* next, const string& s) {
    int j = -1;
    next[0] = j;
    for(int i = 1; i < s.size(); i++) { // 注意i从1开始
        while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
            j = next[j]; // 向前回退
        }
        if (s[i] == s[j + 1]) { // 找到相同的前后缀
            j++;
        }
        next[i] = j; // 将j(前缀的长度)赋给next[i]
    }
}

此时如果输入的模式串为aabaaf,对应的next为-1 0 -1 0 1 -1。

这里的j 和 next[0] 初始化为 -1. 整个next数组是以前缀表减一之后的效果来构建的。

那么前缀表不减一来构建next数组,代码就为:

void getNext(int* next, const string& s) {
    int j = 0;
    next[0] = 0;
    for(int i = 1; i < s.size(); i++) {
        while (j > 0 && s[i] != s[j]) { // j要保证大于0,因为下面有取j-1作为数组下标的操作
            j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了
        }
        if (s[i] == s[j]) {
            j++;
        }
        next[i] = j;
    }
}

此时如果输入的模式串为aabaaf,对应的next为0 1 0 1 2 0,(其实这就是前缀表的数值了)

那么用这样的next数组也可以用来做匹配,代码要有所变动:

代码:

class Solution {
public:
    //前后缀不减一的情况
    void getNext(int* next, const string s) {
        //1.初始化j 和 next 数组
        int j = 0;
        next[0] = j;
        
        for(int i = 1; i < s.size(); i++) {
            //2.处理s[i] != s[j]的情况
            while(j > 0 && s[i] != s[j]) { //不包含和s[0]比较的情况
                j = next[j - 1];
            }
            //3.处理s[i] == s[j]的情况
            if(s[j] == s[i]) { //包含和s[0]比较的情况
                j++;
            }
            next[i] = j;
        }
    }
    int strStr(string haystack, string needle) {
        if(needle.size() == 0) return 0;
        vector<int> next(needle.size());
        getNext(&next[0], needle);

        //1.初始化
        int j = 0;
        for(int i = 0; i < haystack.size(); i++) {
            //处理不相等的情况
            while(j > 0 && haystack[i] != needle[j]) {
                j = next[j - 1];
            }
            //处理相等的情况
            if(haystack[i] == needle[j]) {
                j++;
            }
            if (j == needle.size() ) {
                return (i - needle.size() + 1);
            }            
        }
        return -1;
    }
};

总结 

我们需要明确KMP算法的使用场景,能够解决什么问题:字符串匹配的问题

KMP算法中最关键的是next数组,且要知道next数组其实就是前缀表。

我们要知道next数组是如何来实现匹配代码的,如果使得时间复杂度降为O(m + n)


拓展:next数组的优化 

实际上next数组还可以进行优化,以aabaaf为例,我们使用不减1的方式得到的next数组是:0 1 0 1 2 0。

我们在匹配的时候,如果匹配到第四个 a 匹配失败,根据代码我们会跳到下标为1的位置继续匹配。但实际上下标为1的位置,我们知道是 a 肯定也是匹配失败的,这就多出了无效匹配。

因此可以对next数组进行优化,得到nextval数组:0 1 0 0 2 0;

其实得到nextval数组的方法很简单,我们知道next数组是来进行回退的,当我们当前匹配的不正确的时候,就要进行回退,因此如果我们回退后的字母和当前匹配失败的字母是一样的,则更新当前匹配失败字母的next数值,改为回退后的字母的next数值,意味着,跳过匹配,直接找到下一个next数。

对于aabaaf来说,当我们匹配到第四个a失败时,我们跳到下标为1的位置,发现也是 a 则我们需要把next[4]改为next[0](因为我们是跳到next[j - 1]的位置)。这样就直接跳过了无效匹配,找到了下一个应该跳到的位置。

具体代码实现为:

class Solution {
public:
    //前后缀不减一的情况
    void getNext(int* nextval, const string& s) {
        vector<int> next(s.size());
        //1.初始化j 和 next 数组
        int j = 0;
        next[0] = j;
        
        for(int i = 1; i < s.size(); i++) {
            //2.处理s[i] != s[j]的情况
            while(j > 0 && s[i] != s[j]) { //不包含和s[0]比较的情况
                j = next[j - 1];
            }
            //3.处理s[i] == s[j]的情况
            if(s[j] == s[i]) { //包含和s[0]比较的情况
                j++;
            }
            next[i] = j;
        }

        nextval[0] = next[0];
        for(int i = 1; i < s.size() - 1; i++) {
            if(next[i] != 0 && s[next[i]] == s[i + 1]) {
                nextval[i] = next[next[i] - 1];
            }
            else {
                nextval[i] = next[i];
            }
        } 
    }
    int strStr(string haystack, string needle) {
        if(needle.size() == 0) return 0;
        vector<int> next(needle.size());
        getNext(&next[0], needle);
        //1.初始化
        int j = 0;
        for(int i = 0; i < haystack.size(); i++) {
            //处理不相等的情况
            while(j > 0 && haystack[i] != needle[j]) {
                j = next[j - 1];
            }
            //处理相等的情况
            if(haystack[i] == needle[j]) {
                j++;
            }
            if (j == needle.size() ) {
                return (i - needle.size() + 1);
            }            
        }
        return -1;
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值