代码随想录第九天 | KMP:基本功能字符串匹配(leetcode 28),使用字符串匹配实现找重复子字符串(leetcode 459)

1、KMP

1.1 leetcode 28:KMP思路

两个完全相同的字符串匹配逻辑(通过该元素之前的相同前后缀减少前缀/子串在不相等时前移的位数

1、求next数组的时候是用前缀去匹配后缀
2、功能函数里面是用子串去匹配主串

第一遍看完思路代码:

class Solution {
public:
    void getNext(vector<int>& next, string s) {//KMP算法next数组,记录从开始到当前位置的最长前后缀长度
        int j = 0;//前缀
        for(int i = 1; i < s.size(); i++) {//后缀,用前缀去匹配后缀
            if(s[i] == s[j]) j++;
            else {
                while(j > 0 && s[i] != s[j]) {
                    j = next[j - 1];
    //这个元素已经不等了,前一个还是相等的,前一个元素的最长前后缀长度正好等于接下来要比的元素的下标(正好多1)
                }
                if(j != 0 || s[0] == s[i]) {//s[i] == s[j]
                    j++;
                }
            }
            next[i] = j;
        }
    }
    int strStr(string haystack, string needle) {
        int start = -1;
        int j = 0;
        vector<int> next(needle.size(), 0);
        getNext(next, needle);
        for(int i = 0; i < haystack.size(); i++) {//与求next数组的逻辑(用前缀去匹配后缀)相同,用子串去匹配主串
            if(haystack[i] == needle[j]) {
                j++;
            }
            else {
                while(j > 0 && haystack[i] != needle[j]) {
                    //不停的回退,一定是while
                    j = next[j - 1];
                }
                if(j != 0 || needle[0] == haystack[i]) {
    //光j != 0不行,因为万一needle[0] == haystack[i],j仍需++,只要是haystack[i] == needle[j]下来的都要++
                    j++;
                }
            }
            if(j == needle.size()) {
                start = i - needle.size() + 1;
                break;//找到了就跳出循环
            }
        }
        return start;
    }
};

1.2 KMP过程原理

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

KMP主要应用在字符串匹配上

如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任

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

为了清楚地了解前缀表的来历,我们来举一个例子:
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf
前缀表匹配动画 代码随想录
文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了
但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配

前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置

1.2.1 什么是前缀表

前缀表(可以直接作为next数组):记录下标i之前(包括i)的字符串中,有多大长度的相同 最长 前缀后缀

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

前缀表图片解释:
回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:
前缀图片解释 图1
然后就找到了下标2,指向b,继续匹配:如图:
前缀图片解释 图2
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了

1.2.2 如何计算前缀表

前缀表计算 图1
长度为前1个字符的子串a,最长相同前后缀的长度为0
前缀表计算 图2
长度为前2个字符的子串aa,最长相同前后缀的长度为1
计算前缀表 图3
长度为前3个字符的子串aab,最长相同前后缀的长度为0

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

那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
计算前缀表 图4
寻找前缀表动图 代码随想录
要看前一位的前缀表的数值
前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比较

1.2.3 前缀表与next数组

next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组
其实这并不涉及到KMP的原理,而是具体实现

1.2.4 时间复杂度分析

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

暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率

1.2.5 构造next数组

构造next数组其实就是计算模式串s,前缀表的过程。 主要有如下三步:
1、初始化
2、处理前后缀不相同的情况
3、处理前后缀相同的情况

那么前缀表不减一为例来构建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 = 0的处理,也包含对j != 0的处理,等价于if (j != 0 || s[0] == s[i])
                j++;
            }
            next[i] = j;
        }
    }

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

1.2.6 KMP整体代码

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;
        }
        int next[needle.size()];
        getNext(next, needle);
        int j = 0; // 在haystack里面查找needle的逻辑 跟getNext查找前缀 获得next数组的逻辑一致
        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;
    }
};

1.3 leetcode 459:KMP应用进阶

第一遍代码,暴力法

class Solution {
public:
    bool repeatedSubstringPattern(string s) {
        bool judge = false;
        for(int end = 0; end < s.size() - 1; end++) {//前闭后闭,循环不变量
            string substr;
            for(int sub = 0; sub <= end; sub++) {
                substr += s[sub];
            }
            if(s.size() % substr.size() == 0) {
                judge = true;
                for(int j = end+1; j < s.size(); j += substr.size()) {
                    for(int i = 0; i < substr.size(); i++) {
                        if(substr[i] != s[j+i]) { // 思路还是可以的
                            judge = false;
                        }
                    }
                }
                if(judge == true) {
                    return true;
                }
            }
            else {
                continue;
            }
        }
        return judge;
    }
};

暴力法超时了

怎么一个for循环就可以获取子串? 至少得一个for获取子串起始位置,一个for获取子串结束位置,其实我们只需要判断,以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就行了
改进:不用遍历结束,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串
还是超时

1.3.1 两个目标串叠加 字符串匹配(非KMP,技巧,具体原理与后面使用KMP的类似)

