《彻底搞懂KMP算法:核心思想 + 代码实现》

引言

✨ “说实话,我已经学过 KMP 算法好几次了!第一次接触它时,真的觉得好难啊!😵‍💫 但最终还是勉强记住了。”
✨ “然而,每次需要手写 KMP 时,还是会遇到各种问题,写着写着就懵了……😥”

所以,我决定写一篇博客,希望可以帮你更好地理解 KMP 的思想,并且可以自己实现出来哦!💪💕


KMP 算法:

我们要解决的问题是 在一个字符串 str 里,找到 match 字符串最早出现的位置
🌸 如果找到了,就返回索引;如果找不到,就返回 -1。

下面是 KMP 需要掌握的几个重要知识点:

  • KMP 算法是干什么的?
  • 先看看 BF(Brute Force)暴力解法
  • 认识 next 数组
  • 如何用 next 数组加速匹配?
  • 为什么 next 数组能提高效率?
  • 如何快速求 next 数组?
  • 代码实现!(C++ & Java)

全文主要采用代码C++叙述, 不影响KMP算法的掌握。 Java完整代码详见后文的实现


KMP 算法是什么?

KMP 算法由 Donald Knuth, Vaughan Pratt, James H. Morris 在 1977 年提出。💡
它主要用于字符串匹配和文本搜索。

问题描述
假设我们有两个字符串:

  • str = "abcde"
  • match = "cd"

我们想要找到 matchstr 里最早出现的位置,所以返回 2(从 0 开始计数)。
如果 str = "abcd"match = "de",那么显然 match 不在 str 里,所以返回 -1


先看看 BF(暴力解法)💥

KMP 算法的核心思想是 优化暴力匹配,所以我们先来看看 BF(Brute Force)暴力解法。

BF 算法的思路

  1. 遍历 str 的所有可能起点(从 0n-m)。
  2. 对每个起点,依次匹配 match 的字符。
  3. 如果成功匹配 match.length() 个字符,就找到了!🎉
  4. 如果匹配到一半发现不对劲,就换下一个起点继续匹配。

🌸 代码实现(C++)

class Solution {
public:
    int strStr(string haystack, string needle) {
        int n = haystack.length(), m = needle.length();
        if (n < m) return -1;
        for (int i = 0; i <= n - m; i++) {
            int j;
            for (j = 0; j < m; j++) {
                if (haystack[i + j] != needle[j]) {
                    break;
                }
            }
            if (j == m) return i;
        }
        return -1;
    }
};

🌟 BF 算法的时间复杂度是 O ( n × m ) O(n \times m) O(n×m)
n 远远大于 m 时,匹配过程会非常慢,比如:

str   = aaaaaaaaaaaaaaaaaaaaab
match = ab

会导致 大量的无效匹配,所以我们需要更聪明的做法—— KMP!🤩


认识 next 数组 💡

KMP 的核心优化点就是 next 数组!但它到底是什么呢?😵‍💫 别急,慢慢来~

🌸 next[i] 的含义:
next[i] 代表的是:match[0:i] 的前后缀的最大匹配长度
(前后缀不能是这个字符串本身哦)

看完这个示例你应该理解next数组的含义。
暂时不要考虑next数组如何求解, 先看下一个标题如何利用它加速求解吧。

举个例子:

假设 match = "aabaabtabc",我们来计算 next[6],也就是 match[0:6] = "aabaab"next 值:

next[6] 就是找 前后缀匹配最长的部分

  • 前缀:从开头开始,不能包含整个 aabaab
  • 后缀:从结尾往前看,不能包含整个 aabaab

以下模拟"找"的过程。

aabaabtabc
      i
i指向match字符串的t字符, next[i]是i位置前面字符串`aabaab` 前后缀字符串的最大匹配长度。
🆗,下面来求解这个前后缀字符串是什么, 以及长度
前缀串是[0,l], 后缀串是[r,i-1]
aabaab
l    r
前缀串:a
后缀串:b
❌ 不匹配 前后缀串最大长度为0 

aabaab
 l  r
前缀串:aa
后缀串:ab
❌ 不匹配 前后缀串最大长度为0 

aabaab
  lr
前缀串:aab
后缀串:aab
✅ 匹配长度 前后缀串最大长度更新为3

aabaab
  rl
前缀串:aaba
后缀串:baab
❌ 不匹配 ,前后缀串最大长度保持为3。

aabaab
 r  l
