数据结构以学带练day9——KMP算法理论、实现KMP(找出字符串中第一个匹配项的下标)、KMP应用(重复的子字符串)

162 阅读11分钟

KMP算法

由来

由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP。

应用

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

  • KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容(next表记录),可以利用这些信息避免从头再去做匹配了。

前缀表(next数组)

  • next数组就是一个前缀表(prefix table)。
  • 前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

⭐例:要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaafKMP精讲1.gif 文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。

但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配。而怎么知道是从第三个字符呢?答案就是前缀表里记录了。

  • 前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置
  • 前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

字符串的前缀和后缀

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

⭐例:aabaaf的前缀包括:

  • a
  • aa
  • aab
  • aaba
  • aabaa
  • 字符串的后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串

⭐例:aabaaf的后缀包括:

  • f
  • af
  • aaf
  • baaf
  • abaaf

最长相等前后缀和前缀表计算

⭐例:aabaaf这个模式串中:

  • a:这个字符首字母即尾字母,无前缀和后缀,则最长相等前后缀 = 0
  • aa:前缀a=后缀a,长度1,则最长相等前后缀 = 1
  • aab:前缀a、aa,后缀b、ab,则最长相等前后缀 = 0
  • aaba:前缀a、aa、aab,后缀a、ba、aba,其中前缀a=后缀a,则最长相等前后缀 = 1
  • aabaa:前缀a、aa、aab、aaba,后缀a、aa、baa、abaa,其中前缀aa=后缀aa,则最长相等前后缀 = 2
  • aabaaf:前缀a、aa、aab、aaba、aabaa,后缀f、af、aaf、baaf、abaaf,则最长相等前后缀 = 0

因此,aabaaf这个模式串的前缀表记录的是:[0,1,0,1,2,0]

image.png

⭐例:aabaaf这个模式串中,利用 前缀表找到 当字符不匹配的时候应该指针应该移动的位置: KMP精讲2.gif 找到的不匹配的位置, 那么此时看它的前一个字符的前缀表的数值是多少

  • 为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。所以要看前一位的 前缀表的数值。

前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。最后就在文本串中找到了和模式串匹配的子串了。

前缀表与next数组

很多KMP算法的实现都是使用next数组来做回退操作:

  • next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。这两种操作只是在编程的实现上有些许不同,不涉及什么特殊理由。

例:以前缀表统一减一之后的next数组来做演示

KMP精讲4.gif

题目

28. 找出字符串中第一个匹配项的下标

image.png

Next数组统一前缀表减1的方式

构造next数组

使用needle字符串即模式串找其前缀表:

  1. 初始化
  2. 处理前后缀不相同的情况
  3. 处理前后缀相同的情况
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]
    }
}
1. 初始化
  • 定义两个指针i和jj指向前缀末尾位置,i指向后缀末尾位置。
  • j初始化-1,因为这是Next数组统一减一的方式,且第一个字母是首字母即尾字母,所以最长相等前后缀 = 0,减一 = -1。
  • next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j的值),初始化next[0] = j
2. 处理前后缀不相同的情况
  • i从字符串下标1开始,进行s[i] 与 s[j+1]的比较。
  • 循环遍历模式串needle
  • 如果 s[i] 与 s[j+1]不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。
  • next[j]记录着j(包括j)之前的子串的相同前后缀的长度。当s[i] 与 s[j+1] 不相同,就要找 j+1前一个元素next数组里的值(就是next[j])。
3. 处理前后缀相同的情况
  • 如果 s[i] 与 s[j + 1] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。

使用next数组来做两字符串匹配

  1. 文本串haystack(代码中的s)里找是否出现过模式串needle(代码中的t)
  2. 定义两个下标:j 指向模式串起始位置,i指向文本串起始位置。j初始值依然为-1,依然因为next数组里记录的起始位置为-1i就从0开始,遍历文本串。
  3. s[i] 与 t[j + 1] (因为j从-1开始的) 进行比较。
  4. 如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置。如果s[i] 与 t[j + 1]相同,那么i 和 j 同时向后移动。
  5. 如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。
  6. 返回匹配串的首字母下标:在文本串s中找出模式串t出现的第一个位置 (从0开始),返回当前在文本串s匹配模式串t的位置i 减去 模式串t的长度,就是文本串s中出现模式串t的第一个位置。
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);
    }
}

