KMP算法:字符串匹配的智慧跳跃

起因:暴力法的致命缺陷

不知道你有没有曾经为编程中的慢速字符串搜索而烦恼吗?想象一下处理成千上万的字符,却发现你的解决方案运行时间过长。如果有一种方法可以极大地加快这个过程,会怎么样呢?

偶然一次刷题中也遇到了这么一个问题,看似一道很简单的题,背后却又大学问,题目的描述如下:

给你两个字符串 haystackneedle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1

leetcode链接挂这了,有兴趣的小伙伴可以去试试。[找出字符串中第一个匹配项的下标]

其实就是一个字符串匹配,刚读完题我就想到了一个方法,原思路是这样的:

1、以 needle 的第一个字符为基准,顺序遍历 haystack 字符串

2、如果第一个字符串相等,再以此开始,同时移动 needlehaystack 的下标

3、如果 needle 遍历完则表示可以匹配到,反之则表示没有匹配到需要继续遍历

4、没有匹配到则将 haystack 的下标回到上一次匹配的下一个,needle 则回到第一个

5、重复2、3、4,如果 haystack 遍历完都没有匹配到,则不存在

基于此思路,写下如下代码:

int strStr(string haystack, string needle) 
{
    int n = haystack.size(), m = needle.size();
    if (m == 0) return 0;
    if (n < m) return -1;

    for (int i = 0; i <= n - m; i++) // 优化循环终止条件
    { 
        if (haystack[i] != needle[0]) continue;
        int j;
        for (j = 0; j < m; j++) // 直接比较字符,无需创建临时字符串
        { 
            if (haystack[i + j] != needle[j]) break;
        }
        if (j == m) return i;
    }
    return -1;
}

暴力搜索的局限性

这种方法实现简单,但是性能却经不起推敲,用一组看似简单的测试案例演示:

// 主串(haystack): "AAAAAAAAB" (8个'A' + 1个'B',长度9)
// 模式串(needle): "AAAB"       (3个'A' + 1个'B',长度4)
int pos = strStr("AAAAAAAAB", "AAAB"); // 正确结果应为5

根据代码逻辑,实际匹配过程如下(👉 逐帧解析):

  1. i=0(主串起始位置)

    • 比较 haystack[0](A) == needle[0](A) → 成功

    • 逐字符检查

      • j=0: A vs A ✅
      • j=1: A vs A ✅
      • j=2: A vs A ✅
      • j=3: A vs B ❌
    • 总计比较4次 → 失败,i++

  2. i=1(主串第二个A)

    • 再次比较 haystack[1](A) == needle[0](A) → 成功

    • 逐字符检查

      • j=0: A vs A ✅
      • j=1: A vs A ✅
      • j=2: A vs A ✅
      • j=3: A vs B ❌
    • 总计比较4次 → 失败,i++
      (👉 问题浮现:主串的i=1~3位置已经被验证为A,却再次重复比较!)

  3. i=2、i=3、i=4、i=5

    • 每次i递增后,完全重复上述过程 → 每次比较4次,均失败
  4. i=6(主串第七个A)

    • 比较 haystack[6](A) == needle[0](A) → 成功

    • 逐字符检查

      • j=0: A vs A ✅
      • j=1: A vs A ✅
      • j=2: A vs A ✅
      • j=3: B vs B ✅
    • 总计比较4次 → 成功,返回i=5

用一张图片来演示,如下图:


最后的数字触目惊心,这就是暴力法的 “重复税”。

  • 总比较次数 = 4(i=0) + 4(i=1) + 4(i=2) + 4(i=3) + 4(i=4) + 4(i=5) + 4(i=5) = 28次
  • 实际有效比较:只需检查主串i=5~8位置的"AAB"是否匹配,理想情况仅需4次比较

🔥 核心问题:暴力法像陷入泥潭一样,每次失败后主串指针i仅前进1步,导致已确认匹配的字符被反复重验。当模式串有大量重复前缀时(如本例的"AAA"),这种冗余比较会被无限放大!

当我们再换一种思维实验:如果主串是10000个’A’加’B’?

假设 haystack = string(10000, 'A') + "B"needle = string(999, 'A') + "B",暴力法比较次数 ≈ (10000 - 1000) * 1000 = 9,000,000次


暴力方法看起来简单易懂,但效率极低。问题出现在我们找到不匹配时。我们不是跳过已经检查过的字符,而是反复回到它们那里进行检查,导致无数不必要的比较。怎么解决这个问题?这正是本文说要说的重点——KMP,今天,让我们深入了解 KMP(Knuth-Morris-Pratt),这是解决这个常见问题的优雅且高效的方法。

KMP核心思想:避免重复

