KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)。

虽然 KMP 是基础算法,但是却有着一定的难度,往往理解其算法思路后也难以用代码具体实现。在初学算法的道路上,算是一块难啃的骨头。于是,我决定花些时间好好研究一下这个神奇的算法。

我们提前约定:
1. S(STR) 代表“主串”;
2. P(PATTERN) 代表“子串”;
3. NextVal 为 Next 数组修正后的数组;
4. 本篇文章从程序的实际角度出发,下标统一从 0 开始。

同时,我将声明:本文为了便于理解,将 KMP 算法分成模块进行讲解。

一、KMP 算法基本理论

1.1 KMP 算法概述

相较于暴力破解的 BF 算法KMP 算法的优点在于对主串的遍历不会回溯

所以,当我们给予一个长度为 N 的主串时,使用 KMP 算法的时间复杂度即为 O(N)。

之所以能做到这一点,全靠 Next 数组记录了当主串字符与子串字符不匹配时(s[i] != p[j]),子串索引(j)可以直接跳转的位置(j = next[j])。然后,继续比较即可,直至匹配成功或者匹配失败。

可以通过下面的动画理解⬇⬇:

1. 这个动画中 next[] = {-1, 0, 0, 1}
图1.1.1 动画演示 1

2. 这个动画中 next[] = {-1, 0, 0, 0, 1, 2, 3}
图1.1.2 动画演示 2

当然,这么讲肯定很晦涩难懂,特别对于第一次接触的朋友。所以,我建议先去看看视频动画演示的 KMP 算法,再来看这篇文章。

推荐视频讲解:KMP 算法工作原理——bilibili

1.2 代码实现 KMP 算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int KMP(char* s, char* p)
{
int pos = -1; // 用于返回值,查找成功返回子串第一次在主串中出现时第一个字符的下标

int s_len = strlen(s), // 主串长度
p_len = strlen(p); // 子串长度

int i = 0, // 控制主串的索引
j = 0; // 控制子串的索引

int* next = (int*)malloc(p_len * sizeof(int)); // 用 malloc 声明一个一维 next 数组

if (next)
{
getNext(p, next); // 调用 getNext 函数,初始化 next 数组

while (i < s_len)
{
if (j == -1 || s[i] == p[j]) // 如果“主串字符与子串字符相同”或“子串已返回到第一个字符(j = next[0] = -1)”
{
i++;
j++;
}
else // 主串字符与子串字符不相同
{
j = next[j];
}

if (j >= p_len) // 子串遍历完成,已经全部匹配
{
pos = i - j; // 获取子串第一次在主串中出现时第一个字符的下标
break; // 跳出循环
}
}

free(next); // 释放 next 数组的内存空间
}

return pos;
}

二、Next 数组的基本理论

2.1 Next 数组生成概述

2.1.1 算法一

next 数组的生成主要有两种办法,我个人认为通过“最大共同元素长度”生成的方式没有通过“前一个字符的 next 值”生成的方式好。所以,我主要讲解第二种算法,第一种大家可以去看别的文章。

推荐文章:kmp算法 next[]数组的两种求法

下面着重介绍第二种算法(通过“前一个字符的 next 值”生成):

  1. 首先可以确定的是 next[0] 一定是 -1,那么 k 的初始值一定是等于 next[0] 的(也就是 -1)。那么,j 的初始值也便可以为 1(因为 next[0] = -1 已经确定,只需要从 next[1] 开始确定 next 数组即可)。

  2. 使 k = next[j - 1], 判断 p[j - 1] =?= p[k]

    • 如果 p[j - 1] == p[k],那么就使 next[j] = next[j - 1] + 1 = k + 1;
    • 如果 p[j - 1] != p[k],那么就使 k = next[next[j - 1]] = next[k],然后再次判断 p[j - 1] =?= p[k]
      • 如果 p[j - 1] == p[k],那么就使 next[j] = next[next[j - 1]] + 1 = k + 1;
      • 如果 p[j - 1] != p[k],那么就使 k = next[next[next[j - 1]]] = next[k],然后再次判断 p[j - 1] =?= p[k]
        • 直到判断到 p[j - 1] == p[k] 或者 k == -1(此时 p[k] = p[-1] 已经不存在,无法比较)!

这样讲可能还是有些抽象,那么举个简单的例子:

  1. 我们使 char* p = {"ababaaa"},(j = 0)然后使 next[0] = -1
0123456
ababaaa
-1
  1. j = 1)那么因为 k = next[j - 1] = next[0] = -1 ,因为 p[k] = p[-1] 不存在,所以 next[j] = next[1] = k + 1 = 0