整体代码

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

Next数组就是前缀表的方式

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;
        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;
    }
};

其他方法(仅用于了解find函数)

class Solution {
public:
    int strStr(string haystack, string needle) {
        if(needle.empty())
            return 0;
        int pos=haystack.find(needle);
        return pos;
    }
};

459. 重复的子字符串

image.png

移动匹配法

思路:

判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成

注意:

在判断 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); 
        //std::string::npos是一个常数,
        //它等于size_type类型可以表示的最大值,用来表示一个不存在的位置
        //即如果在t里找到s的位置不是一个不存在的位置,则返回true
        if (t.find(s) != std::string::npos) return true; 
        return false;
    }
};

KMP法(以next-1的方式)

思路:

由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串,这里拿字符串s:abababab 来举例,ab就是最小重复单位,如图所示:

image.png

⭐例:为何重复子串组成的字符串中最长相等前后缀不包含的子串就是最小重复子串image.png 步骤一:因为 这是相等的前缀和后缀,t[0] 与 k[0]相同, t[1] 与 k[1]相同,所以 s[0] 一定和 s[2]相同,s[1] 一定和 s[3]相同,即:s[0]s[1]与s[2]s[3]相同 。

步骤二: 因为在字符串s的同一个位置,所以 t[2] 与 k[0]相同,t[3] 与 k[1]相同。

步骤三: 因为 这是相等的前缀和后缀,t[2] 与 k[2]相同 ,t[3]与k[3] 相同,所以,s[2]一定和s[4]相同,s[3]一定和s[5]相同,即:s[2]s[3] 与 s[4]s[5]相同。

步骤四:循环往复。

所以字符串s,s[0]s[1]与s[2]s[3]相同, s[2]s[3] 与 s[4]s[5]相同,s[4]s[5] 与 s[6]s[7]相同。

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

思路推导(三种方式)

⭐数学推导:

假设字符串s使用多个重复子串构成(这个子串是最小重复单位),重复出现的子字符串长度是x,所以s是由n * x组成。

因为字符串s的最长相同前后缀的长度一定是不包含s本身,所以 最长相同前后缀长度必然是m * x,而且 n - m = 1

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

⭐放入代码:

next 数组记录的就是最长相同前后缀

如果 next[len - 1] != -1,则说明字符串有最长相同的前后缀。

最长相等前后缀的长度为:next[len - 1] + 1。(这里的next数组是以统一减一的方式计算的,因此需要+1)

如果len % (len - (next[len - 1] + 1)) == 0,则说明数组的长度正好可以被 (数组长度-最长相等前后缀的长度) 整除 ,说明该字符串有重复的子字符串。

⭐图文举例:

image.png

next[len - 1] = 7next[len - 1] + 1 = 8,8就是此时字符串asdfasdfasdf的最长相同前后缀的长度。

(len - (next[len - 1] + 1)) 也就是: 12(字符串的长度) - 8(最长公共前后缀的长度) = 4, 4正好可以被 12(字符串的长度) 整除,所以说明有重复的子字符串(asdf)。

整体代码(以next-1的方式)

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;
    }
};

整体代码(以next的方式)

class Solution {
public:
    void getNext (int* next, const string& s){
        next[0] = 0;
        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]) {
                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] != 0 && len % (len - (next[len - 1] )) == 0) {
            return true;
        }
        return false;
    }
};

字符串总结

基础知识

  • 字符串是若干字符组成的有限序列,也可以理解为是一个字符数组;
  • 在C语言中,把一个字符串存入一个数组时,也把结束符 '\0'存入数组,并以此作为该字符串是否结束的标志。
char a[5] = "asd";
for (int i = 0; a[i] != '\0'; i++) {
}
  • 在C++中,提供一个string类,string类会提供 size接口,可以用来判断string类字符串是否结束,就不用'\0'来判断是否结束。
string a = "asd";
for (int i = 0; i < a.size(); i++) {
}

解题常用方法

  • 双指针法(快慢指针、头尾指针)
  • 反转系列(swap、reverse等函数)
  • KMP匹配(前缀表计算、next数组构造主要的两种方式:-1 与 不-1)