想象你正在搜索一个巨大的文件,并且你已经在开头找到了一个模式匹配。使用暴力搜索,你会从非常开始的地方再次开始,反复检查相同的点。然而,KMP 就像一个聪明的助手,它会记住你已经看过的位置,并帮助你跳过。

KMP 通过避免我们在暴力方法中看到的冗余比较来解决此问题。关键思想是,当发生不匹配时,我们不是将 haystack 指针向前移动一个位置,而是利用我们已经收集到的关于匹配字符的信息,移动 needle 指针。

我们如何实现这一点?通过使用前缀表(PMT)

理解前缀表是理解KMP算法的关键,可以说这个前缀表就是KMP算法的核心,所以再次强调:前缀表记录的是每个位置的最长公共前后缀的长度!

理解前缀表(PMT)

前缀表,或部分匹配表,存储了 needle 的最长正确前缀同时也是后缀的长度,因此也叫最长公共前后缀。这有助于算法跳过已经匹配的部分 needle ,而不是从头开始。

让我们通过一个例子来分解它是如何工作的:

ABABAC示例:为 ABABAC 构建前缀表,以下是构建该字符串的前缀表(PMT)的方法:

步骤1:i=1(字符B)

  • 比较pattern[1](B)与pattern[j=0](A)
  • 不匹配j保持0,pmt[1]=0

步骤2:i=2(字符A)

  • 比较pattern[2](A)与pattern[j=0](A)
  • 匹配j++pmt[2]=j (j=1)

步骤3:i=3(字符B)

  • 比较pattern[3](B)与pattern[j=1](B)
  • 匹配j++pmt[3]=j (j=2)

步骤4:i=4(字符A)

  • 比较pattern[4](A)与pattern[j=2](A)
  • 匹配j++pmt[4]=j (j=3)

步骤5:i=5(字符C)

  • 比较pattern[5]©与pattern[j=3](B)
  • 不匹配j=pmt[j-1]=pmt[2]=1
  • 再次比较pattern[5]©与pattern[j=1](B) → 仍不匹配
  • 继续回退j=pmt[j-1]=pmt[0]=0
  • 最终pmt[5]=0

最终PMT[0, 0, 1, 2, 3, 0]

索引012345
字符ABABAC
PMT001230

在上面的动画中,你可以看到 KMP 如何通过利用之前匹配收集到的信息来避免不必要的检查。观察当发生不匹配时,模式指针如何跳到前面,从而加快过程。

不匹配时的回退机制

当模式串在位置j匹配失败时,利用PMT值跳转到pmt[j-1]继续匹配:

案例:主串ABABABAC vs 模式串ABABAC(PMT=[0,0,1,2,3,0]

 主串:A B A B A B A C  
模式串:A B A B A C  
匹配失败位置:j=5(字符C)

回退步骤

  1. pmt[j-1]=pmt[4]=3
  2. 模式串跳转到j=3(字符B)继续与主串i=5比较
  3. 跳过冗余比较A B A(已通过PMT确认匹配)

核心代码实现:

Cppvoid build_pmt(string pattern, vector<int>& pmt) 
{
    pmt[0] = 0;
    int j = 0;
    for (int i = 1; i < pattern.size(); i++) 
    {
        // 关键回退:利用已计算的pmt值递归查找
        while (j > 0 && pattern[i] != pattern[j]) 
        {
            j = pmt[j-1];
        }
        // 匹配成功则延长共同前后缀
        if (pattern[i] == pattern[j]) j++;
        pmt[i] = j;
    }
}

理解了回退机制,我们来看看如何用代码实现这一逻辑。

代码:高效字符串匹配

有了前缀表,KMP 算法可以智能地跳过之前匹配的部分。这里是 C++ 中的 KMP 实现:

void build_pmt(string pattern, vector<int>& pmt) 
{
    int j = 0;
    pmt[0] = 0;
    for (int i = 1; i < pattern.size(); i++) 
    {
        while (j > 0 && pattern[i] != pattern[j]) 
        {
            j = pmt[j - 1];
        }
        if (pattern[i] == pattern[j]) j++;
        pmt[i] = j;
    }
}

int strStr(string haystack, string needle) 
{
    if (needle.empty()) return 0;
    if (needle.size() > haystack.size()) return -1;

    vector<int> pmt(needle.size(), 0);
    build_pmt(needle, pmt);

    int j = 0;
    for (int i = 0; i < haystack.size(); i++) 
    {
        while (j > 0 && haystack[i] != needle[j]) 
        {
            j = pmt[j - 1];
        }
        if (haystack[i] == needle[j]) j++;
        if (j == needle.size()) 
        {
            return i - needle.size() + 1;
        }
    }
    return -1;
}

补充:next表和PMT表

可能有些人之前看到的代码很多人写的是next数组并不是pmt,并且很多都是将第一个初始化为-1。这时候可能有人会疑惑,next和pmt有关系吗,有什么区别?

其实这并不涉及到KMP的原理,而只是工程代码的具体实现,将第一位初始化为-1其实就是前缀表的统一右移一位后,第一位补-1。

  • PMT(部分匹配表)
    • 定义:记录模式串每个前缀子串的最长公共前后缀长度(不包含自身)。
    • 示例:模式串ABABAC的PMT为[0,0,1,2,3,0],表示各位置的最长公共前后缀长度
    • 核心作用:通过已匹配的信息,避免主串指针回溯。
  • next数组
    • 定义:由PMT右移一位首位补-1得到,用于直接指示失配时模式串指针的跳转位置。
    • 示例:PMT[0,0,1,2,3,0]右移后得到next数组[-1,0,0,1,2,3]
    • 核心作用:简化代码逻辑,避免手动计算偏移量。

在右移之后,就不需要在进行类似于 j = ptm[j - 1],而 next[j] = ptm[j - 1],因此就有 j = next[j]。硬要说区别的话就是两者所表达的意义变了:

  • PMT:回答“当前已匹配的子串中,前后缀有多少字符是重复的?”
  • next数组:回答“失配时,模式串指针应跳转到哪个位置继续匹配?”

总的来说两者:

  • 本质相同:PMT和next数组的核心数据一致,均基于最长公共前后缀的复用思想。
  • 工程优化:next数组通过右移和补-1操作,简化了代码实现中的指针跳转逻辑,是PMT的工程化变体。

下面是用next表实现的代码:

#include <vector>
using namespace std;

void build_next(string pattern, vector<int>& next) 
{
    int n = pattern.size();
    next.resize(n);
    next[0] = -1;  // 传统 next 数组第一个位置为 -1
    int j = -1;    // 为了配合 next[0] = -1,j 初始化为 -1
    
    for (int i = 0; i < n; ) 
    {
        if (j == -1 || pattern[i] == pattern[j]) 
        {
            i++;
            j++;
            next[i] = j;  // next[i] 对应 pmt[i-1]
        } 
        else 
        {
            j = next[j];  // 利用已计算的 next 回溯
        }
    }
}

int strStr(string haystack, string needle) 
{
    if (needle.empty()) return 0;
    if (needle.size() > haystack.size()) return -1;

    vector<int> next;
    build_next(needle, next);

    int i = 0, j = 0;
    int n = haystack.size(), m = needle.size();
    
    while (i < n && j < m) 
    {
        if (j == -1 || haystack[i] == needle[j]) 
        {
            i++;
            j++;
        } 
        else 
        {
            j = next[j];  // 直接根据 next 跳转
        }
    }
    
    return (j == m) ? i - m : -1;
}

暴力法 vs KMP

让我们重新审视我们之前的例子,其中包含 haystack = "AAAAAAAAB"needle = "AAAB"

  • 暴力破解:28 次比较
  • KMP:仅需 9 次比较(多亏了前缀表)

当处理大量字符串时,性能差异变得更加明显。KMP 通过利用它在匹配过程中收集的信息,有效地减少了不必要的比较次数。以下是暴力法和KMP的性能对比,随着字符长度的增加,性能差异越来越大。

总结:KMP 是如何改变游戏规则的

KMP 通过减少冗余检查的数量,革新了我们对字符串匹配的方法。借助前缀表,KMP 可以智能地跳过已匹配的部分,使其比暴力方法显著更高效。下次你需要搜索子字符串时,记得 KMP 算法。这不仅仅是一种避免不必要工作的聪明方法——它还是向编写更简洁、更高效的代码迈出的一大步。

尝试在不同字符串搜索问题中实现 KMP 算法,并使用更大的数据集进行实验。你将看到 KMP 算法的实时好处,尤其是在处理文本处理或生物信息学等应用中的大量字符串时。
得更加明显。KMP 通过利用它在匹配过程中收集的信息,有效地减少了不必要的比较次数。以下是暴力法和KMP的性能对比,随着字符长度的增加,性能差异越来越大。

[外链图片转存中…(img-czSr32l5-1740039479058)]

总结:KMP 是如何改变游戏规则的

KMP 通过减少冗余检查的数量,革新了我们对字符串匹配的方法。借助前缀表,KMP 可以智能地跳过已匹配的部分,使其比暴力方法显著更高效。下次你需要搜索子字符串时,记得 KMP 算法。这不仅仅是一种避免不必要工作的聪明方法——它还是向编写更简洁、更高效的代码迈出的一大步。

尝试在不同字符串搜索问题中实现 KMP 算法,并使用更大的数据集进行实验。你将看到 KMP 算法的实时好处,尤其是在处理文本处理或生物信息学等应用中的大量字符串时。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Eliooooooo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值