KMP 算法详解:告别暴力匹配,让字符串匹配 “永不回头”

KMP 算法详解:告别暴力匹配,让字符串匹配 "永不回头"

你有没有遇到过这些场景?在编辑器里 Ctrl+F 搜索关键词、用 C 语言的strstr找子串、统计子串出现次数...... 这些我们习以为常的操作,底层都依赖一个核心能力:字符串匹配

而提到字符串匹配算法,绕不开的就是大名鼎鼎的 KMP 算法。很多同学初学 KMP 的时候,都会卡在「next 数组到底怎么算」「为什么最长相等前后缀是这个值」,甚至觉得它晦涩难懂,不如暴力匹配好理解。

这篇文章,我会从暴力匹配的痛点出发,一步步带你拆解 KMP 的核心逻辑,搞懂 next 数组的本质,最后解答大家最关心的问题:为什么我们常用的库函数,大多不用 KMP?


一、先搞懂:暴力匹配,到底哪里不好?

字符串匹配的需求很简单:给你一个主串S(比如aabaabaaf),和一个模式串T(比如aabaaf),要你找到TS中第一次出现的位置。

我们最容易想到的就是暴力匹配:用两个指针i(指向主串)和j(指向模式串),逐个字符比对:

  • 如果当前字符相等,i++j++,继续比对下一个;
  • 如果不相等,i回退到本次匹配起始位置的下一位,j重置为 0,从头开始匹配。

我们用S="aabaabaaf"T="aabaaf"举个例子:

当匹配到S[5] = 'b'T[5] = 'f'时,字符不相等,暴力匹配会把i回退到S[1]j重置为 0,重新开始匹配。

但这里有个很明显的浪费:我们已经知道前面S[0-4]T[0-4]是完全匹配的(都是aabaa),这些已经比对过的信息,完全没有被利用起来,反而要回头重新比对,这就是暴力匹配效率低的核心原因。


二、KMP 的核心智慧:不回头,利用已有信息

KMP 算法的核心,就是解决暴力匹配的痛点:主串指针i永不回退,只通过移动模式串的指针j,来完成匹配

那怎么知道模式串要移动多少呢?这就要靠 KMP 的核心概念:最长相等前后缀

先搞懂 3 个基础概念

  • 前缀 :不包含字符串最后一个字符的、从开头出发的所有子串。比如aab的前缀是aaa
  • 后缀 :不包含字符串第一个字符的、以结尾结束的所有子串。比如aab的后缀是bab
  • 最长相等前后缀:在所有前缀和后缀中,最长的、完全相等的子串,它的长度就是我们要的值。

举个大家初学最容易搞混的例子:aaba

  • 前缀:aaaaab
  • 后缀:abaaba
  • 相等的子串只有a,所以最长相等前后缀长度是 1,不是 2(后缀里没有aa,自然不能取 2)。

再看aabaa

  • 前缀:aaaaabaaba
  • 后缀:aaabaaabaa
  • 最长相等的子串是aa,所以长度是 2。

next 数组:把匹配规则存起来

KMP 里的next数组,本质就是把模式串每个位置的最长相等前后缀长度存起来。

比如模式串T="aabaaf",我们逐个位置计算,得到的next数组就是:

模式串子串 最长相等前后缀长度 next 数组对应值
a 0 next[0] = 0
aa 1 next[1] = 1
aab 0 next[2] = 0
aaba 1 next[3] = 1
aabaa 2 next[4] = 2
aabaaf 0 next[5] = 0

这个数组有什么用?当匹配失败时,我们不用把j重置为 0,只需要把j回退到next[j-1]的位置,就可以继续匹配,主串的i完全不用动。

还是用之前的例子:当S[5]T[5]匹配失败时,j=5,我们只需要把j回退到next[4]=2的位置,继续比对S[5]T[2]即可,完美利用了之前已经匹配的信息,没有任何重复劳动。


三、手把手实现:next 数组的生成代码

搞懂了原理,我们来写代码生成 next 数组,核心用双指针法:

  • 定义j:代表当前前缀的末尾,也代表当前最长相等前后缀的长度;

  • 定义i:代表当前后缀的末尾,遍历整个模式串;

  • 核心逻辑:

    1. 如果T[i] == T[j],说明前后缀匹配,j++i++,把j的值存入next[i]
    2. 如果T[i] != T[j],说明匹配失败,把j回退到next[j-1],直到j=0或者匹配成功;
    3. 如果j=0还不匹配,next[i]设为 0,i++继续遍历。

