前言
KMP是字符串匹配的经典算法,很多人觉得它很难,特别是next数组的构建。其实KMP的本质就是:不要傻乎乎地从头开始匹配,利用已经匹配的信息。
我并没有能力让你看完就精通所有字符串算法,我只是想让你理解KMP的核心思想、next数组的含义、以及为什么它能把暴力的O(n×m)优化到O(n+m)。
摘要
从"字符串匹配暴力超时"问题出发,剖析KMP算法的核心思想与next数组构建。通过暴力匹配的重复计算分析、最长公共前后缀的图解演示、以及KMP匹配过程的详细推导,揭秘为什么KMP能避免回退。配合LeetCode高频题目与完整代码,给出KMP算法的实现套路。
一、从暴力匹配说起
周四早上,哈吉米遇到一道题:
LeetCode 28 - 找出字符串中第一个匹配项的下标
给你两个字符串 haystack 和 needle ,
请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。
如果 needle 不是 haystack 的一部分,则返回 -1 。
示例:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。第一个匹配项的下标是 0 。
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。
哈吉米的暴力代码:
Java版本:
public int strStr(String haystack, String needle) {
int n = haystack.length();
int m = needle.length();
// 枚举起点
for (int i = 0; i <= n - m; i++) {
boolean match = true;
// 从起点i开始匹配
for (int j = 0; j < m; j++) {
if (haystack.charAt(i + j) != needle.charAt(j)) {
match = false;
break;
}
}
if (match) {
return i;
}
}
return -1;
}
南北绿豆走过来:"数据量多大?"
哈吉米:"haystack最多10万,needle最多1万,O(n×m)=10亿次,可能超时。"
南北绿豆:"这是经典的字符串匹配问题,KMP算法秒了。"
二、暴力匹配的问题
阿西噶阿西画了个图:
示例:haystack = "aaaaaab", needle = "aaab"
暴力匹配过程:
第1次匹配:
haystack: a a a a a a b
needle: a a a b
✓ ✓ ✓ ✗(第4个字符不匹配)
第2次匹配:
haystack: a a a a a a b
needle: a a a b
✓ ✓ ✓ ✗
第3次匹配:
haystack: a a a a a a b
needle: a a a b
✓ ✓ ✓ ✓(匹配成功)
问题在哪?
南北绿豆:"第1次匹配失败后,needle直接回退到起点,浪费了已经匹配的信息。"
已经知道:haystack[0..2]='aaa',needle[0..2]='aaa'
失败原因:haystack[3]='a',needle[3]='b'
关键:haystack[1..3]='aaa',needle[0..2]='aaa'
→ haystack[1..3]的前3个字符和needle的前3个字符相同
→ 不需要从haystack[1]重新匹配,可以直接从needle[3]继续
哈吉米:"所以可以利用已经匹配的部分?"
阿西噶阿西:"对,这就是KMP的核心思想。"
三、KMP的核心思想
南北绿豆:"KMP的关键:当匹配失败时,needle不从头开始,而是跳到某个位置继续匹配。"
3.1 生活化场景
场景:你在读一本书,要找"algorithm"这个单词。
暴力方法:
从第1页开始,一个个字母匹配
a-l-g-o-r-i... 不对,不是这个词
从第2页重新开始
a-l-g... 又不对
效率太低
KMP方法:
匹配到"algo"时发现不对
但你记得"algo"的前缀"al"和后缀"al"不匹配...
(这个例子不太好)
换个例子:匹配"ababc"
已匹配:"abab"
失败位置:'c'
观察:needle="ababc"
前缀"ab"和后缀"ab"相同
所以:不用从头开始,直接从前缀"ab"后面继续
哈吉米:"有点绕..."
南北绿豆:"举个更清楚的例子。"
四、最长公共前后缀(next数组)
南北绿豆:"KMP的核心是next数组,记录每个位置的最长公共前后缀。"
4.1 什么是公共前后缀
定义:
- 前缀:不包含最后一个字符的所有头部子串
- 后缀:不包含第一个字符的所有尾部子串
- 公共前后缀:既是前缀又是后缀的子串
示例:str = "ababa"
前缀:a, ab, aba, abab
后缀:a, ba, aba, baba
公共前后缀:a, aba
最长公共前后缀:aba(长度3)
阿西噶阿西:"next[i]就是记录needle[0..i]的最长公共前后缀的长度。"
4.2 next数组示例
needle = "ababc"
| i | needle[0..i] | 前缀 | 后缀 | 公共 | next[i] |
|---|---|---|---|---|---|
| 0 | a | - | - | - | 0 |
| 1 | ab | a | b | 无 | 0 |
| 2 | aba | a, ab | a, ba | a | 1 |
| 3 | abab | a, ab, aba | b, ab, bab | ab | 2 |
| 4 | ababc | a, ab, aba, abab | c, bc, abc, babc | 无 | 0 |
next数组:[0, 0, 1, 2, 0]
为什么需要next数组?
南北绿豆:"当匹配失败时,next[j]告诉我们needle应该跳到哪个位置继续匹配。"
匹配到needle[4]='c'时失败
前面已经匹配了needle[0..3]='abab'
next[3]=2,说明'abab'的前2个字符='ab'和后2个字符='ab'相同
所以:haystack中刚匹配失败的位置前面2个字符是'ab'
→ 等于needle的前2个字符'ab'
→ needle可以跳到位置2继续匹配(跳过前面的'ab')
五、next数组的构建
阿西噶阿西:"next数组的构建是KMP最难的部分。"
5.1 构建思路
核心:next数组的构建过程,本身就是一个KMP匹配过程(自己匹配自己)。
双指针:
- i:当前位置
- j:前缀末尾位置(也是next[i-1])
5.2 构建过程演示
示例:needle = "ababc"
| i | j | needle[i] | needle[j] | 操作 | next[i] |
|---|---|---|---|---|---|
| 0 | 0 | - | - | 初始化 | 0 |
| 1 | 0 | b | a | 不匹配,j=0 | 0 |
| 2 | 0 | a | a | 匹配,j++ | 1 |
| 3 | 1 | b | b | 匹配,j++ | 2 |
| 4 | 2 | c | a | 不匹配,j=next[j-1]=0 | - |
| 4 | 0 | c | a | 不匹配,j=0 | 0 |
最终next数组:[0, 0, 1, 2, 0]
图示:
flowchart TB
A["比较needle[i]和needle[j]"]
B["相等?"]
C["j++<br/>next[i]=j"]
D["j==0?"]
E["next[i]=0"]
F["j=next[j-1]<br/>继续比较"]
A --> B
B -->|是| C
B -->|否| D
D -->|是| E
D -->|否| F
F --> A
style C fill:#e1ffe1
style E fill:#fff4e1
5.3 构建next数组代码
Java版本:
private int[] buildNext(String needle) {
int m = needle.length();
int[] next = new int[m];
next[0] = 0; // 第一个字符没有前后缀
int j = 0; // 前缀末尾位置
for (int i = 1; i < m; i++) {
// 如果不匹配,j回退
while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
// 如果匹配,j前进
if (needle.charAt(i) == needle.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
C++版本:
vector<int> buildNext(string needle) {
int m = needle.size();
vector<int> next(m, 0);
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && needle[i] != needle[j]) {
j = next[j - 1];
}
if (needle[i] == needle[j]) {
j++;
}
next[i] = j;
}
return next;
}
Python版本:
def buildNext(needle):
m = len(needle)
next = [0] * m
j = 0
for i in range(1, m):
while j > 0 and needle[i] != needle[j]:
j = next[j - 1]
if needle[i] == needle[j]:
j += 1
next[i] = j
return next
六、KMP匹配过程
南北绿豆:"有了next数组,匹配过程就简单了。"
6.1 匹配过程演示
示例:haystack = "ababcaababc", needle = "ababc"
next数组:[0, 0, 1, 2, 0]
匹配过程:
| i | j | haystack[i] | needle[j] | 操作 | 说明 |
|---|---|---|---|---|---|
| 0 | 0 | a | a | 匹配,i++, j++ | - |
| 1 | 1 | b | b | 匹配,i++, j++ | - |
| 2 | 2 | a | a | 匹配,i++, j++ | - |
| 3 | 3 | b | b | 匹配,i++, j++ | - |
| 4 | 4 | c | c | 匹配,i++, j++ | 找到! |
返回:i - j = 4 - 5 = -1?不对,应该是i - needle.length() = 0
重新演示(更复杂的例子):
haystack = "aabaacaab", needle = "aabaac"
next数组:[0, 1, 0, 1, 2, 0]
| i(haystack) | j(needle) | 字符匹配 | 操作 | 说明 |
|---|---|---|---|---|
| 0 | 0 | a==a | 匹配 | i++, j++ |
| 1 | 1 | a==a | 匹配 | i++, j++ |
| 2 | 2 | b==b | 匹配 | i++, j++ |
| 3 | 3 | a==a | 匹配 | i++, j++ |
| 4 | 4 | a==a | 匹配 | i++, j++ |
| 5 | 5 | c==c | 匹配 | j==m,找到! |
返回:起始位置 = i - m = 6 - 6 = 0
哈吉米:"这个例子一次就匹配成功了,看不出KMP的优势啊。"
南北绿豆:"换个会失败的例子。"
七、KMP的威力:失配时的跳转
示例:haystack = "aabaabaac", needle = "aabaac"
next数组:[0, 1, 0, 1, 2, 0]
匹配过程:
| i | j | haystack[i] | needle[j] | 操作 | 说明 |
|---|---|---|---|---|---|
| 0 | 0 | a | a | 匹配 | i++, j++ |
| 1 | 1 | a | a | 匹配 | i++, j++ |
| 2 | 2 | b | b | 匹配 | i++, j++ |
| 3 | 3 | a | a | 匹配 | i++, j++ |
| 4 | 4 | a | a | 匹配 | i++, j++ |
| 5 | 5 | b | c | 不匹配 | j=next[j-1]=next[4]=2 |
| 5 | 2 | b | b | 匹配 | i++, j++ |
| 6 | 3 | a | a | 匹配 | i++, j++ |
| 7 | 4 | a | a | 匹配 | i++, j++ |
| 8 | 5 | c | c | 匹配 | 找到! |
关键步骤解析:
失配位置:i=5, j=5
haystack[5]='b', needle[5]='c'
暴力做法:
needle回到起点,从haystack[1]重新匹配
KMP做法:
j跳到next[4]=2
为什么跳到2?
因为已匹配的部分needle[0..4]='aabaa'
next[4]=2,说明前2个字符='aa'和后2个字符='aa'相同
所以haystack[3..4]='aa' = needle[0..1]='aa'
不需要重新匹配,直接从needle[2]继续
对比图示:
flowchart TB
subgraph 暴力匹配
A1["失配后<br/>needle回到起点"]
B1["i回退<br/>浪费已匹配信息"]
end
subgraph KMP匹配
A2["失配后<br/>j=next[j-1]"]
B2["i不回退<br/>利用已匹配信息"]
end
style A1 fill:#ffe6e6
style A2 fill:#e1ffe1
哈吉米:"KMP的i指针不回退,只有j指针跳转,所以快!"
八、KMP完整代码
8.1 完整实现
Java版本:
public int strStr(String haystack, String needle) {
if (needle.length() == 0) return 0;
int n = haystack.length();
int m = needle.length();
// 构建next数组
int[] next = buildNext(needle);
int j = 0; // needle的指针
for (int i = 0; i < n; i++) {
// 失配时,j跳转
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
// 匹配,j前进
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
// 匹配成功
if (j == m) {
return i - m + 1;
}
}
return -1;
}
private int[] buildNext(String needle) {
int m = needle.length();
int[] next = new int[m];
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
j = next[j - 1];
}
if (needle.charAt(i) == needle.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
C++版本:
int strStr(string haystack, string needle) {
if (needle.empty()) return 0;
int n = haystack.size();
int m = needle.size();
vector<int> next = buildNext(needle);
int j = 0;
for (int i = 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;
}
vector<int> buildNext(string needle) {
int m = needle.size();
vector<int> next(m, 0);
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && needle[i] != needle[j]) {
j = next[j - 1];
}
if (needle[i] == needle[j]) {
j++;
}
next[i] = j;
}
return next;
}
Python版本:
def strStr(haystack, needle):
if not needle:
return 0
n, m = len(haystack), len(needle)
# 构建next数组
next_arr = buildNext(needle)
j = 0
for i in range(n):
while j > 0 and haystack[i] != needle[j]:
j = next_arr[j - 1]
if haystack[i] == needle[j]:
j += 1
if j == m:
return i - m + 1
return -1
def buildNext(needle):
m = len(needle)
next_arr = [0] * m
j = 0
for i in range(1, m):
while j > 0 and needle[i] != needle[j]:
j = next_arr[j - 1]
if needle[i] == needle[j]:
j += 1
next_arr[i] = j
return next_arr
时间复杂度:O(n+m)
- 构建next数组:O(m)
- 匹配过程:O(n)
九、例题2:重复的子字符串
9.1 题目
LeetCode 459 - 重复的子字符串
给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。
示例:
输入:s = "abab"
输出:true
解释:可由子串 "ab" 重复两次构成。
输入:s = "aba"
输出:false
输入:s = "abcabcabcabc"
输出:true
解释:可由子串 "abc" 重复四次构成。
9.2 思路分析
南北绿豆:"这题可以用KMP的next数组巧妙解决。"
关键性质:如果字符串s由子串重复构成,那么s的长度-next[n-1]就是最小重复子串的长度。
示例:s = "abab"
next数组:[0, 0, 1, 2]
最长公共前后缀长度:next[3] = 2
→ 前缀"ab"和后缀"ab"相同
s长度 - next[3] = 4 - 2 = 2
→ 最小重复单元长度是2
如果4能被2整除,说明可以重复构成 ✓
9.3 代码实现
Java版本:
public boolean repeatedSubstringPattern(String s) {
int n = s.length();
int[] next = buildNext(s);
// 最长公共前后缀长度
int len = next[n - 1];
// len > 0 说明有公共前后缀
// n % (n - len) == 0 说明能整除
return len > 0 && n % (n - len) == 0;
}
C++版本:
bool repeatedSubstringPattern(string s) {
int n = s.size();
vector<int> next = buildNext(s);
int len = next[n - 1];
return len > 0 && n % (n - len) == 0;
}
Python版本:
def repeatedSubstringPattern(s):
n = len(s)
next_arr = buildNext(s)
length = next_arr[n - 1]
return length > 0 and n % (n - length) == 0
十、KMP总结
10.1 核心要点
南北绿豆总结:
- 核心思想:利用已匹配信息,避免重复匹配
- next数组:记录最长公共前后缀长度
- 时间复杂度:O(n+m),i指针不回退
- 关键:失配时j跳到next[j-1]
10.2 KMP vs 暴力
| 对比项 | 暴力匹配 | KMP |
|---|---|---|
| i指针 | 会回退 | 不回退 |
| j指针 | 失配后回到0 | 失配后跳到next[j-1] |
| 时间复杂度 | O(n×m) | O(n+m) |
| 空间复杂度 | O(1) | O(m) |
10.3 识别技巧
阿西噶阿西:
- 看到字符串匹配、查找子串,想KMP
- 看到重复模式、周期性,想KMP的next数组
- 看到大数据量字符串匹配,想KMP
10.4 为什么next数组这么构建
南北绿豆:"很多人问:为什么j = next[j-1]?"
解释:
当needle[i] != needle[j]时:
说明needle[0..i-1]中,
前j个字符 != 后j个字符(从i-j+1到i)
但我们知道:
needle[0..j-1]的最长公共前后缀长度是next[j-1]
所以:
可以跳到next[j-1]位置继续比较
相当于用更短的前缀去匹配
哈吉米:"这部分确实难理解,需要多想想。"
参考资料:
- 《算法导论》- Thomas H. Cormen
- 《算法竞赛进阶指南》- 李煜东
- Donald Knuth - KMP算法论文