当一个字符串s:abcabc,内部由重复的子串组成,那么这个字符串的结构一定是这样的:
移动匹配法 图1
也就是由前后相同的子串组成
那么既然前面有相同的子串,后面有相同的子串,用 s + s,这样组成的字符串中,后面的子串做前串,前后的子串做后串,就一定还能组成一个s,如图:
移动匹配法 图2
所以判断字符串s是否由重复子串组成,只要两个s拼接在一起里面还出现一个s的话,就说明是由重复子串组成

具体原理(与后面使用KMP的类似):
具体原理与后面使用KMP的类似
用后面的来判断,其nx % (n - m)x = 0,因为其 n - m == 1
当然,我们在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s,至少会有一个被拼出来

class Solution {
public:
    bool repeatedSubstringPattern(string s) {
        string t = s + s;
        t.erase(t.begin()); t.erase(t.end() - 1); // 掐头去尾
        if (t.find(s) != std::string::npos) return true; // 判断之间是否还有一个s
        return false;
    }
};

不过这种解法还有一个问题,就是 我们最终还是要判断 一个字符串(s + s)是否出现过 s 的过程,大家可能直接用contains,find 之类的库函数。 却忽略了实现这些函数的时间复杂度(暴力解法是m * n,一般库函数实现为 O(m + n)

使用 KMP 实现,代替库函数 find
注意 KMP 算法相关逻辑,while 和 if 位置,while 不能写在 else 中

class Solution {
public:
    void getNext(vector<int> &next, string s) {
        int slow = 0;
        for (int j = 1; j < s.size(); j++) {
            while (s[j] != s[slow] && slow != 0) {
                slow = next[slow - 1];
            }
            if (s[j] == s[slow])
                next[j] = ++slow;
        }
    }
    int findSub(string str, string sub, vector<int> &next) {
        int sub_i = 0;
        if (str.size() == 0)
            return -1;
        for (int str_i = 1; str_i < str.size(); str_i++) {
            while (str[str_i] != sub[sub_i] && sub_i != 0) {
                sub_i = next[sub_i - 1];
            }
            if (str[str_i] == sub[sub_i])
                sub_i++;
            if (sub_i == sub.size())
                return str_i - sub.size() + 1; 
        }
        return -1;
    }
    bool repeatedSubstringPattern(string s) {
        vector<int> next(s.size(), 0);
        getNext(next, s);
        string new_s = s + s;
        if (findSub(new_s, s, next) != s.size()) 
            return true;
        return false;
    }
};

1.3.2 std::string::npos

std::string::npos 是 C++ 中 std::string 类的静态成员变量,它表示在字符串中找不到指定子字符串或字符的返回值。当我们在字符串中使用 find 或 rfind 函数查找子字符串或字符时,如果查找失败,这些函数会返回 std::string::npos

1.3.3 使用 最长前后缀匹配(KMP)求解

如何找到最小重复子串
使用KMP求解 leetcode 459

最长相等前缀中的0,1号位显然是和最长相等后缀中的0,1号位相等,又因为最长相等前缀中的2,3号位和最长相等后缀中的0,1号位在原字符串中是一个位置,自然相等,所以0,1号位(即缺位)与2,3号位相等以此类推,也会与4,5号位相等,循环往复,即这个0,1号位就是最小重复子串

正是因为最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串就是最小重复子串

假设字符串s使用多个重复子串构成(这个子串是最小重复单位),重复出现的子字符串长度是x,所以s是由n * x组成
因为字符串s的最长相同前后缀的长度一定是不包含s本身,所以 最长相同前后缀长度 必然 是m * x,而且 n - m = 1(当然前提是 确实是由 子字符串组成),(这里如果不懂,看上面的推理)

所以如果 nx % (n - m)x = 0,就可以判定有重复出现的子字符串

如果 next[len - 1] != 0(里的next数组不是以统一减一的方式计算的),则说明字符串最长相同的前后缀(就是字符串里的前缀子串和后缀子串相同的最长长度)

数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环

class Solution {
public:
    void getNext(vector<int> &next, 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;
            }
        }
    }
    bool repeatedSubstringPattern(string s) {
        vector<int> next(s.size(), 0);
        getNext(next, s);
        int i = s.size() - next[s.size() - 1];
        //next[s.size()-1]为最长后缀的长度,i为子字符串的长度
        if(next[s.size() - 1] != 0 && s.size() % i == 0) {
            //s[s.size() - 1] == 0则说明没有相等最长后缀
            return true;
        }
        else {
            return false;
        }
    }
};

上面是看完思路写的是前缀表(不减一)的C++代码

代码随想录C++代码如下:(这里使用了前缀表统一减一的实现方式)

class Solution {
public:
    void getNext (int* next, const string& s){
        next[0] = -1;
        int j = -1;
        for(int i = 1;i < s.size(); i++){
            while(j >= 0 && s[i] != s[j + 1]) {
                j = next[j];
            }
            if(s[i] == s[j + 1]) {
                j++;
            }
            next[i] = j;
        }
    }
    bool repeatedSubstringPattern (string s) {
        if (s.size() == 0) {
            return false;
        }
        int next[s.size()];
        getNext(next, s);
        int len = s.size();
        if (next[len - 1] != -1 && len % (len - (next[len - 1] + 1)) == 0) {
            return true;
        }
        return false;
    }
};

2、字符串总结

字符串总结 代码随想录

3、双指针总结

双指针总结 代码随想录

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值