0123456
ababaaa
-10
  1. j = 2)接着,k = next[j - 1] = next[1] = 0,判断 p[j - 1] = p[1] = 'b'p[k] = p[0] = 'a' 不相同。所以,k = next[next[j - 1]] = next[k] = -1,因为 p[k] = p[-1] 不存在,所以 next[j] = next[2] = k + 1 = 0
0123456
ababaaa
-100
  1. j = 3)然后,k = next[j - 1] = next[2] = 0,判断 p[j - 1] = p[2] = 'a'p[k] = p[0] = 'a' 相等。所以,next[j] = next[3] = k + 1 = 1
0123456
ababaaa
-1001
  1. j = 4)接着,k = next[j - 1] = next[3] = 1,判断 p[j - 1] = p[3] = 'b'p[k] = p[1] = 'b' 相等。所以,next[j] = next[4] = k + 1 = 2
0123456
ababaaa
-10012
  1. j = 5)接着,k = next[j - 1] = next[4] = 2,判断 p[j - 1] = p[4] = 'a'p[k] = p[2] = 'a' 相等。所以,next[j] = next[4] = k + 1 = 3
0123456
ababaaa
-100123
  1. j = 6)接着,k = next[j - 1] = next[5] = 3,判断 p[j - 1] = p[5] = 'a'p[k] = p[3] = 'b' 不相等。所以,k = next[next[j - 1]] = next[k] = next[3] = 1,继续判断 p[j - 1] = p[5] = 'a'p[k] = p[1] = 'b' 不相等。所以,k = next[next[j - 1]] = next[k] = next[1] = 0,继续判断 p[j - 1] = p[5] = 'a'p[k] = p[0] = 'a' 相等。所以,next[j] = next[4] = k + 1 = 1
0123456
ababaaa
-1001231

补充说明:next[1] 也必定等于 1,那为什么 j 不从 2 开始呢?
很简单,万一子串只有一个字符怎么办?
懂了吧!

2.1.2 算法二

略略略…

2.2 代码实现 Next 数组生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void getNext(char* p, int* next)
{
int k = -1, // 初始化 k 值,以后这个值在循环初始时必须保证和 next[j - 1] 一致
j = 1, // 因为 next[0] 一定为 -1 ,所以直接从 j = 1 处开始
len = strlen(p); // 子串的长度

next[0] = -1; // next[0] 一定为 -1

while (j < len)
{
if (k == -1 || p[j - 1] == p[k]) // 遍历到 next[0] 了或子串中 p[j - 1] == p[next[j - 1]]
{ // 注意这个顺序
k++;
next[j] = k;
j++;
}
else
{
k = next[k];
}
}
}

三、Next 数组修正的基本理论

3.1 Next 数组修正概述

3.1.1 为什么要修正?

在开始介绍修正算法之前还是得说一下为什么要进行 next 数组修正。

那么,让我们来看一组例子:

主串(S):ababcababd
子串(P):ababd
next 数组及 nextval 数组:

索引01234
子串(P)ababd
next-10012
nextval-10-102
Ⅰ)使用原 next 的 kmp 算法:
主串(S)子串(P)关键判断
ababcababdababd===
ababcababdababd===
ababcababdababd===
ababcababdababd===
ababcababdababd=/=
ababcababdababdnext[4]=2
ababcababdababd=/=
ababcababdababdnext[2]=0
ababcababdababd=/=
ababcababdababd===
===
ababcababdababd===
Ⅱ)使用 nextval 的 kmp 算法:
主串(S)子串(P)关键判断
ababcababdababd===
ababcababdababd===
ababcababdababd===
ababcababdababd===
ababcababdababd=/=
ababcababdababdnext[4]=2
ababcababdababd=/=
ababcababdababdnext[2] = -1
ababcababdababd===
===
ababcababdababd===

这么一来,使用 nextval 在处理类似的子串时就能减少一些不必要的判断,从而提高效率!

3.1.2 如何修正

这个算法并不难,可以用一段伪代码来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
nextval[0] = next[0] = -1;

if p[i] =?= p[next[i]]
{
if 相同(===)
{
则 nextval[i] = next[next[i]];
}
else 不同(=/=)
{
则 nextval[i] = next[i];
}
}

3.2 代码实现 Next 数组修正

1
2
3
4
5
6
7
8
9
10
11
12
13
void getNextval(char* p, int* next)
{
int i,
len = strlen(p);

for (i = 1; i < len; i++)
{
if (p[i] == p[next[i]])
next[i] = next[next[i]];
else
next[i] = next[i];
}
}

