从暴力到KMP:一道题彻底搞懂字符串匹配的前世今生

力扣 28. 找出字符串中第一个匹配项的下标 ,是很多同学入门字符串匹配的第一道题。暴力解法 5 分钟写完,但当你看到 KMP 的代码时,却总觉得"每个字母都认识,连起来就看不懂了"。

今天我们不背模板,而是从暴力为什么慢 出发,一步步推导出 KMP 的精髓 ------ next 数组到底是什么、怎么算、怎么用。读完你会发现,KMP 不过如此。


一、先写个暴力,感受一下痛点

题目要求很简单:在 haystack 中找 needle 第一次出现的下标,找不到返回 -1。

暴力思路更简单:从 haystack 的每个位置起,依次比较 needle 的每个字符。只要有一个不匹配,就 break,从下一个位置重新开始。

ini 复制代码
var strStr = function(haystack, needle) {
    let len1 = haystack.length;
    let len2 = needle.length;
    let flag = true;
    for(let i = 0; i < len1; i++) {
        let temp = i;
        for(let j = 0; j < len2; j++) {
            if(haystack[temp] !== needle[j]) {
                flag = false;
                break;
            }
            temp++;
            flag = true;
        }
        if(flag) return i;
    }
    return -1;
};

复杂度分析 :最坏情况下,haystack = "aaaaab"needle = "aaab",每次匹配到最后一个字符才失败,然后 i 只前进一位重新比较。时间复杂度 O(n*m) ,当 n、m 达到 10⁴ 时,最坏 10⁸ 次比较,可能超时。

痛点在哪?

暴力匹配失败后,主串指针 i 回退,模式串指针 j 也回到 0,之前已经比较过的信息全部丢弃。就像你抄了一份很长的笔记,抄到一半发现抄错了一个字,于是撕掉整页从头抄 ------ 太浪费了!

KMP 的核心思想就是:匹配失败时,主串不回溯,模式串尽量少回溯。

而"尽量少回溯"究竟是多少,就由 next 数组 来告诉你。


二、KMP 的直觉:利用"已匹配"部分的信息

我们用一个经典例子来感受:

  • haystack = "ABABABC"
  • needle = "ABABC"

暴力匹配到第 5 个字符时失败:

arduino 复制代码
ABABABC
ABABC
    ↑ 这里 'A' vs 'C' 不匹配

此时前面已经匹配了 "ABAB"。如果我们能让 j 回退到某个位置,使得 needle 的前缀能和主串已匹配的后缀对齐,而不是直接回退到 0,就能省去大量比较。

观察 needle = "ABABC",已匹配部分 "ABAB"最长相等前后缀"AB"(长度 2)。

所以我们把 j 回退到 2(即指向第三个字符 'A'),主串 i 不动,继续比较:

arduino 复制代码
ABABABC
  ABABC
    ↑ 继续比较 'A' vs 'A'

你看,j 从 4 退到 2,而不是 0,主串 i 没有回退。这就是 KMP 的魔力。

nextj 的定义正是:当模式串第 j 位匹配失败时,j 应该回退到的位置 (或者说,needle[0..j-1] 的最长相等前后缀的长度)。


三、手撕 next 数组:标准前缀函数是怎么算出来的

next 数组的正式定义(以 0 为起始下标):

next[i] 表示 needle[0..i] 这个子串中,最长相等真前后缀的长度(真前后缀指不包含整个子串本身)。

比如 needle = "ABABC"

  • i=0, "A",无真前后缀 → next0 = 0
  • i=1, "AB",前缀 "A",后缀 "B",不等 → 0
  • i=2, "ABA",前缀 "A" 等于后缀 "A",长度 1 → 1
  • i=3, "ABAB",前缀 "AB" 等于后缀 "AB",长度 2 → 2
  • i=4, "ABABC",前缀 "A","AB","ABA" 与后缀 "C","BC","ABC" 无相等 → 0