C 语言实现

c 复制代码
void getNext(int* next, const char* T) {
    int len = strlen(T);
    int j = 0; // 前缀末尾,最长相等前后缀长度
    next[0] = 0; // 第一个字符没有前后缀
    for (int i = 1; i < len; i++) {
        // 匹配失败,回退j
        while (j > 0 && T[i] != T[j]) {
            j = next[j-1];
        }
        // 匹配成功,j加1
        if (T[i] == T[j]) {
            j++;
        }
        // 把当前j存入next数组
        next[i] = j;
    }
}

TypeScript 实现

typescript 复制代码
/**
 * 生成 KMP 算法的 next 数组
 * @param pattern 模式串
 * @returns next 数组(存储每个位置的最长相等前后缀长度)
 */
function getNext(pattern: string): number[] {
    const n: number = pattern.length;
    const next: number[] = new Array(n).fill(0);
    let j: number = 0; // 前缀末尾指针,同时表示当前最长相等前后缀长度

    for (let i = 1; i < n; i++) {
        // 匹配失败,回退 j 到前一个位置的 next 值
        while (j > 0 && pattern[i] !== pattern[j]) {
            j = next[j - 1];
        }
        // 匹配成功,j 加 1
        if (pattern[i] === pattern[j]) {
            j++;
        }
        // 将当前 j 存入 next 数组
        next[i] = j;
    }

    return next;
}

四、完整的 KMP 匹配实现

有了 next 数组,我们就可以实现完整的 KMP 匹配了,逻辑和生成 next 数组很像:

  • 主串指针i遍历主串,永不回退;
  • 模式串指针j遍历模式串,匹配失败时用 next 数组回退;
  • j等于模式串长度时,说明匹配成功,返回起始位置即可。

C 语言实现(模拟 strstr 功能)

c 复制代码
#include <stdio.h>
#include <string.h>

void getNext(int* next, const char* T) {
    int len = strlen(T);
    int j = 0;
    next[0] = 0;
    for (int i = 1; i < len; i++) {
        while (j > 0 && T[i] != T[j]) {
            j = next[j-1];
        }
        if (T[i] == T[j]) {
            j++;
        }
        next[i] = j;
    }
}

int kmp_strstr(const char* S, const char* T) {
    int len_S = strlen(S);
    int len_T = strlen(T);
    if (len_T == 0) return 0; // 空串默认在0位置匹配
    int next[len_T];
    getNext(next, T);
    int j = 0;
    for (int i = 0; i < len_S; i++) {
        // 匹配失败,回退j
        while (j > 0 && S[i] != T[j]) {
            j = next[j-1];
        }
        // 匹配成功,j加1
        if (S[i] == T[j]) {
            j++;
        }
        // 匹配完成,返回起始位置
        if (j == len_T) {
            return i - len_T + 1;
        }
    }
    return -1; // 没有匹配到
}

int main() {
    const char* S = "aabaabaaf";
    const char* T = "aabaaf";
    printf("匹配起始位置:%d\n", kmp_strstr(S, T)); // 输出3
    return 0;
}

TypeScript 实现(模拟搜索与计数功能)

typescript 复制代码
/**
 * KMP 字符串搜索:找到模式串在主串中第一次出现的位置
 * @param mainStr 主串
 * @param pattern 模式串
 * @returns 起始索引(未找到返回 -1)
 */
function kmpSearch(mainStr: string, pattern: string): number {
    const lenMain: number = mainStr.length;
    const lenPattern: number = pattern.length;

    // 边界情况处理
    if (lenPattern === 0) return 0;
    if (lenPattern > lenMain) return -1;

    const next: number[] = getNext(pattern);
    let j: number = 0; // 模式串指针

    for (let i = 0; i < lenMain; i++) {
        // 匹配失败,回退 j
        while (j > 0 && mainStr[i] !== pattern[j]) {
            j = next[j - 1];
        }
        // 匹配成功,j 加 1
        if (mainStr[i] === pattern[j]) {
            j++;
        }
        // 匹配完成,返回起始位置
        if (j === lenPattern) {
            return i - lenPattern + 1;
        }
    }

    return -1; // 未找到匹配
}

/**
 * KMP 字符串计数:统计模式串在主串中出现的次数
 * @param mainStr 主串
 * @param pattern 模式串
 * @returns 出现次数
 */
