【算法】-KMP算法

目录

什么是KMP算法

KMP的基本格式

什么是Next数组

Next数组求得和使用

一些例题


什么是KMP算法

  • KMP 是一个解决模式串在文本串是否出现过,如果出现过,最早出现的位置的经典算法。
  • Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP 算法”,常用于在一个文本串 S 内查找一个模式串 P 的出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris 三人于 1977 年联合发表,故取这 3 人的姓氏命名此算法。
  • KMP 算法就利用之前判断过的信息,通过一个 next 数组,保存模式串中前后最长公共子序列的长度,每次回溯时,通过 next 数组找到,前面匹配过的位置,省去了大量的计算时间。
  • KMP 算法的时间复杂度为O(m+n)

        其实,对于KMP算法来说,主要特点就是主串(目标串)不用回溯,主串指针i一直往后面移动,只有子串(模式串)的指针j在回溯。这就大大减少了模式匹配算法的比较次数以及回溯次数。KMP算法可以在O(m+n)的时间复杂度量级上完成串的模式匹配。


KMP的基本格式

KMP的算法流程大概如下:

  • 假设现在目标串S匹配到 i 位置,模式串P匹配到 j 位置
    • 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;
    • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
      • 换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j - next[j],且此值大于等于1。

    因此也可以看出next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。

    此也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符

// 设定 s 是目标串, p 是模式串
int KMPFind(char* s, char* p) {  
    int sLen = strlen(s);   // 目标串的长度
    int pLen = strlen(p);   // 模式串的长度
    int i = 0;              // 指向目标串的下标
    int j = 0;              // 指向模式串的下标
    // 定义next数组
    int *next = new int [pLen];
    getNext(next, p);
    while (i < sLen && j < pLen) {
        // 当字符相等时,指针后移,匹配下一个字符 
        if (j == -1 || s[i] == p[j]) {  
            i++;  
            j++;  
        }
        // 如果 j!=-1 并且s[i] != p[j],则表示字符匹配失败
        // 此时指针 i 不回溯,j 则重置为next[j]
        else {    
            j = next[j];
        }
    }
    // 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置  
    if (j == pLen) {
        return i - j;
    }
    // 如果匹配失败,则返回-1 
    else{
        return -1;
    }
}

什么是Next数组

  其实,next数组就是用来让模式串指针j进行合理地回溯,其记录了模式串与目标串不匹配时,模式串应该从哪里开始重新匹配。

  • 在KMP算法中,我们申请一个整型数组next
  • 并且令next[j] = k
  • 则next[j]表示当子串中Pj和主串Si中失配时,
  • 在子串中需要重新和主串中的i进行匹配的字符下标为next[j]。

也就是说,next数组要求的就是最长相同前后缀的长度。

因此,每个字符对应的next数组值,其实就是该字符之前的子串中包含最大长度的相同前后缀子串


 Next数组求得和使用

  • ①寻找前缀后缀最长公共元素长度
    • 对于P = p0 p1 ...pj-1 pj,寻找模式串P中长度最大且相等的前缀和后缀。
    • 如果存在p0 p1 ...pk-1 pk = pj- k pj-k+1...pj-1 pj,那么在包含pj的模式串中有最大长度为k+1的相同前缀后缀。

举个例子,如果给定的模式串为“abab”,那么它的各个子串的前缀后缀的公共元素的最大长度如下表格所示:

比如对于字符串aba来说,它有长度为1的相同前缀后缀a;而对于字符串abab来说,它有长度为2的相同前缀后缀ab(相同前缀后缀的长度为k + 1,k + 1 = 2)。

  • ②求next数组
    • next 数组考虑的是除当前字符外的最长相同前缀后缀,所以通过第①步骤求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为-1,如下表格所示:

比如对于aba来说,第3个字符a之前的字符串ab中有长度为0的相同前缀后缀,所以第3个字符a对应的next值为0;而对于abab来说,第4个字符b之前的字符串aba中有长度为1的相同前缀后缀a,所以第4个字符b对应的next值为1(相同前缀后缀的长度为k,k = 1)。

  • ③根据next数组进行匹配
    • 匹配失配,j = next [j],模式串向右移动的位数为:j - next[j]。
    • 换言之,当模式串的后缀pj-k pj-k+1, ..., pj-1 跟文本串si-k si-k+1, ..., si-1匹配成功,但pj 跟si匹配失败时,因为next[j] = k,相当于在不包含pj的模式串中有最大长度为k 的相同前缀后缀,即p0 p1 ...pk-1 = pj-k pj-k+1...pj-1,故令j = next[j],从而让模式串右移j - next[j] 位,使得模式串的前缀p0 p1, ..., pk-1对应着文本串 si-k si-k+1, ..., si-1,而后让pk 跟si 继续匹配。如下图所示:

       基本代码如下:

// 函数定义:计算给定字符串 p 的最长前缀后缀匹配长度,并存储在数组 next 中
void getNext(char* p, int next[]) {
    int pLen = strlen(p);   // 取到子串的长度 pLen 
    next[0] = -1;           // 初始化 next[0] 为 -1,表示子串已经滑动到头 
    int k = -1;             // k 表示前缀子串
    int j = 0;              // j 表示后缀子串
    // 遍历子串
    while (j < pLen - 1) { 
        if (k == -1 || p[j] == p[k]) {  // 如果当前字符匹配成功,或者已经回到了子串起始位置
            ++k;                        // 向后移动前缀子串的指针
            ++j;                        // 向后移动后缀子串的指针
            next[j] = k;                // 将当前后缀子串的匹配长度保存在 next 数组中
        }  
        else {                          // 如果当前字符匹配失败
            k = next[k];                // 回溯到前缀子串的上一个可能匹配位置
        }  
    }  
} 

那么为什么前后缀不等时k = next[k]呢?

        其实如果按照程序自己代入一些例子时,不难发现在求解next[j]时,knext[j-1],即前一个位置的最长相等前缀后缀长度。那么k = next[k]的原理是利用已知的next数组信息,通过不断向前寻找更短的相等前缀后缀来更新k

        具体来说,当当前位置j的字符与位置k的字符不相等时,就需要k更新为next[k],即跳转到前一个位置的最长相等前缀后缀的末尾。这样做是利用之前已经计算得到的信息,尽可能地利用已经匹配过的部分,减少重复的比较次数。

        通过不断地更新k,直到找到一个与当前字符相等的位置,或者k等于-1,表示无法继续向前找到更短的相等前缀后缀,这时候就可以更新next[j]了。

        总之,k = next[k]的原理是利用已知的最长相等前缀后缀信息,向前寻找更短的相等前缀后缀,以优化字符串匹配的效率。

        那么next数组看似开头两位就是-1和0,并且除了第一位其他的位数都大于等于0,这正确吗?

        答案是并不对,next可以全部加1使得开头两位变成0和1,这样做也是正确的。

        除此之外,next数组应该保证不出现p[j] = p[ next[j] ],这样的话修改next数组可以把一些位置的0减去1变为负数。

        为什么呢?理由是:当p[j] != s[i] 时,下次匹配必然是p[ next [j]] 跟s[i]匹配,如果p[j] = p[ next[j] ],必然导致后一步匹配失败(因为p[j]已经跟s[i]失配,然后你还用跟p[j]等同的值p[next[j]]去跟s[i]匹配,很显然,必然失配),所以不能允许p[j] = p[ next[j ]]。

        如果出现了p[j] = p[ next[j] ]怎么办呢?如果出现了,则需要再次递归,即令next[j] = next[ next[j] ]

        优化后的代码:

void GetNextval(char* p, int next[])  
{  
    int pLen = strlen(p);  
    next[0] = -1;  
    int k = -1;  
    int j = 0;  
    while (j < pLen - 1)  
    {  
        //p[k]表示前缀,p[j]表示后缀    
        if (k == -1 || p[j] == p[k])  
        {  
            ++j;  
            ++k;  
            //较之前next数组求法,改动在下面4行  
            if (p[j] != p[k]) {
                next[j] = k;   //之前只有这一行 
            } 
       
            else  
                //因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]  
                next[j] = next[k];  
        }  
        else  
        {  
            k = next[k];  
        }  
    }  
}  

一些例题

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

        代码中运用了失败数组存储模式字符串中每个位置对应的最长公共前后缀的长度,类似next数组,但是没有特殊处理过。 

int strStr(char* haystack, char* needle) {
    int n = strlen(haystack), m = strlen(needle); // 获取 haystack 和 needle 的长度
    if (m == 0) { // 如果 needle 为空,则直接返回 0
        return 0;
    }
    int fail[m]; // 定义一个长度为 m 的数组用于存储失配函数的值
    fail[0] = 0; // 初始化失配函数数组的第一个元素为 0
    // 构建失配函数数组
    for (int i = 1, j = 0; i < m; i++) {
        while (j > 0 && needle[i] != needle[j]) { // 如果字符不匹配,则回溯 j 到上一个可能的匹配位置
            j = fail[j - 1];
        }
        if (needle[i] == needle[j]) { // 如果字符匹配,则增加 j
            j++;
        }
        fail[i] = j; // 将计算得到的失配函数值存入数组中
    }
    // 在 haystack 中查找 needle
    for (int i = 0, j = 0; i < n; i++) {
        while (j > 0 && haystack[i] != needle[j]) { // 如果字符不匹配,则根据失配函数值回溯 j
            j = fail[j - 1];
        }
        if (haystack[i] == needle[j]) { // 如果字符匹配,则增加 j
            j++;
        }
        if (j == m) { // 如果 j 等于 needle 的长度 m,则说明找到了匹配的子串
            return i - m + 1; // 返回子串的起始位置
        }
    }
    return -1; // 没有找到匹配的子串,返回 -1
}



459. 重复的子字符串 - 力扣(LeetCode)

// 使用KMP算法实现字符串匹配
bool kmp(char* query, char* pattern) {
    int n = strlen(query);  // 查询字符串长度
    int m = strlen(pattern);  // 模式字符串长度
    int fail[m];  // 失败函数数组
    memset(fail, -1, sizeof(fail));  // 初始化失败函数数组为-1
    // 构建失败函数数组
    for (int i = 1; i < m; ++i) {
        int j = fail[i - 1];  // j表示前一个字符的失败函数值
        // 如果失败,向前回溯
        while (j != -1 && pattern[j + 1] != pattern[i]) {
            j = fail[j];
        }
        // 更新失败函数值
        if (pattern[j + 1] == pattern[i]) {
            fail[i] = j + 1;
        }
    }
    int match = -1;  // 当前匹配的位置
    // 在查询字符串中匹配模式字符串
    for (int i = 1; i < n - 1; ++i) {
        // 如果匹配失败,向前回溯
        while (match != -1 && pattern[match + 1] != query[i]) {
            match = fail[match];
        }
        // 更新匹配位置
        if (pattern[match + 1] == query[i]) {
            ++match;
            // 如果匹配到了模式字符串的末尾,则返回true
            if (match == m - 1) {
                return true;
            }
        }
    }
    return false;  // 匹配失败,返回false
}

// 判断字符串是否由一个子字符串重复多次构成
bool repeatedSubstringPattern(char* s) {
    int n = strlen(s);  // 获取字符串长度
    char k[2 * n + 1];  // 创建一个长度为2*n+1的字符串数组
    k[0] = 0;  // 初始化字符串数组的第一个元素为0
    strcat(k, s);  // 将原字符串拼接到字符串数组中
    strcat(k, s);  // 再次将原字符串拼接到字符串数组中,相当于把原字符串复制了一遍
    return kmp(k, s);  // 调用KMP算法判断是否存在重复子字符串
}