前缀串:aabaa
后缀串:abaab
❌ 不匹配 前后缀串最大长度保持为3

aabaab
r    l
这种情况前后缀串等于这个字符串本身了本身了,不符合next的要求。
next[i] = 3。✅ 匹配长度 3

🎀 总结:
next[i] 让我们知道 如果匹配失败,模式串该回退到哪里,这样就能避免大量的无效匹配,提高效率!


KMP 代码实现 🎯

利用 next 数组进行匹配加速!

🌸 核心思想

  • 如果 str[i] == match[j],匹配成功,i++j++
  • 如果 str[i] != match[j],匹配失败:
    • j > 0,说明 match 里有前后缀匹配,我们直接跳到 next[j] 位置,避免重新匹配。
    • j == 0,说明 match 没有可以跳过的部分,i++ 继续匹配下一个字符。

🌸 代码实现

int kmp(const string& str, const string& match) {
    int n = str.length(), m = match.length();
    if (n < m) return -1;
    
    // 计算 next 数组
    vector<int> next = next_array(match);

    int i = 0, j = 0;
    while (i < n && j < m) {
        if (str[i] == match[j]) {
            i++, j++;
        } else if (j > 0) {
            j = next[j];  // 匹配失败,回溯到 next[j]
        } else {
            i++;  // 没有可以回溯的地方,继续向前
        }
    }

    return (j == m) ? i - j : -1;
}

🌸模拟说明:

下面是索引index,strmatch字符串已经matchnext数组值
index: 0 1 2 3 4 5 6 7 8 9 10111213
str =  a a b a a b c a a b a a b a ...
match= a a b a a b c a a b a a b t
next= -1 0 1 0 1 2 3 0 1 2 3 4 5 6
                                 i
                                 j
按照上面的代码逻辑,它会一直执行str[i]==match[j]分支,
直到index=13,str[13]=a, match[13]=t. 两者不相等。
如果按照Brute Force算法,下面应该移动起始索引(0开头挪到1开头)进行匹配。
KMP算法利用next数组,已知的前后缀串信息, 加速匹配。
match下标13的前面字符串是aabaabcaabaab, 前后缀串是aabaab,长度是6(next[13]存储的信息)
前后缀串的位置是已知的,因为开头start确定,末尾end确定。 前缀串是[start,start+6), 这里start是0,表示str[0,6)的子串。end从start(0)13(不匹配),后缀串是[end-6,end),这里是str[7,13)的子串。

我们发现[0,6),(7,13]是完全匹配的。next的定义!
思路是平移和跳转
index: 0 1 2 3 4 5 6 7 8 9 10111213
str =  a a b a a b c a a b a a b a ...
match=               a a b a a b c a a b a a b t
                                 i
                                 j
match0str7对齐,末尾613对齐。
这个时候我们舍弃前面的1 2 3 4 5 6作为起始索引的可能了, 它们一定不会匹配成功的(why? 详见下文)。 这样就加速了。
只需要再次考察index=13的位置。 

由于有平移操作,因此要更新j的位置, j原先在match13位置
[0,6)
[7,13)
前缀串的开区间的末尾下标6就是next[13].
因此跳跃j=13->j=6
相当于
`j=next[j]`, 这是第二个分支else-if的含义。
next[j]!=-1或者j>0说明当前j不是在0位置,可以跳跃。

接着看这个样例。
str[13]!=match[6], 尽管平移了一次。但仍然不匹配。
继续平移。
j = next[6]; //next[6]等于3.
----
index: 0 1 2 3 4 5 6 7 8 9 10111213
str  = a a b a a b c a a b a a b a ...
match=                     a a b a a b c a a b a a b t
                                 i
                                 j

终于
str[13]match[3]匹配上了。

后续str字符串省略了。
但我说明的核心过程已经完成了。

💖 为什么这样可以加速?

  • 传统暴力匹配失败时,要 回到开头 重新比对;
  • KMP 利用 next 数组,让 match 直接跳到合适的位置,避免重复匹配。
str  : ----------l
match: ----------y
next :-10--------k
                 i
                 j

现在假设在下标j处str[i]=l, match[j]=y,两者不匹配。
next[j]=k.
说明[start,start+k)[end-k,end)匹配。
因此想到平移。
有如下对应:
start<-end-k
start+1<-end-k+1
...
start+k-1<-end-1
比较start+k<-end
j往左挪到start+k的位置。
然后继续比较。