所以 next = [0, 0, 1, 2, 0]

但我们在匹配过程中,是needle[j]haystack[i] 不匹配时 ,令 j = next[j-1]。换句话说,我们需要的是已匹配部分(长度为 j)的最长相等前后缀长度 ,即 next[j-1]

为了方便,很多实现里把 next 数组的定义微调为:next[i] 表示 needle[0..i-1] 的前后缀长度(即长度为 i 的前缀信息)。但上述代码使用的是标准递推计算 next[i] 表示 needle[0..i] 的前后缀长度,并在匹配失败时用 j = next[j-1]。这是完全等价的,我们接下来就逐行解析这种写法。

3.1 next 数组的递推计算(双指针法)

ini 复制代码
const next = new Array(len2).fill(0);
for (let i = 1, j = 0; i < len2; i++) {
    while (j > 0 && needle[j] !== needle[i]) {
        j = next[j - 1];
    }
    if (needle[j] === needle[i]) {
        j++;
        next[i] = j;
    }
}

这个循环在做什么?

  • 我们有两个指针:i 表示当前要计算的 next 值的位置(后缀末尾),j 表示当前已匹配的前缀长度(也是前缀末尾的下一个位置)。
  • 我们希望找到 needle[0..i] 的最长相等前后缀长度,利用已知的 next[0..i-1] 来递推。

具体过程(以 "ABABC" 为例):

  • i=1, j=0needle[0]='A' vs needle[1]='B',不等,不进 if,next1=0(保持 j=0)。
  • i=2, j=0'A' vs 'A',相等,j++ → 1,next2=1。
  • i=3, j=1needle[1]='B' vs needle[3]='B',相等,j++ → 2,next3=2。
  • i=4, j=2needle[2]='A' vs needle[4]='C',不等,进入 while:j>0 且 'A'!='C',j = next[j-1] = next[1] = 0。跳出 while,此时 j=0,needle[0]='A' vs 'C' 不等,next4=0。

关键难点:while 里的 j = next[j-1] 是啥意思?

当当前字符不匹配时,说明我们之前假设的"长度为 j 的前后缀相等"已经不成立了,于是我们退而求其次,寻找更短 的相等前后缀。而 next[j-1] 正好是 needle[0..j-1] 这个前缀的最长相等前后缀长度,用它来更新 j,继续比较。

这本质上是一个递归回退过程,和主串匹配失败时的回退逻辑如出一辙。

说白了,求 next 数组的过程,就是在模式串自己身上"自匹配",用的正是 KMP 匹配的同一套逻辑。


四、匹配过程:next 数组的实战应用

有了 next,匹配就变得非常简单:

ini 复制代码
for (let i = 0, j = 0; i < len1; i++) {
    while (j > 0 && haystack[i] !== needle[j]) {
        j = next[j - 1];
    }
    if (haystack[i] === needle[j]) {
        j++;
    }
    if (j === len2) {
        return i - len2 + 1;
    }
}
return -1;

这里 i 是主串指针,永远只向前移动;j 是模式串指针,失败时通过 next 回退。

匹配成功(j === len2)时,返回起始下标 i - len2 + 1

还是那个例子haystack="ABABABC", needle="ABABC", next=[0,0,1,2,0]

  • i=0, j=0: 'A'=='A' → j=1
  • i=1, j=1: 'B'=='B' → j=2
  • i=2, j=2: 'A'=='A' → j=3
  • i=3, j=3: 'B'=='B' → j=4
  • i=4, j=4: 'A' vs 'C' 不等 → while: j=next3=2,此时 j=2,haystack[4]='A' vs needle[2]='A' 相等 → j=3
  • i=5, j=3: haystack[5]='B' vs needle[3]='B' → j=4
  • i=6, j=4: haystack[6]='C' vs needle[4]='C' → j=5 → 匹配成功,返回 6-5+1=2

完全正确,且主串 i 从未回退。