四、KMP 算法的完整代码(理解版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
#include <stdio.h>
#include <malloc.h>
#include <string.h>

void getNext(char* p, int* next)
{
int k = -1, // 初始化 k 值,以后这个值在循环初始时必须保证和 next[j - 1] 一致
j = 1, // 因为 next[0] 一定为 -1 ,所以直接从 j = 1 处开始
len = strlen(p); // 子串的长度

next[0] = -1; // next[0] 一定为 -1

while (j < len)
{
if (k == -1 || p[j - 1] == p[k])
{
k++;
next[j] = k;
j++;
}
else
{
k = next[k];
}
}
}

int KMP(char* s, char* p)
{
int pos = -1; // 用于返回值,查找成功返回子串第一次在主串中出现时第一个字符的下标

int s_len = strlen(s), // 主串长度
p_len = strlen(p); // 子串长度

int i = 0, // 控制主串的索引
j = 0; // 控制子串的索引

int* next = (int*)malloc(p_len * sizeof(int)); // 用 malloc 声明一个一维 next 数组

if (next)
{
getNext(p, next); // 调用 getNext 函数,初始化 next 数组

while (i < s_len)
{
if (j == -1 || s[i] == p[j]) // 如果“主串字符与子串字符相同”或“子串已返回到第一个字符(j = next[0] = -1)”
{
i++;
j++;
}
else // 主串字符与子串字符不相同
{
j = next[j];
}

if (j >= p_len) // 子串遍历完成,已经全部匹配
{
pos = i - j; // 获取子串第一次在主串中出现时第一个字符的下标
break; // 跳出循环
}
}

free(next); // 释放 next 数组的内存空间
}

return pos;
}

int main(int argc, char* argv[])
{
char s[] = { "abcabcabcdefsdjklasjseayjllasdn" },
p[] = { "seayj" };
printf("pos=%d\n", KMP(s, p));
return 0;
}
#include <stdio.h>
#include <malloc.h>
#include <string.h>

void getNextval(char* p, int* next)
{
int i,
len = strlen(p);

for (i = 1; i < len; i++)
{
if (p[i] == p[next[i]])
next[i] = next[next[i]];
else
next[i] = next[i];
}
}

void getNext(char* p, int* next)
{
int k = -1, // 初始化 k 值,以后这个值在循环初始时必须保证和 next[j - 1] 一致
j = 1, // 因为 next[0] 一定为 -1 ,所以直接从 j = 1 处开始
len = strlen(p); // 子串的长度

next[0] = -1; // next[0] 一定为 -1

while (j < len)
{
if (k == -1 || p[j - 1] == p[k])
{
k++;
next[j] = k;
j++;
}
else
{
k = next[k];
}
}
}

int KMP(char* s, char* p)
{
int pos = -1; // 用于返回值,查找成功返回子串第一次在主串中出现时第一个字符的下标

int s_len = strlen(s), // 主串长度
p_len = strlen(p); // 子串长度

int i = 0, // 控制主串的索引
j = 0; // 控制子串的索引

int* next = (int*)malloc(p_len * sizeof(int)); // 用 malloc 声明一个一维 next 数组

if (next)
{
getNext(p, next); // 调用 getNext 函数,初始化 next 数组

getNextval(p, next);

while (i < s_len)
{
if (j == -1 || s[i] == p[j]) // 如果“主串字符与子串字符相同”或“子串已返回到第一个字符(j = next[0] = -1)”
{
i++;
j++;
}
else // 主串字符与子串字符不相同
{
j = next[j];
}

if (j >= p_len) // 子串遍历完成,已经全部匹配
{
pos = i - j; // 获取子串第一次在主串中出现时第一个字符的下标
break; // 跳出循环
}
}

free(next); // 释放 next 数组的内存空间
}

return pos;
}

int main(int argc, char* argv[])
{
char s[] = { "abcabcabcdefsdjklasjseayjllasdn" },
p[] = { "seayj" };
printf("pos=%d\n", KMP(s, p));
return 0;
}

五、KMP 算法代码(简洁版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
void getNextval(char* p, int* next)
{
int k = -1, // 初始化 k 值,以后这个值在循环初始时必须保证和 next[j - 1] 一致
j = 1, // 因为 next[0] 一定为 -1 ,所以直接从 j = 1 处开始
len = strlen(p); // 子串的长度

next[0] = -1; // next[0] 一定为 -1

while (j < len)
{
if (k == -1 || p[j - 1] == p[k])
{
k++;

// next 数组修正
if (p[j] == p[k])
next[j] = next[k];
else
next[j] = k;

j++;
}
else
{
k = next[k];
}
}
}

int KMP(char* s, char* p)
{
int pos = -1; // 用于返回值,查找成功返回子串第一次在主串中出现时第一个字符的下标

int s_len = strlen(s), // 主串长度
p_len = strlen(p); // 子串长度

int i = 0, // 控制主串的索引
j = 0; // 控制子串的索引

int* next = (int*)malloc(p_len * sizeof(int)); // 用 malloc 声明一个一维 next 数组

if (next)
{
getNextval(p, next);

while (i < s_len)
{
if (j == -1 || s[i] == p[j]) // 如果“主串字符与子串字符相同”或“子串已返回到第一个字符(j = next[0] = -1)”
{
i++;
j++;
}
else // 主串字符与子串字符不相同
{
j = next[j];
}

if (j >= p_len) // 子串遍历完成,已经全部匹配
{
pos = i - j; // 获取子串第一次在主串中出现时第一个字符的下标
break; // 跳出循环
}
}

free(next); // 释放 next 数组的内存空间
}

return pos;
}

六、补充

2023年07月09日
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
namespace KMP_STD
{
internal class KMP
{
private const int MSG_KMP_MATCH_EXCEPTION = -1; // 消息 —— 匹配错误
public const bool FLAG_OPTIMIZE_NEXT = true; // 标志 —— 生成 Next 数组时进行 Next 数组优化
public const bool FLAG_NORMAL_NET = false; // 标志 —— 生成 Next 数组时不进行 Next 数组优化

public int[]? Next = null;

// 生成 KMP Next 数组
public bool GetNext(string P, bool WhetherToOptimize = FLAG_NORMAL_NET)
{
Next = new int[P.Length];

// Next 数组前两位必为 -1 0, 所以只需要从第三位开始生成即可
Next[0] = -1;
Next[1] = 0;
int j = 2, k = 0;

try
{
while (j < P.Length)
{
if (k == -1 || P[j - 1] == P[k])
{
if (FLAG_OPTIMIZE_NEXT == WhetherToOptimize)
{ // 优化 Next 生成算法 —— NextVal
++k;

if (P[j] == P[k])
Next[j] = Next[k];
else
Next[j] = k;

j++;
}
else
{ // 普通 Next 生成算法 —— Next
Next[j] = ++k;
j++;
}
}
else
{
k = Next[k];
}
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());

return false;
}

return true;
}

// 字符串匹配算法逻辑
public int Match(string P, string S)
{
int PIndex = 0, // 主串遍历索引
SIndex = 0, // 字串遍历索引
FirstPos = -1; // 返回字串在主串第一次出现时的首字符位置索引值

try
{
while (PIndex < P.Length)
{
if (SIndex == -1 || P[PIndex] == S[SIndex])
{
PIndex++;
SIndex++;
}
else
{
SIndex = Next![SIndex];
}

// 检查是否匹配成功
if (SIndex == S.Length)
{
FirstPos = PIndex - S.Length; // 返回匹配成功时字串第一次出现的首字母位置
break;
}
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
return MSG_KMP_MATCH_EXCEPTION;
}

return FirstPos;
}
}
}

七、最新算法模板

该算法模板来自于 AcWing 的总结,相对于闫总的代码模板,这里改为下标统一从 0 开始。

相关代码同时存储在 Github 上。
具体地算法讲解可以看 AcWing 的这篇文章:深入浅出 next 数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <vector>
#include <string>

// 计算部分匹配表(前缀函数)
static std::vector<int> computePrefixFunction(const std::string& pattern) {
int m = pattern.length();
std::vector<int> lps(m, 0); // 最长相等前缀后缀数组
int j = 0;

for (int i = 1; i < m; ++i) {
while (j > 0 && pattern[i] != pattern[j]) {
j = lps[j - 1]; // 回退
}
if (pattern[i] == pattern[j]) {
++j;
}
lps[i] = j;
}

return lps;
}

// KMP匹配函数
static void KMP(const std::string& text, const std::string& pattern) {
int n = text.length();
int m = pattern.length();

// 计算部分匹配表
std::vector<int> lps = computePrefixFunction(pattern);

int j = 0; // pattern的指针

for (int i = 0; i < n; ++i) {
while (j > 0 && text[i] != pattern[j]) {
j = lps[j - 1]; // 回退
}
if (text[i] == pattern[j]) {
++j;
}
if (j == m) {
std::cout << "Pattern found at index " << i - m + 1 << std::endl;
j = lps[j - 1]; // 寻找下一个匹配
}
}
}