总结第一点:
由next数组记录的前后缀串长度信息,
完整地[start,start+k)[end-k, end)一定相等,不用验证。

第二个:
为什么start+1, start+2,一直到end-k+1的位置为开头一定不成立,不验证了,直接挪到end-k位置开始比对呢?
不妨反证一下,
从[start, end-k), 假设取一个下标x能够匹配。
      start x start+k end-k end
          | | |        |    |
str  : ---------------------l
match: ---------------------y
next :-10-------------------k
                            i
                            j
假设str[x,end)能匹配上match。那么说明match必须要从0开始有end-x的长度。
next[j]是end-x
但由于x在[start, end-k), 这个假设会导致next[j]值应当大于k,故这样的x不存在。
因此,[start,end-k)这样位置直接放弃!

next 数组快速求解 💡

🤩快速求解 match 字符串的 next 数组。

首先,next[0] = -1next[1] = -1。这两个是确定的。那么对于一般的 next[i],可以根据前面的已知信息来推导。

在我看来,这本质上与前面加速求解相同,核心是跳转。

示例一:
match = abatabasabatabas?
next  = ---------------7?
               7        i    

求解 next[i],已知 next[i-1],我们观察一下 i-1,它的前缀后缀串是 abataba,长度是 7。
我们不妨观测 match[7] 的位置,match[i-1]match[7] 是相等的!
next[i] = next[i-1] + 1

遗憾的是,示例一不总是发生。

示例二:
match = abatabasabatabat?
next  = ---------------7?
           3   7        i    

i-1 位置字符变为 t 了,match[i-1] != match[7]
直觉告诉我们,next[i] 只能小于 next[i-1]
t 字符前后缀串是 i-1 前后缀串的子串。
由于前缀串后缀串的位置对应关系,
先从 i-1 位置跳转 7
next[7] = 3,观察一下 match[3]match[3] = match[i]
说明 next[i] = next[3] + 1

为什么?
7 位置前缀串 aba 等于 7 位置的后缀串。
7 位置的后缀串是 i-1 位置后缀串的子串。
总之,经过这种对应(自行体会),abat 这个串就作为 i 位置的前缀串,同样在对应后它也满足后缀串。

总之:经过了一次跳转,然后用示例一的思路和前后缀对应的思路,同样推出了 next[i] 的信息。

示例三:
match = a b a
next  = -1 0 ?
            i

b 字符 next 值为 0,跳转到 0 下标处。 a 字符与 b 字符不等。
但已经无法跳转,故确定 next[i] = 0

代码实现 ✨

vector<int> next_array(const string& s, int n) {
    if (n == 1) return {-1};
    vector<int> next(n);
    next[0] = -1, next[1] = 0;
    for (int i = 2, pre = 0; i < n;) {
        if (s[i - 1] == s[pre])  next[i++] = ++pre;
        else if (pre > 0) pre = next[pre];
        else next[i++] = 0;
    }
    return next;
}

代码解释 ✨

  • 首先,如果 match 串本身长度为 1,返回 {-1} 即可。
  • next[0] = -1, next[1] = 0; 不解释。
  • pre 是前一个字符比对的下标。
  • i 表示当前求解 next[i] 的下标。
示例一举例:
match = abatabasabatabas?
next  = -10--------------
          i
  • 初始 pre = 0, i = 2
  • pre = 0,无法跳转,执行分支三,结算当前 next[i] = 0i 自增 1
match = abatabasabatabas?
next  = -100-------------
           i
  • s[i-1]s[pre] 比对,aa 相等。
  • 结算 i 位置 next[i] = next[i-1] + 1, next[i-1] = pre,next[i++] = ++pre
  • i 自增是因为结算了,pre 自增是更新后续下一个 i 的比对位置。
  • 注意前增和后增!

时空复杂度分析 💡

next_array 函数,KMP 算法的预处理过程,时间复杂度推导。

求解 next 数组时间复杂度分析

vector<int> next_array(const string& s, int m) {
    if (m == 1) return {-1};
    vector<int> next(m);
    next[0] = -1, next[1] = 0;
    for (int i = 2, pre = 0; i < m;) {
        if (s[i - 1] == s[pre])  next[i++] = ++pre;
        else if (pre > 0) pre = next[pre];
        else next[i++] = 0;
    }
    return next;
}
  • i2 递增到 m O ( m ) O(m) O(m)。如果不走第二个分支的话。
  • 考虑到第二个分支,时间复杂度可能比较难理解了。