686. 重复叠加字符串匹配 - 力扣(LeetCode)

int strStr(char* haystack, char* needle) {
    int n = strlen(haystack), m = strlen(needle); // 获取 haystack 和 needle 的长度
    if (m == 0) { // 如果 needle 为空,则直接返回 0
        return 0;
    }
    int fail[m]; // 定义一个长度为 m 的数组用于存储失配函数的值
    fail[0] = 0; // 初始化失配函数数组的第一个元素为 0
    // 构建失配函数数组
    for (int i = 1, j = 0; i < m; i++) {
        while (j > 0 && needle[i] != needle[j]) { // 如果字符不匹配,则回溯 j 到上一个可能的匹配位置
            j = fail[j - 1];
        }
        if (needle[i] == needle[j]) { // 如果字符匹配,则增加 j
            j++;
        }
        fail[i] = j; // 将计算得到的失配函数值存入数组中
    }
    // 在 haystack 中查找 needle
    for (int i = 0, j = 0; i < n; i++) {
        while (j > 0 && haystack[i] != needle[j]) { // 如果字符不匹配,则根据失配函数值回溯 j
            j = fail[j - 1];
        }
        if (haystack[i] == needle[j]) { // 如果字符匹配,则增加 j
            j++;
        }
        if (j == m) { // 如果 j 等于 needle 的长度 m,则说明找到了匹配的子串
            return i - m + 1; // 返回子串的起始位置
        }
    }
    return -1; // 没有找到匹配的子串,返回 -1
}

int repeatedStringMatch(char * a, char * b){
    int an = strlen(a), bn = strlen(b);
    int index = strStr(a, an, b, bn);// 在字符串 a 中查找字符串 b 的匹配位置
    if (index == -1) { // 如果没有找到匹配的子串,则返回 -1
        return -1;
    }
    if (an - index >= bn) { // 如果匹配位置之后的长度足够容纳字符串 b,则返回 1
        return 1;
    }
    // 计算需要重复多少次字符串 a 才能容纳整个字符串 b
    return (bn + index - an - 1) / an + 2;
}
我们这里说的KMP不是拿来放电影的(虽然我很喜欢这个软件),而是一种算法KMP算法是拿来处理字符串匹配的。换句话说,给你两个字符串,你需要回答,B串是否是A串的子串(A串是否包含B串)。比如,字符串A="I'm matrix67",字符串B="matrix",我们就说B是A的子串。你可以委婉地问你的MM:“假如你要向你喜欢的人表白的话,我的名字是你的告白语中的子串吗?” 解决这类问题,通常我们的方法是枚举从A串的什么位置起开始与B匹配,然后验证是否匹配。假如A串长度为n,B串长度为m,那么这种方法的复杂度是O (mn)的。虽然很多时候复杂度达不到mn(验证时只看头一两个字母就发现不匹配了),但我们有许多“最坏情况”,比如,A= "aaaaaaaaaaaaaaaaaaaaaaaaaab",B="aaaaaaaab"。我们将介绍的是一种最坏情况下O(n)的算法(这里假设 m<=n),即传说中的KMP算法。 之所以叫做KMP,是因为这个算法是由Knuth、Morris、Pratt三个提出来的,取了这三个人的名字的头一个字母。这时,或许你突然明白了AVL 树为什么叫AVL,或者Bellman-Ford为什么中间是一杠不是一个点。有时一个东西有七八个人研究过,那怎么命名呢?通常这个东西干脆就不用人名字命名了,免得发生争议,比如“3x+1问题”。扯远了。 个人认为KMP是最没有必要讲的东西,因为这个东西网上能找到很多资料。但网上的讲法基本上都涉及到“移动(shift)”、“Next函数”等概念,这非常容易产生误解(至少一年半前我看这些资料学习KMP时就没搞清楚)。在这里,我换一种方法来解释KMP算法
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值