【详解KMP】头秃复习:KMP 算法解 LeetCode 28. 实现 strStr()


一、简单题复习 K M P KMP KMP 算法

问题引入:LeetCode 28. 实现 strStr()

题目链接:28. 实现 strStr()

文章讲解:代码随想录
视频讲解:帮你把KMP算法学个通透!(理论篇)
     帮你把KMP算法学个通透!(求next数组代码篇)

思路

 本题是经典的字符串匹配问题,因此可以使用 K M P KMP KMP 算法进行求解。由于笔者在学校学习中, K M P KMP KMP 算法的理论与实际操作考察约等于无,在此单独开一篇来复习该算法的理论思想与代码实现.


二、 K M P KMP KMP ( K n u t h − M o r r i s − P r a t t ) (Knuth-Morris-Pratt) (KnuthMorrisPratt)算法

1. K M P KMP KMP 算法基本内容

K M P KMP KMP 算法是一种典型的用于字符串匹配的高效算法,实现功能为在原串(文本串)中找到一个模式串是否出现,若出现则返回第一次出现的下标;

K M P KMP KMP 对比之前的暴力算法与 B F BF BF 算法,最大的优势在于对文本串只进行一次遍历,从而大大降低了算法的时间复杂度;

K M P KMP KMP 算法的基本思想为:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。

 如何实现记录一部分匹配内容并递推呢?需要借助 K M P KMP KMP 算法的核心技术:前缀表.

2. 前缀表

I I I. 基本概念

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

前缀表:记录一个字符串从开头到当前位置的子串中,最大相等前后缀长度的一个数组。

 在 K M P KMP KMP 算法中,前缀表使用一个next[]数组来表示;

I I II II. 具体生成 / 求法

注意:生成的一定是模式串(需要查找的字符串)的前缀表!!!

 如何求解或生成一个next[]数组,以下面一个字符串为例:
在这里插入图片描述
 以该字符串 S S S 求解倒数第二个下标的next值为例;next数组记录了当前下标以前的子字符串的最大相等前后缀值,因此我们只看包含 i i i 位置以前部分的字符串;可以看到,我们可以找到一对相等的前后缀 “ a b a aba aba” ,并且就是最大的相等前后缀,因为更长的前后缀分别为 “ a b a c abac abac” 与 “ c a b a caba caba” ,前后缀并不相等;那么在next[i]的位置记录下最大相等前后缀长度 3 3 3,完成下标 i i i 位置的前缀表求解,其他位置的next值同理。

 以上部分为手写计算时的求解方法,具体的生成函数我们在后续代码部分进行详解。

I I I III III. Why 前缀表?

 使用前缀表的作用主要就体现在当前字符匹配不合适时,可以快速检索到已经匹配过的字符串,从而省去了回退的操作;

 那么为什么前缀表可以完成记录并重新匹配的操作?怎样完成这样的操作?
在这里插入图片描述
 我们以上图的一个模式串pattern和文本串text的匹配为例,可以看到当前两个串的前七个字符是可以完全匹配的,而当前所指的ij两个位置的字符不匹配,若是暴力算法,则会回退到text的第二个字符位置重新开始匹配;而 K M P KMP KMP 算法利用最大相等前后缀的方法如下:

在这里插入图片描述

 注意到patterntext的前七个字符已经相等了,而j前面一个位置的最大相等前后缀为 3 3 3 ,即有 a b a aba aba 这个相等的前后缀,那么pattern的前三个字符和texti之前的三个字符都是 a b a aba aba ,那么pattern中前四位就有可能和text中包含i的前四位字符相等。这种情况下我们就可以将pattern字符串的 a b a aba aba 前缀挪到text字符串的 a b a aba aba 后缀的位置,比较他们后面的各个字符是否相等:
在这里插入图片描述
 可以发现我们“挪”字符串的过程相当于把模式串pattern后移了,体现在数组指针上就是i不动,而j前移指向某个位置;

j前移的位数则是由我们的next数组所决定,如图所示,第一次是在模式串字符 g g g 的位置出现不匹配,那么j回退一位找到next[j-1]的值,指向的就是应该前移指向的下标 3 3 3.

 简单来说,next数组储存了最大的相等前后缀长度,在当前字符不匹配时,因为前面匹配的若干个元素已经相等,有相同的前后缀,使用next数组可以快速跳转到下一个可能匹配的下标,从而继续在文本串中的遍历匹配,而优化掉了回退的操作。

三、代码实现

1. n e x t next next 数组构建

 定义一个专用于求解next的函数getNext

void getNext(string s, vector<int> &next)

 构建步骤主要有如下三步:

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

(1)初始化

 由于我们定义的getNext是无返回值的函数,因此需要在匹配函数的部分提前定义一个next,使用引用传入getNext函数中,长度应与模式串s长度相同;

 对next内容的初始化为:

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

 对于next数组的构建方法有几种方法,笔者比较偏好从 0 0 0 开始的构建方式,并且在上面讨论的前缀表是从 0 0 0 开始的,能保持本篇文章的一致性;

(2)循环迭代构建

