

摘要: KMP算法是字符串匹配中的经典算法,通过利用匹配失败后的信息,避免主串指针的回溯,将时间复杂度从暴力匹配的O(n×m)优化到O(n+m)。本文从核心概念入手,详细讲解了前缀与后缀的定义、最长相等前后缀的计算方法,以及next数组的四种实现方式(经典版、统一减一版、nextval优化版、PMT版),并通过图解和代码示例展示构建过程。文章还包含LeetCode 28题的完整题解、多个生动比喻帮助理解,以及时间复杂度的详细分析。无论你是初学者还是准备面试,本文都能帮你彻底搞懂KMP算法的理论基础与代码实现。
KMP(Knuth-Morris-Pratt)算法是一种高效的字符串匹配算法,由Knuth、Morris和Pratt三位大神共同发明。它通过利用模式串自身的重复模式,避免不必要的回溯,将时间复杂度优化到O(m+n)。
1. 传统暴力匹配的问题 暴力匹配在匹配失败时,模式串会回溯到开头,主串回溯到起始位置的下一个字符,导致大量重复比较。 2. KMP的核心思想
前缀:从字符串开头开始,不包含最后一个字符的所有连续子串 后缀:从字符串结尾开始,不包含第一个字符的所有连续子串 真前缀/真后缀:不包含整个字符串本身
想象一串糖葫芦:A B A B C(5个山楂)
text
前缀(从左边吃):
- "A" (吃第1个)
- "AB" (吃第1-2个)
- "ABA" (吃第1-3个)
- "ABAB" (吃第1-4个)
- ❌ 不吃"ABABC"(那是整串)
后缀(从右边吃):
- "C" (吃最后1个)
- "BC" (吃最后2个)
- "ABC" (吃最后3个)
- "BABC" (吃最后4个)
- ❌ 不吃"ABABC"(那是整串)以字符串 "ABABA" 为例:
长度 | 前缀 | 后缀 | 是否相等 |
|---|---|---|---|
1 | "A" | "A" | ✅ 相等 |
2 | "AB" | "BA" | ❌ 不相等 |
3 | "ABA" | "ABA" | ✅ 相等 |
4 | "ABAB" | "BABA" | ❌ 不相等 |
最长相等前后缀长度 = 3("ABA")
对于模式串"ABABC":
next[i] 表示:模式串前 i+1 个字符组成的子串中,最长相等前后缀的长度
位置i | 子串 | 前缀集合 | 后缀集合 | 最长相等前后缀 | next[i] |
|---|---|---|---|---|---|
0 | "A" | {} | {} | 无 | 0 |
1 | "AB" | {A} | {B} | 无 | 0 |
2 | "ABA" | {A, AB} | {A, BA} | "A" (长度1) | 1 |
3 | "ABAB" | {A, AB, ABA} | {B, AB, BAB} | "AB" (长度2) | 2 |
4 | "ABABA" | {A, AB, ABA, ABAB} | {A, BA, ABA, BABA} | "ABA" (长度3) | 3 |
5 | "ABABAC" | {A, AB, ABA, ABAB, ABABA} | {C, AC, BAC, ABAC, BABAC} | 无 | 0 |
这是最常用的实现,next[i]直接存储最长相等前后缀长度。其实寻找的方法跟我们知道的辗转相除法原理有异曲同工之妙。可以抽空对比一下。
我们刚学的时候很不理解,我们要找的是最大长度相等前后缀,这里仅仅比较了某两位位置的字符是否相等,很不理解,其实,这里的门道全在next数组中,类似于多米诺骨牌,我们前面比较的结果已经被记录在案了,影响我们后面的比较,看似是两个字符的比较,实则已经是动态规划的必然结果了。
next[0]=0 (基础)
↓
next[1]=0 (通过比较s[0]和s[1])
↓
next[2]=1 (通过比较s[0]和s[2],利用next[1])
↓
next[3]=2 (通过比较s[1]和s[3],利用next[2])
↓
next[4]=3 (通过比较s[2]和s[4],利用next[3])
↓
next[5]=0 (通过比较,利用next[4]、next[2]、next[0])java
/**
* 经典版next数组
* next[i] = 子串s[0...i]的最长相等前后缀长度
*/
public static int[] getNext(String pattern) {
int m = pattern.length();
int[] next = new int[m];
int j = 0; // 前缀指针,也表示长度
next[0] = 0; // 第一个字符没有前后缀
for (int i = 1; i < m; i++) {
// 不匹配时回溯
while (j > 0 && pattern.charAt(j) != pattern.charAt(i)) {
j = next[j - 1];
}
// 匹配时前进
if (pattern.charAt(j) == pattern.charAt(i)) {
j++;
}
next[i] = j;
}
return next;
}java
String pattern = "ABABC";
int[] next = getNext(pattern);
// 结果: [0, 0, 1, 2, 0]这种实现在KMP原论文中使用,代码更简洁。
java
/**
* 减一版next数组
* next[i] = 最长相等前后缀长度 - 1
*/
public static int[] getNextMinusOne(String pattern) {
int m = pattern.length();
int[] next = new int[m];
int j = -1; // 前缀指针,从-1开始
int i = 0;
next[0] = -1; // 第一个字符设为-1
while (i < m - 1) {
if (j == -1 || pattern.charAt(j) == pattern.charAt(i)) {
i++;
j++;
next[i] = j;
} else {
j = next[j]; // 回溯
}
}
return next;
}java
String pattern = "ABABC";
int[] next = getNextMinusOne(pattern);
// 结果: [-1, 0, 0, 1, 2]
// 对应关系:经典版+1 = 减一版的值java
public static int kmpSearchMinusOne(String text, String pattern) {
int[] next = getNextMinusOne(pattern);
int n = text.length(), m = pattern.length();
int i = 0, j = 0;
while (i < n && j < m) {
if (j == -1 || text.charAt(i) == pattern.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == m) {
return i - j;
}
return -1;
}解决模式串有大量重复字符时的效率问题。
java
/**
* 优化版next数组(nextval)
* 避免在重复字符上多次回溯
*/
public static int[] getNextVal(String pattern) {
int m = pattern.length();
int[] nextval = new int[m];
int j = 0;
nextval[0] = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && pattern.charAt(j) != pattern.charAt(i)) {
j = nextval[j - 1];
}
if (pattern.charAt(j) == pattern.charAt(i)) {
j++;
}
// 优化:如果下一个字符相同,继续回溯
if (i + 1 < m && pattern.charAt(i + 1) == pattern.charAt(j)) {
nextval[i] = nextval[j - 1];
} else {
nextval[i] = j;
}
}
return nextval;
}java
// 模式串 "AAAAA"
String pattern = "AAAAA";
// 经典版
int[] next1 = getNext(pattern); // [0, 1, 2, 3, 4]
// 优化版
int[] nextval = getNextVal(pattern); // [0, 0, 0, 0, 0]
// 优化版避免了重复比较,效率更高直接体现"部分匹配表"的概念。
java
/**
* PMT (Partial Match Table) 版本
* 与经典版相同,但命名更学术
*/
public static int[] getPMT(String pattern) {
int m = pattern.length();
int[] pmt = new int[m];
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = pmt[j - 1];
}
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
pmt[i] = j;
}
return pmt;
}以 "ABABAC" 为例,详细跟踪:
text
初始: pattern = "A B A B A C"
索引: 0 1 2 3 4 5
next: 0 0 0 0 0 0
i=1, j=0: pattern[1]='B' vs pattern[0]='A' → 不匹配
j=0, next[1]=0
i=2, j=0: pattern[2]='A' vs pattern[0]='A' → 匹配!
j=1, next[2]=1
i=3, j=1: pattern[3]='B' vs pattern[1]='B' → 匹配!
j=2, next[3]=2
i=4, j=2: pattern[4]='A' vs pattern[2]='A' → 匹配!
j=3, next[4]=3
i=5, j=3: pattern[5]='C' vs pattern[3]='B' → 不匹配
j=next[2]=1 (回溯)
pattern[5]='C' vs pattern[1]='B' → 不匹配
j=next[0]=0
pattern[5]='C' vs pattern[0]='A' → 不匹配
j=0, next[5]=0
结果: next = [0, 0, 1, 2, 3, 0]版本 | next[0] | 特点 | 适用场景 |
|---|---|---|---|
经典版本 | 0 | 直观易懂,最常用 | 一般场景 |
-1偏移版 | -1 | 代码简洁,判断方便 | 教材、竞赛 |
nextval | 0 | 优化重复字符 | 特殊模式串 |
PMT | 0 | 原始定义 | 学术理解 |
时间复杂度分析
KMP算法的精髓在于:
KMP算法适用于需要多次匹配或模式串较长、主串很大的场景,但对于一般场景,语言一些内置的find()方法(基于Boyer-Moore和Horspool等优化算法)通常更快。
以上就是KMO算法的理论基础,对于不了解的同学听起来肯定很晦涩难懂。下面我们来形象的理解一下:
想象你在查字典找单词 "APPLE":
暴力方法(笨办法):
KMP方法(聪明办法):
假设你要从A点到B点,路线是:直行→右转→直行→左转→直行
暴力匹配(迷路后回到起点):
text
你在路上开,到了第4个路口发现走错了
然后掉头开回起点,重新开始找路
每次都这样,太浪费时间!KMP(记住经验):
text
你在第4个路口发现错了,但你知道:
"前面3个路口(直行→右转→直行)是对的"
下次你就直接从这个经验点开始,不用回到起点给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1 。
示例 1:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。示例 2:
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。提示:
1 <= haystack.length, needle.length <= 104haystack 和 needle 仅由小写英文字符组成这道题是KMP算法的经典应用,直接套用KMP匹配过程即可。
关键点:
needle 的 next 数组
关于这里的回溯,我们可能并不是很能理解,我们不知道到底会移到哪里,然后就是关于循环中的i和j分别代表什么和初值处理
首先:
strStr方法中的i和j:
变量 | 全称 | 含义 | 指向 |
|---|---|---|---|
i | index of text | 主串(haystack)的当前位置 | 正在比较的主串字符 |
j | index of pattern | 模式串(needle)的当前位置 | 正在比较的模式串字符 |
然后就是getNext方法中的i和j:
这里的i是从1开始的,因为如果i=0,此时j也等于0,没法比较,我们这里的目的是找到字符串的最长相等前后缀。
方法 | i的含义 | i初值 | 原因 |
|---|---|---|---|
getNext | 后缀末尾指针 | i=1 | 至少需要2个字符才能比较前后缀 |
strStr | 主串指针 | i=0 | 从主串第一个字符开始匹配 |
然后就是关于回溯的过程,并不是只有一次,所以我们不能用if,必须要用while
needle: A B A B A C
索引: 0 1 2 3 4 5
next: [0, 0, 1, 2, 3, 0]
主串某个字符: X (假设不等于 'C', 'B', 'A')
回溯路径(while):
j=5 → next[4]=3 → 检查needle[3]='B' ≠ X
→ next[2]=1 → 检查needle[1]='B' ≠ X
→ next[0]=0 → 检查needle[0]='A' ≠ X
→ 最终 j=0
用if只能走一步:
j=5 → next[4]=3 → 停止!j=3
但needle[3]='B' ≠ X,错误!应该继续回溯到0回溯位置的确定:新位置 = 最长相等前后缀的长度
简单的说:跳过的就是最长相等前后缀中的"前缀部分"。因为我们的next数组已经找到了最长相等的前后缀
已匹配: [0...k-1] ... [L-k...L-1]
└─前缀─┘ └─后缀─┘
相等
后缀已经和主串比较过(肯定匹配)
所以前缀也一定和主串的对应部分匹配
因此不需要重新比较前缀部分
直接从前缀的下一个位置开始为什么不能回溯到后缀呢,其实也很简单:
next数组就写完了,之后就是比对主串的方法
变量 | 含义 | 初始值 | 作用 |
|---|---|---|---|
i | 主串指针 | 0 | 指向主串当前要比较的字符 |
j | 模式串指针 | 0 | 指向模式串当前要比较的字符 |
next | next数组 | 预先计算好 | 记录回溯位置 |
主串指针i从0开始,逐个遍历主串的每个字符, i只增不减,永不回溯 ,然后不匹配的时候就执行上面的回溯逻辑 ,之后就是常规操作了。
我们刚学的时候很不理解,我们要找的是最大长度相等前后缀
这里仅仅比较了某两位位置的字符是否相等,很不理解
其实,这里的门道全在next数组中,类似于多米诺骨牌,我们前面比较的结果已经被记录在案了,影响我们后面的比较,看似是两个字符的比较,实则已经是动态规划的必然结果了。
next[0]=0 (基础)
↓
next[1]=0 (通过比较s[0]和s[1])
↓
next[2]=1 (通过比较s[0]和s[2],利用next[1])
↓
next[3]=2 (通过比较s[1]和s[3],利用next[2])
↓
next[4]=3 (通过比较s[2]和s[4],利用next[3])
↓
next[5]=0 (通过比较,利用next[4]、next[2]、next[0])表面只比较两个字符,但next数组已经"记住"了之前所有字符的相等关系,所以一个字符的比较就能推断出整个前后缀的相等性!这是动态规划的思想,用小问题的解构建大问题的解
class Solution {
//前缀表(不减一)Java实现
public int strStr(String haystack, String needle) {
if (needle.length() == 0) return 0;
int[] next = new int[needle.length()];
getNext(next, needle);
int j = 0;
for (int i = 0; i < haystack.length(); i++) {
while (j > 0 && needle.charAt(j) != haystack.charAt(i))
j = next[j - 1];
if (needle.charAt(j) == haystack.charAt(i))
j++;
if (j == needle.length())
return i - needle.length() + 1;
}
return -1;
}
private void getNext(int[] next, String s) {
int j = 0;
next[0] = 0;
for (int i = 1; i < s.length(); i++) {
while (j > 0 && s.charAt(j) != s.charAt(i))
j = next[j - 1];
if (s.charAt(j) == s.charAt(i))
j++;
next[i] = j;
}
}
}结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!
