
欢迎来到我的:世界
希望作者的文章对你有所帮助,有不足的地方还请指正,大家一起学习交流 !
目录
- 前言
- 内容
-
- BF算法
- BF算法C++代码实现
- 思考:朴素匹配的小优化
- [KMP 算](#KMP 算)
- [KMP 算法:](#KMP 算法:)
- next数组
- next数组C++代码实现
- 优化KMP算法
-
- [一、为什么 next 数组还不够用?](#一、为什么 next 数组还不够用?)
- [二、nextval 数组:规则与构造](#二、nextval 数组:规则与构造)
- [三、C++ 代码验证](#三、C++ 代码验证)
- 四、用图把过程看清楚
- 五、小结:优化到底改变了什么
- [附:本节验证用的完整 C++ 程序](#附:本节验证用的完整 C++ 程序)
- [如果这篇 KMP(含 nextval 优化)对你有帮助,欢迎点赞、收藏;有写得不清楚的地方也欢迎在评论区指出,咱们一起把它改得更好 !](#如果这篇 KMP(含 nextval 优化)对你有帮助,欢迎点赞、收藏;有写得不清楚的地方也欢迎在评论区指出,咱们一起把它改得更好 !)
前言
在字符串处理的广阔世界中,字符串匹配是一项基础且极为重要的任务。从文本编辑器中的查找替换功能,到生物信息学中 DNA 序列的比对,字符串匹配无处不在。而 KMP 算法,作为字符串匹配算法家族中的一颗璀璨明星,以其高效性和独特的思想,备受关注。今天,就让我们一同深入探索 KMP 算法的奥秘。
内容
什么是字符串匹配问题
简单来说,字符串匹配问题就是在一个主字符串 (text)中查找是否存在一个特定的模式字符串(pattern)。例如,主字符串 text 为 "ABABDABACDABABCABAB",模式字符串 pattern 为 "ABABCABAB",我们需要判断 pattern 是否是 text 的子串,如果是,返回其在 text 中的起始位置。
BF算法
暴力匹配算法(又叫做BF算法(Brute Froce算法))及其局限性;
在接触 KMP 算法之前,我们先来看看最直观的暴力匹配算法。暴力匹配算法的思路非常简单直接,它从主字符串的第一个字符开始,逐字符与模式串的第一个字符进行比较;如果相等则继续比较后续字符;如果不相等,则主串的比较位往后移动一位,重新从模式串的第一个字符开始比较。这个过程会一直重复,知道模式串的所有字符都匹配成功;
用例介绍:这串主串和匹配串,进行匹配,设置两个索引下标 主串索引i , 匹配串索引j ,此时i=0开始来遍历匹配这两串;

如果i位置的字符等于j位置的字符就代表匹配成功,继续后遍历匹配,

当i == 5 ,且 此 时 j==5 明显a与c不匹配,说明主串从i=0位置开始,找不到匹配的匹配串,此时再让主串的索引i回退到本次匹配开始的位置的下一个位置(i=1),而模式串索引j回到起始位置;

以此类推,直到找到匹配的子串或者确定模式字符串不是主字符串的子串为止。
暴力匹配算法虽然容易理解和实现,但其时间复杂度较高。
计算时间复杂度 :假设 m 是模式字符串的长度,n 是主字符串的长度,在最坏情况下,每个子串都要对比m个字符,共要对比n-m+1个子串,所以时间复杂度为0((n-m+1) * m),可以看成是0(n*m-m*m+m) ,又因为在很多情况是n>>m(远大于),根据大O渐进表示法,直接抓大头,所以其最坏的时间复杂度是0(n*m);
平均情况下,假设模式串中的字符是随机分布的,并且模式串不在主串中出现。对于主串的每个位置,平均需要比较的字符数是一个常数。因此,平均时间复杂度仍然是 O(m*n)。
BF算法C++代码实现
cpp
#include <iostream>
#include <string>
// 字符串匹配函数,返回模式串在主串中首次出现的位置,未找到返回0
int Index(const std::string& S, const std::string& T) {
// 处理空串情况
if (S.length() == 0 || T.length() == 0) {
return 0;
}
int i = 0, j = 0;
while (i < static_cast<int>(S.length()) && j < static_cast<int>(T.length())) {
if (S[i] == T[j]) {
++i;
++j; // 继续比较后继字符
}
else {
i = i - j + 1; // 主串索引回溯到上次匹配开始位置的下一位
j = 0; // 模式串索引重置为开头
}
}
if (j == static_cast<int>(T.length())) {
return i - static_cast<int>(T.length()); // 返回匹配的起始位置
}
return 0; // 未找到匹配返回0
}
另一种写法,我觉得既然学了C++就得多用用容器,虽然时间复杂度不会改变,但这可以让你的代码更简洁,明了;
cpp
int bruteForceSearch(string& str, string& sub)
{
size_t lensub = sub.length();
size_t lenstr = str.length();
int i = 0;
// 外层循环,i 从 0 到 m - n
while (i <= lenstr - lensub)
{
// 从主字符串 str 的第 i 个位置开始截取长度为 lensub 的子串
string tmp = str.substr(i, lensub);
// 比较截取的子串 tmp 和目标子串 sub 是否相等
if (tmp == sub) return i;
else i++;
}
return -1;
}
思考:朴素匹配的小优化
使用容器进行小优化
我们这道朴素匹配算法为什么会很慢,就是因为他计算的很多没有必要的比较,这里我想可以使用容器哈希表,来实现快速查找到下一个可以进行匹配的位置,这样可以避免一些不必要的计算;
cpp
// 使用容器优化的暴力匹配法
int optimizedBruteForce(const string& text, const string& pattern) {
int n = text.length(); // 主串长度
int m = pattern.length(); // 模式串长度
// 遍历主串的每个可能的起始位置
for (int i = 0; i <= n - m; ) {
int j = 0; // 用于匹配模式串的索引
// 从当前起始位置开始匹配模式串
while (j < m && text[i + j] == pattern[j]) {
++j; // 如果字符匹配,继续比较下一个字符
}
// 如果模式串的所有字符都匹配成功
if (j == m) {
return i; // 返回匹配的起始位置
}
// 如果当前起始位置匹配失败,根据模式串的第一个字符在当前窗口中的位置进行跳过
unordered_set<char> windowSet(text.begin() + i, text.begin() + i + j); // 当前窗口的字符集合
if (windowSet.find(pattern[0]) != windowSet.end()) {
// 如果模式串的第一个字符在当前窗口中出现过,跳过该字符的位置
i += j;
}
else {
// 如果模式串的第一个字符不在当前窗口中,直接跳过整个窗口
i += j + 1;
}
}
return -1; // 如果没有找到匹配的子串,返回-1
}
滑动窗口进行小优化
cpp
#include <iostream>
#include <string>
// 暴力匹配函数,返回模式串在主串中第一次出现的位置,若未找到则返回 -1
int bruteForceSearch(const std::string& text, const std::string& pattern) {
int n = text.length();
int m = pattern.length();
for (int i = 0; i <= n - m; ++i) {
int j;
for (j = 0; j < m; ++j) {
if (text[i + j] != pattern[j]) {
break;
}
}
if (j == m) {
return i;
}
}
return -1;
}
KMP 算
KMP 算法:
KMP 算法的核心思想是:当某次比对失败时,能够利用已经比对过的部分信息,尽可能多地将模式字符串向后移动,减少不必要的重复比对,从而提高匹配效率。
首先要弄清楚前缀,后缀和部分匹配值。
前缀 :是指除最后一个字符外,字符串的所有头子串;
后缀 :是指除第一个字符外,字符串的所有尾部子串;
部分匹配值:是指字符串的最长相等前后缀长度;
比如:先介绍前后缀
| 匹配串 | 前缀 | 后缀 |
|---|---|---|
| a | 空 | 空 |
| ab | a | b |
| aba | a,ab | a,ba |
| abac | a,ab,aba | c,ac,bac |
而部分匹配值的作用:就是用来设置KMP算法中的next数组(数组中存的是最长相等前后缀长度);就是在某个位置匹配失败时,能够快速将模式串的指针回溯到合适的位置,避免主串指针的回溯。
举个例子:咱们先不管next数组怎么设置的,先假设看到这模式串的next的数组是[-1,0,0,0,1,2],然后看看如何使用KMP算法;


然后开始进行比较,与BF算法类似;设置两个指针i j开始遍历匹配,当i==5 且j==5的时候,此时不匹配,接下来就是KMP算法中next数组的作用了;此时要j指针回溯到你此时j=next[j]数组中的位置(对于为什么要这样,有什么原理,后面会详细介绍,现在先走下去)

此时再继续进行遍历匹配:但此时i位置和j位置的字符位置不匹配,此时再次进行j指针回溯j=next[j],此时就回溯到了0下标位置,也就是起始位置;

此时再进行遍历匹配:此时就可以全部遍历完了;好了这就是大概KMP算法的算法步骤了,由此就可以了解到KMP算法和BF暴力算法的本质区别就是那个next数组,有了next数组就可以让模式串进行回溯,且主串中的i指针不回退;
在这里解释一下为什么next中的值,就一定可以回溯,且一定正确呢?

理论证明:

看完理论证明,咱们可以带入确定的值看看是否能推出来,不过要先看看下面的next数组的实现;
next数组
接下来就是重点了,开始设置next数组;首先要知道next数组的目的是什么,用通俗易懂的话:是用来保存子串某位置匹配失败后,回头的下标位置。
咱们就根据这句话来填写next数组。
咱们先提示一下:next[j] 记录的是模式串中从第 0 个字符到第 j - 1 个字符所构成的子串的最长公共前后缀的长度。
特别注意:
- 将next0设为-1是一种边界处理。当主串与模式串在第一个字符就不匹配时,需要将模式串向后移动一位继续比较,-1可以作为一个特殊标记来表示这种情况。
- next1设为0,是因为长度为1的子串不存在公共前后缀,所以最长公共前后缀长度为0。

next数组C++代码实现
cpp
//设置next数组
void getnext(string& sub, vector<int>& next)
{
//设置初始值
next[0] = -1, next[1] = 0;
int k = 0;
int i = 2;
while (i < sub.size())
{
//找到相同字符
if (k == -1 || sub[i - 1] == sub[k])
{
next[i] = k + 1;
i++;
k++;
}
else
{
k = next[k];
}
}
}
C++代码实现KMP算法
cpp
#include <iostream>
#include <string>
#include <vector>
// 计算未优化的 next 数组
void getNext(const std::string& pattern, std::vector<int>& next) {
int j = 2;
int k = 0;
next[0] = -1;
next[1] = 0;
while (j < pattern.size()) {
if (k == -1 || pattern[j-1] == pattern[k]) {
next[j] = k + 1;
++j;
++k;
} else {
k = next[k];
}
}
}
// KMP 搜索算法
int kmpSearch(const std::string& text, const std::string& pattern) {
int n = text.length();
int m = pattern.length();
std::vector<int> next(m);
getNext(pattern, next);
int i = 0;
int j = 0;
while (i < n) {
if (j == -1 || text[i] == pattern[j]) {
++i;
++j;
}
if (j == m) {
return i - j;
} else if (i < n && text[i] != pattern[j]) {
j = next[j];
}
}
return -1;
}
int main() {
std::string text = "ABABDABACDABABCABAB";
std::string pattern = "ABABCABAB";
int index = kmpSearch(text, pattern);
if (index != -1) {
std::cout << "模式串在文本中的起始索引为: " << index << std::endl;
} else {
std::cout << "未找到匹配的模式串。" << std::endl;
}
return 0;
}
优化KMP算法
前面我们已经知道:失配时主串指针 i 不动,只把模式串指针 j 回退到 next[j]。next 数组保证了"回退之后不会漏掉匹配",但它并没有保证每一次回退都是必要的。这一节我们就把这些"没必要的回退"砍掉------这就是 nextval 数组(优化后的 next)。
一、为什么 next 数组还不够用?
先看一个最典型的例子:
- 主串 S =
aaabaaaab - 模式串 P =
aaaab - P 的 next 数组:
[-1, 0, 1, 2, 3]
匹配到 i = 3、j = 3 时失配(S[3] = 'b' 与 P[3] = 'a'),按 next 数组乖乖回退:
| 回退 | 接下来比较 | 结果 |
|---|---|---|
j = next[3] = 2 |
S3='b' 与 P2='a' | 失败,而且是必然失败 |
j = next[2] = 1 |
S3='b' 与 P1='a' | 失败,而且是必然失败 |
j = next[1] = 0 |
S3='b' 与 P0='a' | 失败,而且是必然失败 |
j = next[0] = -1 |
主串后移一位,模式串从头开始 | 正常 |
问题就出在:P[3] = P[2] = P[1] = P[0] = 'a',这四个位置是同一个字符 。既然 S[3] = 'b' 已经和 P[3] = 'a' 比过并失败了,那么它和同样等于 'a' 的 P[2]、P[1]、P[0] 再比,结果必然还是失败。这三次比较,是完全可以预料结果的重复劳动。
一句话总结:如果 Pj == Pnext\[j],那么回退到 nextj 是多余的,应该直接回退到 nextvalnext\[j]。
二、nextval 数组:规则与构造
nextval 的规则只有两句话:
text
nextval[0] = -1
对 j >= 1:
若 P[j] == P[next[j]] ,则 nextval[j] = nextval[next[j]] // 同字符:这步回退没意义,继续往前跳
否则 ,则 nextval[j] = next[j] // 不同字符:这步回退是"真回退",保留
以 P = aaaab 为例:
| j | Pj | nextj | Pj 与 Pnext\[j] | nextvalj |
|---|---|---|---|---|
| 0 | a | -1 | --- | -1 |
| 1 | a | 0 | a = a | -1 |
| 2 | a | 1 | a = a | -1 |
| 3 | a | 2 | a = a | -1 |
| 4 | b | 3 | b ≠ a | 3 |
对照着看:
- 前四个位置全是
'a',互为"同一种情况",于是 nextval 一路压缩到-1:一次失配就直接换主串的下一位,中间那几次回退全部省掉; j = 4保留了3,因为P[4] = 'b' ≠ P[3] = 'a',这次回退能真正换到一个不同的字符,是有意义的。
两个小结论(后面会用到):
- 因为
next[j] < j恒成立,所以算nextval[j]时nextval[next[j]]一定已经算好了,从左往右一遍就能填完; - 恒有
nextval[j] <= next[j],回退只会跳得更靠前,不会倒退------匹配过程中主串指针i依然不回退。
三、C++ 代码验证
先把"只看逻辑"的干净版本写出来:
cpp
// 求 nextval:把 next 里"注定失败"的回退一次性跳完
void getNextval(const string& p, const vector<int>& next, vector<int>& nextval) {
int m = (int)p.size();
nextval.assign(m, 0);
nextval[0] = -1;
for (int j = 1; j < m; ++j) {
if (p[j] == p[next[j]]) nextval[j] = nextval[next[j]]; // 同字符:继续往前跳
else nextval[j] = next[j]; // 不同字符:这次回退有效
}
}
小细节:
next[j]只有在下标 0 处才是 -1,而循环从j = 1开始,所以p[next[j]]不会越界。
为了公平对比,把两张表塞进同一个搜索函数:传 next 就是未优化版本,传 nextval 就是优化版本;
cpp
long long g_cmp = 0; // 字符比较次数(比较成功或失败都算一次)
int kmpSearch(const string& s, const string& p, const vector<int>& table) {
int n = (int)s.size(), m = (int)p.size();
g_cmp = 0;
int i = 0, j = 0;
while (i < n && j < m) {
if (j == -1) { ++i; j = 0; continue; } // 模式串退无可退,主串后移一位
++g_cmp;
if (s[i] == p[j]) { ++i; ++j; }
else j = table[j]; // 唯一的区别就在这一行
}
return (j == m) ? i - m : -1;
}
用 S = "aaabaaaab"、P = "aaaab" 跑一遍,得到:
text
next = -1 0 1 2 3
nextval = -1 -1 -1 -1 3
未优化:匹配下标 4,比较 12 次
优化后:匹配下标 4,比较 9 次
省下 3 次比较
两种写法给出了完全相同的匹配结果(下标 4),但优化版本少做了 3 次注定失败的比较,也就是减少了 25%。
再把主串拉长一点,看看差距会不会消失(文本用 "aaaac" 重复拼接,模式仍是 aaaab,两组结果都是"找不到"):
| 主串长度 n | 未优化 KMP | 优化 KMP | 减少 |
|---|---|---|---|
| 50 | 90 次 | 60 次 | 33% |
| 100 | 180 次 | 120 次 | 33% |
| 200 | 360 次 | 240 次 | 33% |
| 400 | 720 次 | 480 次 | 33% |
差距稳定在 33% 左右:它会随着主串变长一直存在,但也不会越拉越大------因为它改变的是常数因子。
正确性说明:我用 nextval 版本和暴力匹配做过 1000 组随机串对拍(含大量重复字符),结果完全一致。优化只会跳过"必然失败"的比较,不会跳过任何一次可能成功的匹配。
顺便:nextval 也可以一遍生成(很多教材里就是这种写法):
cpp
// 一遍生成 nextval(与上面的两遍写法等价)
void getNextval(const string& p, vector<int>& nextval) {
int m = (int)p.size();
nextval.assign(m, 0);
nextval[0] = -1;
if (m > 1) nextval[1] = (p[1] == p[0]) ? -1 : 0; // j = 1 也要压缩
int j = 2, k = 0;
while (j < m) {
if (k == -1 || p[j - 1] == p[k]) {
++k; // 此时 k 恰好等于 next[j]
nextval[j] = (p[j] == p[k]) ? nextval[k] : k; // 同字符就压缩,不同就保留
++j;
} else {
k = nextval[k]; // 用压缩后的表继续回退,跳得更快
}
}
}
它和"先求 next、再压缩"的两遍写法完全等价 (我也对拍过 20 万组模式串)。原因很直白:每前进一位时,k 就相当于"还没来得及压缩的 nextj",此时把 p[j] 和 p[k] 比一下------相同就取 nextval[k],不同就取 k,正好就是压缩规则。
四、用图把过程看清楚
图 1:失配之后的"无效回退链"。
P[0] ~ P[3] 全是 'a',所以 S[3] = 'b' 跟它们比是白比;
图 2:nextval 的构造规则。
什么情况下继续往前跳、什么情况下保留 next[j],一张表就说清楚了;
图 3:同一段主串,12 次比较 vs 9 次比较。
红色那三格就是被 nextval 省掉的无效比较,匹配结果完全一样(都是下标 4);
图 4:主串变长,差距依然稳定。
优化前后的比较次数都是线性增长的,nextval 改变的是常数因子,而不是数量级。
五、小结:优化到底改变了什么
| 对比项 | 未优化 next | 优化 nextval |
|---|---|---|
| 失配后回退到 | next[j] |
nextval[j](且 nextval[j] <= next[j]) |
| 是否会出现"注定失败"的比较 | 会(P[j] == P[next[j]] 时) |
不会 |
| 时间复杂度 | O(n + m) |
O(n + m)(不变) |
| 实际比较次数 | 常数较大 | 常数更小,模式串前缀重复度高时收益明显 |
| 匹配结果 | 正确 | 正确,不会漏解 |
所以 nextval 并不是"另一个更快的算法",而是一张更聪明的回退表 :算法的框架没变,变的只是失配之后跳到哪儿。理解了这一点再回头看 KMP,其实就一句话------主串不回头,模式串尽量少退,能一步跳完的绝不走两步。
附:本节验证用的完整 C++ 程序
把下面的代码存成 .cpp 文件直接编译运行,就能得到文中那些比较次数:
cpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;
long long g_cmp = 0; // 字符比较次数(无论成功还是失败都算 1 次)
// ---------- 1. 原始 next 数组 ----------
void getNext(const string& p, vector<int>& next) {
int m = (int)p.size();
next.assign(m, 0);
next[0] = -1;
if (m > 1) next[1] = 0;
int j = 2, k = 0;
while (j < m) {
if (k == -1 || p[j - 1] == p[k]) {
next[j] = k + 1;
++j; ++k;
} else {
k = next[k];
}
}
}
// ---------- 2. nextval 数组(由 next 压缩而来) ----------
void getNextval(const string& p, const vector<int>& next, vector<int>& nextval) {
int m = (int)p.size();
nextval.assign(m, 0);
nextval[0] = -1;
for (int j = 1; j < m; ++j) {
if (p[j] == p[next[j]]) nextval[j] = nextval[next[j]];
else nextval[j] = next[j];
}
}
// ---------- 3. 通用 KMP:table 传 next 就是原版,传 nextval 就是优化版 ----------
int kmpSearch(const string& s, const string& p, const vector<int>& table) {
int n = (int)s.size(), m = (int)p.size();
g_cmp = 0;
int i = 0, j = 0;
while (i < n && j < m) {
if (j == -1) { ++i; j = 0; continue; }
++g_cmp;
if (s[i] == p[j]) { ++i; ++j; }
else j = table[j];
}
return (j == m) ? i - m : -1;
}
void printVec(const string& name, const vector<int>& v) {
cout << name << " = ";
for (int x : v) cout << x << ' ';
cout << endl;
}
int main() {
string s = "aaabaaaab";
string p = "aaaab";
vector<int> next, nextval;
getNext(p, next);
getNextval(p, next, nextval);
printVec("next ", next);
printVec("nextval", nextval);
int a = kmpSearch(s, p, next); long long c1 = g_cmp;
int b = kmpSearch(s, p, nextval); long long c2 = g_cmp;
cout << "未优化:匹配下标 " << a << ",比较 " << c1 << " 次" << endl;
cout << "优化后:匹配下标 " << b << ",比较 " << c2 << " 次" << endl;
cout << "省下 " << (c1 - c2) << " 次比较" << endl << endl;
// 把主串拉长,看看差距是否稳定
for (int k : {10, 20, 40, 80}) {
string text;
for (int t = 0; t < k; ++t) text += "aaaac";
int x = kmpSearch(text, p, next); long long n1 = g_cmp;
int y = kmpSearch(text, p, nextval); long long n2 = g_cmp;
cout << "n = " << text.size() << " 未优化 " << n1 << " 次,优化 " << n2
<< " 次(结果 " << x << " / " << y << ")" << endl;
}
return 0;
}
运行结果:
text
next = -1 0 1 2 3
nextval = -1 -1 -1 -1 3
未优化:匹配下标 4,比较 12 次
优化后:匹配下标 4,比较 9 次
省下 3 次比较
n = 50 未优化 90 次,优化 60 次(结果 -1 / -1)
n = 100 未优化 180 次,优化 120 次(结果 -1 / -1)
n = 200 未优化 360 次,优化 240 次(结果 -1 / -1)
n = 400 未优化 720 次,优化 480 次(结果 -1 / -1)
如果这篇 KMP(含 nextval 优化)对你有帮助,欢迎点赞、收藏;有写得不清楚的地方也欢迎在评论区指出,咱们一起把它改得更好 !
到了最后: 感谢支持
------------对过程全力以赴,对结果淡然处之