next数组的生成算法会依赖到循环迭代的结构,因此为便于理解和编写程序,在此分析并写出next数组生成的递归函数;

 假设当前生成的是下标为inext值,要求前i+1位字符串的最大相等前后缀,此时有两种情况: s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j] s [ i ] ≠ s [ j ] s[i]\ne s[j] s[i]=s[j];

在这里插入图片描述

s [ i ] = s [ j ] s[i]=s[j] s[i]=s[j] 时,显然此时的相等前后缀可以在前一个位置上的前后缀延伸一个长度,那么此时直接让next[i] = next[i-1]+1即可,而由于j记录的是当前的前缀长度,所以可以先j++,最后将j的值赋给next[i]

s [ i ] ≠ s [ j ] s[i]\ne s[j] s[i]=s[j] 则要复杂一些:
在这里插入图片描述
 在出现当前字符不匹配的情况时,也就意味着我们匹配next[i-1]+1长度的前后缀失败了,所以应该退而求其次,去匹配next[i-1]长度的前后缀,若再不匹配就继续递推…直到找到一个匹配的前后缀为止;然而这种方法和我们的迭代方法很难相容,原因在于后缀的前面几位很难确定是否与前缀相等,暴力匹配又会大大增大时间复杂度;

 对于该问题,可以在生成next数组的同时使用next数组,形成一个递归关系:
在这里插入图片描述

 如图,对于一个字符不匹配的情况,我们发现可以寻找当前匹配的“前后缀的前后缀”;因为当前i位的前后缀匹配失败了,那么参考上面两张图,前后缀继续匹配必然需要前缀的右边界向左移动,后缀的左边界向右移动;只有当移动到后缀的第一位和前缀的第一位相等的时候,才有可能匹配成功;(停止条件)

 我们刚才说的停止条件,实际上在图中相当于:在前缀蓝色框里面再找一个子前缀,在后缀的蓝色框里面再找一个子后缀,让子前缀和子后缀相等,这个时候前两位前缀有可能和后两位后缀相等,那么此时子前缀的位置将是下一个j的位置,重新进行ji位置的字符匹配;

 在代码中如何实现快速找到子前缀的位置呢;我们注意到,两个蓝色框内的字符串是完全相等的,也就是说前缀蓝色框的子后缀后缀蓝色框的子后缀是一样的,那么刚刚说的停止条件实际上是:在前缀的蓝色框中寻找最大相等前后缀,而最大相等前后缀恰恰可以用我们的next数组找到;

 具体操作将j向前退一位查询next的值,即为j应该跳转到的子前缀的位置,继续比较ji位置的字符即可,若相等那么跳转到相等情况赋值即可,若不相等则重复上面的操作.

将上述的操作归纳为递归方程列写如下:

j ( i ) = { n e x t [ i − 1 ] + 1 , s [ i ] = s [ j ] n e x t [ j ( i ) − 1 ] , s [ i ] ≠ s [ j ] j_{(i)} = \begin{cases} next[i-1]+1, & \text s[i]=s[j]\\ next[j_{(i)}-1], & \text s[i]\ne s[j] \end{cases} j(i)={next[i1]+1,next[j(i)1],s[i]=s[j]s[i]=s[j]

 如此我们便可以根据递归方程编写next数组的循环迭代生成代码了.

void getNext(string s, vector<int> &next){  //构建next数组
        next[0] = 0;
        int j = 0;
        for(int i = 1; i < s.size(); i++){
            while(j > 0 && s[i] != s[j]){  //若不等,则j回退
                j = next[j-1];  //寻找当前前缀的子前缀
            }
            if(s[i] == s[j]){ //若相等,那么相等前后缀长度+1
                j++;
            }
            next[i] = j; //赋值给当前的next
        }
    }

2. 使用 n e x t next next 数组进行匹配

 使用next进行匹配的过程我们在 Why 前缀表?部分已经讲解了理论部分,因此此处不过多赘述,只简单讲解一下代码;

 寻找模式串在文本串中第一次出现的位置,我们从文本串开头开始遍历,用i作为文本串里的指针,j作为模式串里的指针开始循环

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

 如果当前文本串和模式串中指向的字符相等,那么ij都前进一位,由于i的增量在for循环中已经包含,所以只需要给j增量就好

if (haystack[i] == needle[j]) {
	j++;
}

 若当前文本串和模式串中指向的字符不等时,j就需要前移寻找可能匹配的新位置,j前移的位数则是由我们的next数组所决定,那么j回退一位找到next[j-1]的值,则next[j-1]的值就是新的j,从新的j
位置开始匹配;

while(j > 0 && haystack[i] != needle[j]) {
	j = next[j - 1];                
}

 当模式串中字符全部匹配结束,即j的位置到达模式串末尾,代表寻找到匹配的子串,利用下标简单计算输出即可;若完整的走完了for循环,则代表遍历完文本串仍然没有找到匹配的下标,那么返回 − 1 -1 1.

3. 完整代码

class Solution {
public:
    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 = next[j - 1];
            }
            if (s[i] == s[j]) {
                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数组
        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、付费专栏及课程。

余额充值