力扣 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 = 0i=1, "AB",前缀 "A",后缀 "B",不等 → 0i=2, "ABA",前缀 "A" 等于后缀 "A",长度 1 → 1i=3, "ABAB",前缀 "AB" 等于后缀 "AB",长度 2 → 2i=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=0 :
needle[0]='A'vsneedle[1]='B',不等,不进 if,next1=0(保持 j=0)。 - i=2, j=0 :
'A'vs'A',相等,j++ → 1,next2=1。 - i=3, j=1 :
needle[1]='B'vsneedle[3]='B',相等,j++ → 2,next3=2。 - i=4, j=2 :
needle[2]='A'vsneedle[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'vsneedle[2]='A'相等 → j=3 - i=5, j=3:
haystack[5]='B'vsneedle[3]='B'→ j=4 - i=6, j=4:
haystack[6]='C'vsneedle[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 到底解决了什么问题?------ 几个金句帮你记
-
暴力匹配是"知错就改,改完从头再来";KMP 是"知错就改,改完继续前进"。
主指针永不回溯,是 KMP 效率的根本保证。
-
next 数组不是算法黑盒,而是"模式串自己的失败经验记录表"。
它告诉我们:当模式串走到某个位置走不通时,可以退回到哪个"安全位置"继续走,而不必回到起点。
-
学 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 之后,你会发现它不只是解决一道题,而是教会你一种 "利用历史信息指导未来决策" 的思维方式。这种思维,在动态规划、缓存设计、甚至是写业务代码时都同样受用。