function kmpCount(mainStr: string, pattern: string): number {
    const lenMain: number = mainStr.length;
    const lenPattern: number = pattern.length;

    // 边界情况处理(空串规则可根据需求调整)
    if (lenPattern === 0) return lenMain + 1;
    if (lenPattern > lenMain) return 0;

    const next: number[] = getNext(pattern);
    let count: number = 0;
    let j: number = 0; // 模式串指针

    for (let i = 0; i < lenMain; i++) {
        // 匹配失败,回退 j
        while (j > 0 && mainStr[i] !== pattern[j]) {
            j = next[j - 1];
        }
        // 匹配成功,j 加 1
        if (mainStr[i] === pattern[j]) {
            j++;
        }
        // 匹配到一次,计数 +1,继续找下一个
        if (j === lenPattern) {
            count++;
            j = next[j - 1]; // 回退 j 以寻找重叠匹配
        }
    }

    return count;
}

// 测试代码
const mainStr1: string = "aabaabaaf";
const pattern1: string = "aabaaf";
console.log("搜索起始位置:", kmpSearch(mainStr1, pattern1)); // 输出 3

const mainStr2: string = "aabaaabaa";
const pattern2: string = "aa";
console.log("子串出现次数:", kmpCount(mainStr2, pattern2)); // 输出 4

五、灵魂拷问:为什么库函数不用 KMP?

很多同学搞懂 KMP 之后都会有这个疑问:既然 KMP 效率更高,为什么 C 语言的strstr、很多语言的原生字符串方法都不用 KMP?

核心原因有 3 个:

  1. KMP 的优势场景很有限

    KMP 的优势是主串不回退,适合超长主串(比如几十 MB 的日志、DNA 序列)、模式串有大量重复前缀的场景。但日常开发中,我们处理的大多是短字符串,KMP 预处理 next 数组的开销,反而会抵消匹配的优势。

  2. 库函数有更优的选择

  3. 现代标准库的实现,大多不会用纯 KMP:

  • Glibc 的strstr用的是Two-Way 算法,比 KMP 更高效,且不需要额外的数组存储 next 值;
  • 很多语言的原生方法用的是朴素匹配 + 轻量优化,代码简单、常数时间更小,短字符串场景下实际运行速度比 KMP 更快。
  1. 实现复杂度的权衡

    KMP 的逻辑比朴素匹配复杂很多,对于标准库来说,"简单、稳定、易维护" 也是很重要的考量,在性能差距不大的情况下,自然会优先选择更简单的实现。


六、什么时候该用 KMP?

KMP 不是万能的,但在这些场景下,它的优势会完全体现出来:

  • 大文本搜索、海量日志分析,主串长度远大于模式串;
  • 网络流、实时数据流的匹配,主串无法回退重读;
  • 模式串有大量重复前缀,暴力匹配会出现大量无效回退的场景。

KMP 算法的本质,从来不是什么晦涩的公式,而是 **「不做重复劳动」的编程智慧 **。它通过挖掘已经匹配过的信息,避免了暴力匹配中无意义的回退,把匹配效率稳定在了 O (n+m) 的级别。

当然,我们也不用神化 KMP,日常开发中,绝大多数场景下,标准库自带的字符串匹配函数已经足够好用。学习 KMP 的意义,更多的是理解这种「利用已有信息优化流程」的算法思想,这种思想会在很多算法场景中帮到你。

人生处于顺境时能走多快不重要,重要的是遭遇挫折时,能够快速找回自己

------KMP

相关推荐
干啥啥不行,秃头第一名2 小时前
C++20概念(Concepts)入门指南
开发语言·c++·算法
zzh940772 小时前
Gemini 3.1 Pro 硬核推理优化剖析:思维织锦、动态计算与国内实测
算法
2301_807367192 小时前
C++中的解释器模式变体
开发语言·c++·算法
愣头不青2 小时前
617.合并二叉树
java·算法
always_TT3 小时前
C语言中的字符与字符串(char数组)
c语言·开发语言
MIUMIUKK3 小时前
双指针三大例题
算法
forAllforMe3 小时前
LAN9252 从机寄存器配置--C语言举例
c语言·开发语言
灵感__idea3 小时前
Hello 算法:复杂问题的应对策略
前端·javascript·算法
weixin_537590453 小时前
《C程序设计语言》练习答案(练习1-4)
c语言·开发语言