推荐这样的方式理解:

  • 构造一个变量 i - pre
  • i - pre 是有上界的,就是 m
  • 从第一个分支i 自增,i - pre 不变。因为 pre 也增加。
  • 从第二个分支i 不变,i - pre 增加。因为 pre 会回溯到更小的位置处。
  • 从第三个分支i 自增,i - pre 不变。
  • 我们可以得出 i + (i - pre) 是严格递增的。无论从哪个分支,因此循环总次数就是 i + (i - pre) 的上界 2 m 2m 2m

next_array 函数的时间复杂度是 O ( m ) O(m) O(m)

同样地,得出 kmp 函数时间复杂度是 O ( n ) O(n) O(n)

准确地:KMP 算法的时间复杂度是 O ( n + m ) O(n + m) O(n+m),一般 str 长度远大于 match 长度。即 n ≫ m n \gg m nm
时间复杂度 O ( n ) O(n) O(n)

空间复杂度使用了部分匹配表 next 数组: O ( m ) O(m) O(m)

C++,Java完整实现. ✨

c++:


#include<iostream>
#include<vector>
#include<string>
using namespace std;

//KMP算法 用于字符串匹配

int kmp(const string& str, const string& match);
vector<int> next_array(const string& s, int n);
int kmp(const string& str, const string& match){
    int n = str.length(), m = match.length();
    vector<int> next = next_array(match, m);
    int i=0,j=0;
    while(i<n&&j<m){
        if(str[i]==match[j]) ++i,++j;
        else if(j==0) ++i;
        else j=next[j];
    }
    return j==m?i-j:-1;
}
//求s串的next数组
vector<int> next_array(const string& s, int n){
    if(n==1) return {-1};
    vector<int> next(n);
    next[0]=-1,next[1]=0;
    for(int i=2,pre=0;i<n;){
        if(s[i-1]==s[pre]) next[i++]=++pre;
        else if(pre>0) pre=next[pre];
        else next[i++]=0;
    }
    return next;
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    string str, match;
    getline(cin, str);
    getline(cin, match);
    int idx = kmp(str, match);
    if(idx==-1){
        cout<<"str不存在子串match"<<'\n';
    }else{
        printf("str第一次出现match串的下标:%d\n", idx);
    }
    return 0;
}

java:

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.IOException;

public class code01kmp {
    public static int[] DEFALUT_ARRAY = new int[]{-1,0};
    public static int[] getNextArr(String s, int n){
        if(n<=2){
            return DEFALUT_ARRAY;
        }
        int[] next = new int[n];
        next[0] = -1;
        next[1] = 0;
        for(int i=2,pre=0;i<n;){
            if(s.charAt(i-1)==s.charAt(pre)) next[i++] = ++pre;
            else if(pre>0) pre=next[pre];
            else next[i++]=0;
        }
        return next;
    }
    public static int KMP(String str, String match){
        int n = str.length(), m = match.length();
        if(n<m) return -1;
        int[] next = getNextArr(match,m);
        int i,j;
        for(i=0,j=0;i<n && j<m;){
            if(str.charAt(i)==match.charAt(j)){
                i++;
                j++;
            }else if(j==0){
                ++i;
            }else{
                j = next[j];
            }
        }
        return j==m?i-j:-1;
    }
    public static void main(String[] args) throws IOException{
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        String str = in.readLine();
        String match = in.readLine();
        int idx = KMP(str, match);
        if(idx==-1){
            out.println("str不存在match子串");
        }else{
            out.printf("str第一次出现match子串下标:%d \n", idx);
        }
        out.flush();
        out.close();
        in.close();

    }  
}


总结 🎀

✨ KMP 的核心是 next 数组,利用它可以避免无效匹配,提高匹配效率
暴力解法的时间复杂度是 O(n × m),KMP 通过 next 数组将其优化到 O(n)!
掌握 KMP 的关键是理解 next 数组的含义和作用! 🌸


结语✨

时隔一个月再次更新。
怎么说呢?
感觉自己爆更,刻意写博客写得很烂很水。偶尔写一次挺不错的。😊
嗯, 随缘更新吧。写一篇有质量的文章太难了, 好难受。 月更博主, 加油读者朋友们。

希望这篇文章能帮助你理解 KMP 算法~ 💖
如果有什么问题,欢迎留言交流哦!🌷✨

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值