文章目录
- 起因:暴力法的致命缺陷
- 暴力搜索的局限性
- KMP核心思想:避免重复
- 理解前缀表(PMT)
- 不匹配时的回退机制
- 代码:高效字符串匹配
- 补充:next表和PMT表
- 暴力法 vs KMP
- 总结:KMP 是如何改变游戏规则的
- 总结:KMP 是如何改变游戏规则的
起因:暴力法的致命缺陷
不知道你有没有曾经为编程中的慢速字符串搜索而烦恼吗?想象一下处理成千上万的字符,却发现你的解决方案运行时间过长。如果有一种方法可以极大地加快这个过程,会怎么样呢?
偶然一次刷题中也遇到了这么一个问题,看似一道很简单的题,背后却又大学问,题目的描述如下:
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
leetcode链接挂这了,有兴趣的小伙伴可以去试试。[找出字符串中第一个匹配项的下标]
其实就是一个字符串匹配,刚读完题我就想到了一个方法,原思路是这样的:
1、以
needle
的第一个字符为基准,顺序遍历haystack
字符串2、如果第一个字符串相等,再以此开始,同时移动
needle
和haystack
的下标3、如果
needle
遍历完则表示可以匹配到,反之则表示没有匹配到需要继续遍历4、没有匹配到则将
haystack
的下标回到上一次匹配的下一个,needle
则回到第一个5、重复2、3、4,如果
haystack
遍历完都没有匹配到,则不存在
基于此思路,写下如下代码:
int strStr(string haystack, string needle)
{int n = haystack.size(), m = needle.size();if (m == 0) return 0;if (n < m) return -1;for (int i = 0; i <= n - m; i++) // 优化循环终止条件{ if (haystack[i] != needle[0]) continue;int j;for (j = 0; j < m; j++) // 直接比较字符,无需创建临时字符串{ if (haystack[i + j] != needle[j]) break;}if (j == m) return i;}return -1;
}
暴力搜索的局限性
这种方法实现简单,但是性能却经不起推敲,用一组看似简单的测试案例演示:
// 主串(haystack): "AAAAAAAAB" (8个'A' + 1个'B',长度9)
// 模式串(needle): "AAAB" (3个'A' + 1个'B',长度4)
int pos = strStr("AAAAAAAAB", "AAAB"); // 正确结果应为5
根据代码逻辑,实际匹配过程如下(👉 逐帧解析):
-
i=0(主串起始位置)
-
比较
haystack[0]
(A) ==needle[0]
(A) → 成功 -
逐字符检查
:
- j=0: A vs A ✅
- j=1: A vs A ✅
- j=2: A vs A ✅
- j=3: A vs B ❌
-
总计比较4次 → 失败,i++
-
-
i=1(主串第二个A)
-
再次比较
haystack[1]
(A) ==needle[0]
(A) → 成功 -
逐字符检查
:
- j=0: A vs A ✅
- j=1: A vs A ✅
- j=2: A vs A ✅
- j=3: A vs B ❌
-
总计比较4次 → 失败,i++
(👉 问题浮现:主串的i=1~3位置已经被验证为A,却再次重复比较!)
-
-
i=2、i=3、i=4、i=5
- 每次i递增后,完全重复上述过程 → 每次比较4次,均失败
-
i=6(主串第七个A)
-
比较
haystack[6]
(A) ==needle[0]
(A) → 成功 -
逐字符检查
:
- j=0: A vs A ✅
- j=1: A vs A ✅
- j=2: A vs A ✅
- j=3: B vs B ✅
-
总计比较4次 → 成功,返回i=5
-
用一张图片来演示,如下图:
最后的数字触目惊心,这就是暴力法的 “重复税”。
- 总比较次数 = 4(i=0) + 4(i=1) + 4(i=2) + 4(i=3) + 4(i=4) + 4(i=5) + 4(i=5) = 28次
- 实际有效比较:只需检查主串i=5~8位置的"AAB"是否匹配,理想情况仅需4次比较!
🔥 核心问题:暴力法像陷入泥潭一样,每次失败后主串指针
i
仅前进1步,导致已确认匹配的字符被反复重验。当模式串有大量重复前缀时(如本例的"AAA"),这种冗余比较会被无限放大!
当我们再换一种思维实验:如果主串是10000个’A’加’B’?
假设 haystack = string(10000, 'A') + "B"
,needle = string(999, 'A') + "B"
,暴力法比较次数 ≈ (10000 - 1000) * 1000 = 9,000,000次。
暴力方法看起来简单易懂,但效率极低。问题出现在我们找到不匹配时。我们不是跳过已经检查过的字符,而是反复回到它们那里进行检查,导致无数不必要的比较。怎么解决这个问题?这正是本文说要说的重点——KMP,今天,让我们深入了解 KMP(Knuth-Morris-Pratt),这是解决这个常见问题的优雅且高效的方法。
KMP核心思想:避免重复
想象你正在搜索一个巨大的文件,并且你已经在开头找到了一个模式匹配。使用暴力搜索,你会从非常开始的地方再次开始,反复检查相同的点。然而,KMP 就像一个聪明的助手,它会记住你已经看过的位置,并帮助你跳过。
KMP 通过避免我们在暴力方法中看到的冗余比较来解决此问题。关键思想是,当发生不匹配时,我们不是将 haystack
指针向前移动一个位置,而是利用我们已经收集到的关于匹配字符的信息,移动 needle
指针。
我们如何实现这一点?通过使用前缀表(PMT)。
理解前缀表是理解KMP算法的关键,可以说这个前缀表就是KMP算法的核心,所以再次强调:前缀表记录的是每个位置的最长公共前后缀的长度!
理解前缀表(PMT)
前缀表,或部分匹配表,存储了 needle
的最长正确前缀同时也是后缀的长度,因此也叫最长公共前后缀。这有助于算法跳过已经匹配的部分 needle
,而不是从头开始。
让我们通过一个例子来分解它是如何工作的:
ABABAC
示例:为 ABABAC
构建前缀表,以下是构建该字符串的前缀表(PMT)的方法:
步骤1:i=1(字符B)
- 比较
pattern[1]
(B)与pattern[j=0]
(A) - 不匹配 →
j
保持0,pmt[1]=0
步骤2:i=2(字符A)
- 比较
pattern[2]
(A)与pattern[j=0]
(A) - 匹配 →
j++
→pmt[2]=j (j=1)
步骤3:i=3(字符B)
- 比较
pattern[3]
(B)与pattern[j=1]
(B) - 匹配 →
j++
→pmt[3]=j (j=2)
步骤4:i=4(字符A)
- 比较
pattern[4]
(A)与pattern[j=2]
(A) - 匹配 →
j++
→pmt[4]=j (j=3)
步骤5:i=5(字符C)
- 比较
pattern[5]
©与pattern[j=3]
(B) - 不匹配 →
j=pmt[j-1]=pmt[2]=1
- 再次比较
pattern[5]
©与pattern[j=1]
(B) → 仍不匹配 - 继续回退
j=pmt[j-1]=pmt[0]=0
- 最终
pmt[5]=0
最终PMT:[0, 0, 1, 2, 3, 0]
索引 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
字符 | A | B | A | B | A | C |
PMT | 0 | 0 | 1 | 2 | 3 | 0 |
在上面的动画中,你可以看到 KMP 如何通过利用之前匹配收集到的信息来避免不必要的检查。观察当发生不匹配时,模式指针如何跳到前面,从而加快过程。
不匹配时的回退机制
当模式串在位置j
匹配失败时,利用PMT值跳转到pmt[j-1]
继续匹配:
案例:主串ABABABAC
vs 模式串ABABAC
(PMT=[0,0,1,2,3,0]
)
主串:A B A B A B A C
模式串:A B A B A C
匹配失败位置:j=5(字符C)
回退步骤:
- 查
pmt[j-1]=pmt[4]=3
- 模式串跳转到
j=3
(字符B)继续与主串i=5
比较 - 跳过冗余比较
A B A
(已通过PMT确认匹配)
核心代码实现:
Cppvoid build_pmt(string pattern, vector<int>& pmt)
{pmt[0] = 0;int j = 0;for (int i = 1; i < pattern.size(); i++) {// 关键回退:利用已计算的pmt值递归查找while (j > 0 && pattern[i] != pattern[j]) {j = pmt[j-1];}// 匹配成功则延长共同前后缀if (pattern[i] == pattern[j]) j++;pmt[i] = j;}
}
理解了回退机制,我们来看看如何用代码实现这一逻辑。
代码:高效字符串匹配
有了前缀表,KMP 算法可以智能地跳过之前匹配的部分。这里是 C++ 中的 KMP 实现:
void build_pmt(string pattern, vector<int>& pmt)
{int j = 0;pmt[0] = 0;for (int i = 1; i < pattern.size(); i++) {while (j > 0 && pattern[i] != pattern[j]) {j = pmt[j - 1];}if (pattern[i] == pattern[j]) j++;pmt[i] = j;}
}int strStr(string haystack, string needle)
{if (needle.empty()) return 0;if (needle.size() > haystack.size()) return -1;vector<int> pmt(needle.size(), 0);build_pmt(needle, pmt);int j = 0;for (int i = 0; i < haystack.size(); i++) {while (j > 0 && haystack[i] != needle[j]) {j = pmt[j - 1];}if (haystack[i] == needle[j]) j++;if (j == needle.size()) {return i - needle.size() + 1;}}return -1;
}
补充:next表和PMT表
可能有些人之前看到的代码很多人写的是next数组并不是pmt,并且很多都是将第一个初始化为-1。这时候可能有人会疑惑,next和pmt有关系吗,有什么区别?
其实这并不涉及到KMP的原理,而只是工程代码的具体实现,将第一位初始化为-1其实就是前缀表的统一右移一位后,第一位补-1。
- PMT(部分匹配表)
- 定义:记录模式串每个前缀子串的最长公共前后缀长度(不包含自身)。
- 示例:模式串
ABABAC
的PMT为[0,0,1,2,3,0]
,表示各位置的最长公共前后缀长度 - 核心作用:通过已匹配的信息,避免主串指针回溯。
- next数组
- 定义:由PMT右移一位并首位补-1得到,用于直接指示失配时模式串指针的跳转位置。
- 示例:PMT
[0,0,1,2,3,0]
右移后得到next数组[-1,0,0,1,2,3]
- 核心作用:简化代码逻辑,避免手动计算偏移量。
在右移之后,就不需要在进行类似于 j = ptm[j - 1]
,而 next[j] = ptm[j - 1]
,因此就有 j = next[j]
。硬要说区别的话就是两者所表达的意义变了:
- PMT:回答“当前已匹配的子串中,前后缀有多少字符是重复的?”
- next数组:回答“失配时,模式串指针应跳转到哪个位置继续匹配?”
总的来说两者:
- 本质相同:PMT和next数组的核心数据一致,均基于最长公共前后缀的复用思想。
- 工程优化:next数组通过右移和补-1操作,简化了代码实现中的指针跳转逻辑,是PMT的工程化变体。
下面是用next表实现的代码:
#include <vector>
using namespace std;void build_next(string pattern, vector<int>& next)
{int n = pattern.size();next.resize(n);next[0] = -1; // 传统 next 数组第一个位置为 -1int j = -1; // 为了配合 next[0] = -1,j 初始化为 -1for (int i = 0; i < n; ) {if (j == -1 || pattern[i] == pattern[j]) {i++;j++;next[i] = j; // next[i] 对应 pmt[i-1]} else {j = next[j]; // 利用已计算的 next 回溯}}
}int strStr(string haystack, string needle)
{if (needle.empty()) return 0;if (needle.size() > haystack.size()) return -1;vector<int> next;build_next(needle, next);int i = 0, j = 0;int n = haystack.size(), m = needle.size();while (i < n && j < m) {if (j == -1 || haystack[i] == needle[j]) {i++;j++;} else {j = next[j]; // 直接根据 next 跳转}}return (j == m) ? i - m : -1;
}
暴力法 vs KMP
让我们重新审视我们之前的例子,其中包含 haystack = "AAAAAAAAB"
和 needle = "AAAB"
。
- 暴力破解:28 次比较
- KMP:仅需 9 次比较(多亏了前缀表)
当处理大量字符串时,性能差异变得更加明显。KMP 通过利用它在匹配过程中收集的信息,有效地减少了不必要的比较次数。以下是暴力法和KMP的性能对比,随着字符长度的增加,性能差异越来越大。
总结:KMP 是如何改变游戏规则的
KMP 通过减少冗余检查的数量,革新了我们对字符串匹配的方法。借助前缀表,KMP 可以智能地跳过已匹配的部分,使其比暴力方法显著更高效。下次你需要搜索子字符串时,记得 KMP 算法。这不仅仅是一种避免不必要工作的聪明方法——它还是向编写更简洁、更高效的代码迈出的一大步。
尝试在不同字符串搜索问题中实现 KMP 算法,并使用更大的数据集进行实验。你将看到 KMP 算法的实时好处,尤其是在处理文本处理或生物信息学等应用中的大量字符串时。
得更加明显。KMP 通过利用它在匹配过程中收集的信息,有效地减少了不必要的比较次数。以下是暴力法和KMP的性能对比,随着字符长度的增加,性能差异越来越大。
[外链图片转存中…(img-czSr32l5-1740039479058)]
总结:KMP 是如何改变游戏规则的
KMP 通过减少冗余检查的数量,革新了我们对字符串匹配的方法。借助前缀表,KMP 可以智能地跳过已匹配的部分,使其比暴力方法显著更高效。下次你需要搜索子字符串时,记得 KMP 算法。这不仅仅是一种避免不必要工作的聪明方法——它还是向编写更简洁、更高效的代码迈出的一大步。
尝试在不同字符串搜索问题中实现 KMP 算法,并使用更大的数据集进行实验。你将看到 KMP 算法的实时好处,尤其是在处理文本处理或生物信息学等应用中的大量字符串时。