五、完整代码与复杂度

ini 复制代码
var strStr = function(haystack, needle) {
    const n = haystack.length, m = needle.length;
    if (m === 0) return 0; // 防御性处理,虽然题目保证非空
    
    // 1. 求 next 数组
    const next = new Array(m).fill(0);
    for (let i = 1, j = 0; i < m; i++) {
        while (j > 0 && needle[j] !== needle[i]) {
            j = next[j - 1];
        }
        if (needle[j] === needle[i]) {
            j++;
            next[i] = j;
        }
    }
    
    // 2. 匹配
    for (let i = 0, j = 0; i < n; i++) {
        while (j > 0 && haystack[i] !== needle[j]) {
            j = next[j - 1];
        }
        if (haystack[i] === needle[j]) {
            j++;
        }
        if (j === m) {
            return i - m + 1;
        }
    }
    return -1;
};

时间复杂度 :O(n + m),两个循环中 while 的总执行次数是 O(m) 和 O(n) 级别的(因为 j 每次回退后都会再前进,但总回退次数不超过前进次数)。

空间复杂度:O(m) 用于存储 next 数组。


六、KMP 到底解决了什么问题?------ 几个金句帮你记

  1. 暴力匹配是"知错就改,改完从头再来";KMP 是"知错就改,改完继续前进"。

    主指针永不回溯,是 KMP 效率的根本保证。

  2. next 数组不是算法黑盒,而是"模式串自己的失败经验记录表"。

    它告诉我们:当模式串走到某个位置走不通时,可以退回到哪个"安全位置"继续走,而不必回到起点。

  3. 学 KMP 不要死记代码,要记住两个"匹配"

    • 先让模式串和自己匹配(求 next)
    • 再让主串和模式串匹配(用 next)

七、一个容易踩的坑:next 数组的"长度"与"下标"对应关系

很多初学者会把 next 数组搞混,这里用一个对比帮你理清:

实现方式 nexti 含义 匹配失败时回退
标准前缀函数(本文) needle[0..i] 的最长相等前后缀长度 j = next[j-1]
偏移版(常见于教材) next[i] 表示当 needle[i] 失配时,j 跳到的位置(即 next[i] = 标准next[i-1] j = next[j]

两种写法等价,但一定要保持一致。本文的写法更直观,因为 next[i] 直接对应子串本身的属性,理解起来更自然。


八、小结与拓展

KMP 算法虽然经典,但并不是银弹。当模式串较短或字符集较大时,暴力往往更快(因为常数小)。但在大量文本匹配、搜索引擎、编译原理的词法分析等场景,KMP 的思想(前缀函数)是不可或缺的基础。

真正理解 KMP 之后,你会发现它不只是解决一道题,而是教会你一种 "利用历史信息指导未来决策" 的思维方式。这种思维,在动态规划、缓存设计、甚至是写业务代码时都同样受用。

相关推荐
烬羽3 小时前
字符串算法入门:从反转字符串到回文判断,面试不再慌
算法·面试
先吃饱再说18 小时前
判断回文字符串,从一行代码到双指针优化
算法
黄敬峰21 小时前
深入理解算法核心:从递归思想、数组扁平化到快速排序
算法
得物技术1 天前
从狂野代码到按目标生产:得物推荐 AI Harness 的工程化实践|AICon 演讲整理
人工智能·算法·架构
AI小老六1 天前
SkillOpt 架构拆解:把 Skill 文本当参数,用执行轨迹训练 Agent
后端·算法·ai编程
胡萝卜术1 天前
从“分数打架”到“排名投票”:为什么你的ChatBI必须用RRF?
算法·设计模式·面试
Asize1 天前
初识DFS 与 BFS:递归、队列与图遍历
算法
罗西的思考2 天前
机器人 / 强化学习】HIL-SERL:人类在环驱动的具身智能进化框架
人工智能·算法·机器学习