字符串匹配(重点解析KMP算法)
目录
找出字符串中第一个匹配的下标
题目描述
方法一:暴力法
思路
C++ 代码实现
复杂度分析
方法二:KMP 算法
算法介绍
概述
作用
前缀表(next数组)
为什么使用最长公共前后缀?
具体例子说明
跳跃的原理
信息复用原理的详细分析
详细推导过程
步骤1:找到模式串的内部重复结构
步骤2:建立等式关系
步骤3:利用前缀等于后缀的性质
为什么这样跳跃是安全的?
为什么要用"最长"公共前后缀?
构建next数组
步骤一: 初始化阶段
步骤二:从第二个字符开始遍历
难点解析:回退逻辑 (while循环)
1 回退的触发条件
2 回退的核心思想
3 为什么 j = next[j-1] 是正确的?
4 为什么这个回退策略是完备的?
步骤三:匹配检查和更新
重难点深度分析
难点1:j变量的双重含义
难点2:回退的连锁反应
难点3:时间复杂度的非显然性
C++ 代码实现
KMP 算法的主过程
第一步:函数框架
第二步:初始化指针
第三步:遍历 haystack 进行匹配
第四步: 没找到
完整C++ 代码实现
复杂度分析
找出字符串中第一个匹配的下标
题目描述
给你两个字符串 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 <= 104
haystack 和 needle 仅由小写英文字符组成
方法一:暴力法
思路
我们可以让字符串 needle 与字符串 haystack 的所有长度为 m 的子串均匹配一次。
为了减少不必要的匹配,我们每次匹配失败即立刻停止当前子串的匹配,对下一个子串继续匹配。如果当前子串匹配成功,我们返回当前子串的开始位置即可。如果所有子串都匹配失败,则返回 −1。
C++ 代码实现
class Solution {
public:int strStr(string haystack, string needle) {int haystackLength = haystack.size();int needleLength = needle.size();// 如果 needle 是空字符串,按题意返回 0if (needleLength == 0) return 0;// 遍历 haystack,尝试每个可能的起始位置for (int i = 0; i <= haystackLength - needleLength; i++) {bool matchFound = true;// 比较 haystack 从 i 开始的子串,是否和 needle 匹配for (int j = 0; j < needleLength; j++) {if (haystack[i + j] != needle[j]) {matchFound = false; // 有字符不匹配,标记为 falsebreak; // 提前退出内层循环}}// 如果找到了完全匹配的位置,返回起始下标if (matchFound) {return i;}}// 没有找到匹配的子串,返回 -1return -1;}
};
复杂度分析
-
时间复杂度:O(n×m),其中 n 是字符串 haystack 的长度,m 是字符串 needle 的长度。最坏情况下我们需要将字符串 needle 与字符串 haystack 的所有长度为 m 的子串均匹配一次。
-
空间复杂度:O(1)。我们只需要常数的空间保存若干变量。
方法二:KMP 算法
算法介绍
概述
KMP 算法(Knuth-Morris-Pratt 算法)是一种用于字符串匹配的算法。KMP 算法的核心是构建一个“部分匹配表”,该表记录了字符串中每个位置与前面子串的匹配情况。由Knuth、Morris 和 Pratt 三人共同提出的。
作用
KMP主要应用在字符串匹配上。
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
前缀表(next数组)
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
前缀表记录的是:
模式串中每个位置之前(不含当前字符)最长相等的前缀与后缀的长度。
因此,要理解前缀表,首先要知道什么是前后缀。
前缀:不包含最后一个字符的子串,从开头开始。
后缀:不包含第一个字符的子串,从结尾往前数。
举个例子(模式串:ababc):
子串 | 前缀集合(不含完整串) | 后缀集合(不含完整串) |
---|---|---|
"a" | "" | "" |
"ab" | "a" | "b" |
"aba" | "a" , "ab" | "a" , "ba" |
"abab" | "a" , "ab" , "aba" | "b" , "ab" , "bab" |
"ababc" | "a" , "ab" , "aba" , "abab" | "c" , "bc" , "abc" , "babc" |
其中 :
"aba" 的最长公共前后缀是 "a",长度为 1。
"abab" 的最长公共前后缀是 "ab",长度为 2。
因此:ababc这个模式串的前缀表就是next[0,0,1,2,0]
为什么使用最长公共前后缀?
这是KMP算法的核心。当在位置i发生失配时,我们已知前面的字符是匹配的。如果模式串的前缀和后缀相同,那么我们可以利用这个信息来避免重复比较。
具体例子说明
假设我们要在文本串 "ABABCABABA" 中查找模式串 "ABABA":
文本串: A B A B C A B A B A
模式串: A B A B A↑ 失配位置
在位置4(字符C与A)发生失配。此时我们已知前4个字符"ABAB"是匹配的。
观察模式串"ABAB":
- 前缀:"A", "AB", "ABA"
- 后缀:"B", "AB", "BAB"
- 最长公共前后缀:"AB",长度为2
跳跃的原理
由于我们已知文本串中对应"ABAB"的部分,而"ABAB"的前缀"AB"等于后缀"AB",所以:
原始对齐:
文本串: A B A B C ...
模式串: A B A B A↑跳跃后对齐:
文本串: A B A B C ...
模式串: A B A B A↑
我们可以直接将模式串向右移动2位(4-2=2),让模式串的前缀"AB"对齐到之前匹配部分的后缀"AB"位置,然后继续比较。
信息复用原理的详细分析
假设我们在匹配过程中遇到以下情况:
- 文本串:text
- 模式串:pattern
- 当前比较到文本串的位置 i,模式串的位置 j
- 在位置 i 处发生失配:text[i] ≠ pattern[j]
在失配之前,我们已经成功匹配了 j 个字符,这意味着:
text[i-j], text[i-j+1], ..., text[i-1]
完全等于
pattern[0], pattern[1], ..., pattern[j-1]
现在的问题是:当在位置 i 失配时,我们应该将模式串向右移动多少位?
传统暴力算法会将模式串右移1位,从头开始匹配。但KMP算法的精髓是:如果模式串内部存在重复结构,我们可以利用这个结构跳过一些不必要的比较。
详细推导过程
步骤1:找到模式串的内部重复结构
假设 pattern[0:j] 有长度为 k 的最长公共前后缀,这意味着: pattern[0:k] = pattern[j-k:j]
具体来说:
前缀:pattern[0], pattern[1], ..., pattern[k-1]
后缀:pattern[j-k], pattern[j-k+1], ..., pattern[j-1]
这两部分完全相同
步骤2:建立等式关系
由于 text[i-j:i] = pattern[0:j](已匹配部分),我们可以得到:
text[i-j:i-j+k] = pattern[0:k](文本串中对应前缀的部分)
text[i-k:i] = pattern[j-k:j](文本串中对应后缀的部分)
步骤3:利用前缀等于后缀的性质
因为 pattern[0:k] = pattern[j-k:j],结合上面的等式:
text[i-k:i] = pattern[j-k:j] = pattern[0:k]
最终得出结论结论:文本串从位置 i-k 开始的 k 个字符,正好等于模式串从开头开始的 k 个字符。
具体例子演示
让我用一个具体例子来说明:
文本串: A B A B C A B A B A
索引: 0 1 2 3 4 5 6 7 8 9模式串: A B A B A
索引: 0 1 2 3 4
匹配过程:
第一次对齐:
文本串: A B A B C A B A B A
模式串: A B A B A0 1 2 3 4↑ ↑ ↑ ↑ ↑✓ ✓ ✓ ✓ ✗ (在位置4失配)
此时:
i = 4(文本串失配位置)
j = 4(模式串失配位置)
已匹配:text[0:4] = "ABAB" = pattern[0:4]
分析模式串的内部结构:
模式串 "ABABA" 的前4个字符 "ABAB" 的最长公共前后缀:
- 前缀:"A", "AB", "ABA"
- 后缀:"B", "AB", "BAB"
- 最长公共前后缀:"AB",长度 k = 2
所以:pattern[0:2] = "AB" = pattern[2:4] = "AB"
应用信息复用原理:
由于:
text[0:4] = "ABAB" = pattern[0:4]
pattern[0:2] = "AB" = pattern[2:4]
我们可以推导出:
text[0:2] = pattern[0:2] = "AB"(对应前缀)
text[2:4] = pattern[2:4] = "AB"(对应后缀)
因此:text[2:4] = pattern[0:2]
这意味着我们可以直接将模式串移动到位置2,让 pattern[0] 对齐到 text[2]:
原来的对齐:
文本串: A B A B C A B A B A
模式串: A B A B A↑ 失配跳跃后的对齐:
文本串: A B A B C A B A B A
模式串: A B A B A↑ 从这里继续比较
为什么这样跳跃是安全的?
关键在于我们跳过的中间位置(位置1)不可能产生匹配。
如果从位置1开始匹配:
文本串: A B A B C ...
模式串: A B A B A
这要求 text[1] = pattern[0],即 "B" = "A",显然不成立。
更一般地,任何小于最优跳跃距离的移动都不会产生匹配,因为这会要求模式串存在更长的公共前后缀,这与"最长"公共前后缀的定义矛盾。
为什么要用"最长"公共前后缀?
使用最长公共前后缀可以确保跳跃距离最大,从而跳过尽可能多的不必要比较。如果使用较短的公共前后缀,虽然仍然正确,但效率不是最优的。
构建next数组
现在,我们已经理解了next数组的作用和原理,那么我们要如何构建next数组呢?
我们要构建对应的 next 数组,目标是找到每一位之前(包含该位)的前缀和后缀中最长的“相等部分”。
步骤一: 初始化阶段
int m = pattern.size();
vector<int> next(m, 0); // 初始化 next 数组
int j = 0; // 前缀指针
关键理解:
- next[i] 表示:模式串中子串 pattern[0...i] 的最长公共前后缀长度
- j 是一个非常重要的变量,它表示当前已匹配的前缀长度
- 初始化 next[0] = 0 ,因为单个字符没有真前缀和真后缀
步骤二:从第二个字符开始遍历
我们从 i = 1
开始扫描整个 pattern
字符串,因为next[0] 已经被初始化为 0 :
for (int i = 1; i < pattern.size(); i++) {while (j > 0 && pattern[i] != pattern[j]) {// 匹配失败,回退 j 到 next[j - 1]j = next[j - 1];}if (pattern[i] == pattern[j]) {// 匹配成功,最长前后缀长度 +1j++;}next[i] = j; // 记录当前 i 的前后缀最大长度
}
难点解析:回退逻辑 (while循环)
while (j > 0 && pattern[i] != pattern[j]) {j = next[j - 1]; // 回退
}
1 回退的触发条件
- j > 0:确保j不会变成负数
- pattern[i] != pattern[j]:当前字符不匹配
2 回退的核心思想
当我们发现 pattern[i] != pattern[j] 时,说明:
- 长度为 j+1 的前后缀不存在(因为最后一个字符不匹配)
- 我们需要寻找一个更短的公共前后缀
- next[j-1] 恰好告诉我们:在前 j 个字符中,最长公共前后缀的长度
3 为什么 j = next[j-1] 是正确的?
让我用具体例子说明:
模式串: A B A B C A B A B
索引: 0 1 2 3 4 5 6 7 8
当计算 next[4] 时:
- 当前子串:"ABABC"
- i=4, j=2
- pattern[4]='C', pattern[2]='A'
- 'C' != 'A',无法匹配
核心推理过程:
第1步:理解j=2的含义
- j=2 意味着:pattern[0:2] = pattern[2:4]
- 即:"AB" = "AB"(位置0-1的"AB" 等于 位置2-3的"AB")
- 这是子串"ABAB"的最长公共前后缀
第2步:失败的扩展尝试
我们想要找到子串"ABABC"的公共前后缀,自然想到扩展现有的:
- 扩展前缀:"AB" → "ABA"(加上pattern[2])
- 扩展后缀:"AB" → "ABC"(加上pattern[4])
- 结果:"ABA" ≠ "ABC",扩展失败
第3步:寻找替代方案
既然长度3的公共前后缀不存在,我们需要寻找更短的。问题是:在"ABABC"中,除了长度3之外,还有哪些长度的公共前后缀值得尝试?
关键:任何"ABABC"的公共前后缀,其去掉最后一个字符后,必然是"ABAB"的公共前后缀
第4步:利用子结构性质
- 假设"ABABC"有长度为k的公共前后缀(k < 3)
- 那么:pattern[0:k] = pattern[5-k:5]
- 去掉最后一个字符:pattern[0:k-1] == pattern[4 - (k-1):4]
- 这说明"ABAB"必须有长度为k-1的公共前后缀
因此,我们只需要考虑"ABAB"的公共前后缀长度-1的情况
第5步:查找"ABAB"的次长公共前后缀
已知"ABAB"的最长公共前后缀长度是2("AB"),那么次长的是多少? 这就是next[j-1] = next[1]
要回答的问题:
- 子串"AB"的最长公共前后缀长度是多少?
- 答案是0(单个字符'A'和'B'不相等,没有公共前后缀)
第6步:回退到j=0
- next[1] = 0,说明"ABAB"内部除了长度2的公共前后缀,没有其他的
- 因此j回退到0,意味着我们要尝试长度1的公共前后缀
- 即比较pattern[0]='A'和pattern[4]='C'
- 'A' ≠ 'C',所以最终next[4] = 0
可视化整个过程
尝试长度3: "ABA" vs "ABC" → 失败↓ 回退
尝试长度1: "A" vs "C" → 失败↓
结论: next[4] = 0
4 为什么这个回退策略是完备的?
- 1.如果"ABABC"有长度k的公共前后缀,那么"ABAB"必须有长度k-1的公共前后缀
- 2."ABAB"的所有可能的公共前后缀长度就是:2, 0(由next数组记录)
- 3.因此"ABABC"只可能有长度3或1的公共前后缀
- 4.长度3已经验证失败,长度1也验证失败,所以答案是0
回退的本质是:利用已知的子结构信息,系统性地枚举所有可能的公共前后缀长度,而不是盲目地尝试所有长度
步骤三:匹配检查和更新
if (pattern[i] == pattern[j]) {j++;
}
next[i] = j;
逻辑说明:
- 如果 pattern[i] == pattern[j],说明可以扩展当前的公共前后缀
- j 自增表示公共前后缀长度增加1
- next[i] = j 记录当前位置的最长公共前后缀长度
重难点深度分析
难点1:j变量的双重含义
j 在算法中有两重含义:
- 作为长度:表示当前已匹配的公共前后缀长度
- 作为索引:表示下一个要比较的前缀字符位置
这种设计很巧妙,因为长度为k的前缀的下一个字符正好在位置k。
难点2:回退的连锁反应
回退可能不是一步到位的,而是连续多次回退:
// 可能的执行序列
j = 5 → next[4] = 2 → j = 2
j = 2 → next[1] = 0 → j = 0
j = 0 → 停止回退
每次回退都利用了之前计算的结果,这体现了动态规划的思想。
难点3:时间复杂度的非显然性
虽然有嵌套循环,但时间复杂度仍是 O(m):
j 在外层循环中最多增加 m-1 次
j 在内层while循环中只会减少
因此while循环的总执行次数不会超过 m-1 次 总时间复杂度:O(m)
C++ 代码实现
vector<int> buildNext(const string& pattern) {int m = pattern.size();vector<int> next(m, 0);int j = 0;for (int i = 1; i < m; i++) {while (j > 0 && pattern[i] != pattern[j]) {j = next[j - 1]; // 回退}if (pattern[i] == pattern[j]) {j++;}next[i] = j;}return next;
}
KMP 算法的主过程
构建完 next 数组后,就是 KMP 算法的主过程——用它来搜索 needle 在 haystack 中首次出现的位置。
第一步:函数框架
int strStr(string haystack, string needle) {if (needle.empty()) return 0; // 约定:空串直接返回0vector<int> next = buildNext(needle); // 第一步,构建 next 数组// 下面是匹配逻辑……
}
第二步:初始化指针
int j = 0; // j 指向 needle 中当前位置,从0开始
我们会用 j 来代表当前在 needle 中比较到的位置。i 是外层 for 循环中 haystack 的指针。
第三步:遍历 haystack 进行匹配
for (int i = 0; i < haystack.size(); i++) {// 当 mismatch 发生时,尝试跳转 needle 的位置while (j > 0 && haystack[i] != needle[j]) {j = next[j - 1]; // 回退 j 到前一个可能的匹配位置}// 如果匹配,j 往右移动一位if (haystack[i] == needle[j]) {j++;}// 如果 j 达到 needle 的长度,说明完全匹配if (j == needle.size()) {return i - j + 1; // 返回匹配的起始位置}
}
逐步解析
for (int i = 0; i < haystack.size(); i++) {
遍历主串 haystack 的每一个字符。
i 是当前主串中正在尝试匹配的字符下标。
1. 处理不匹配(失配时跳转):
while (j > 0 && haystack[i] != needle[j]) {j = next[j - 1]; // 回退 j 到前一个可能的匹配位置
}
-
当 needle[j] 与 haystack[i] 不相等,说明发生了失配。
-
此时不回退 i(主串指针不动),而是通过 next[j-1] 来回退模式串的 j:
-
表示当前模式串前面部分中有多少字符可以“复用”,避免从头匹配。
例如:
假设:
needle = "ABCDABD"
next = {0,0,0,0,1,2,0}(由之前步骤得出)
当匹配到:
haystack: ...A B C D A B X...↑ ↑ ↑ ↑ ↑ ↑
needle: A B C D A B D
- 此时 haystack[i] = 'X',而 needle[j] = 'D',不匹配。
- 于是根据 next[6-1] = next[5] = 2,我们将 j 回退到 2,继续用 needle[2] 与 haystack[i] 比较。
haystack: ... A B C D A B X ...↑i = 6 (haystack[i] = 'X')needle: A B C D A B D↑j = 2 (needle[j] = 'C')
-
继续尝试比较 haystack[6] 和 needle[2]:
-
'X' != 'C',再次 失配。
继续回退: j = next[2-1] = 0;
haystack: ... A B C D A B X ...↑i = 6 (haystack[i] = 'X')needle: A B C D A B D↑j = 0 (needle[j] = 'A')
依旧不等,j 还是 0,最终结束本轮比较。
此时,i++,重新进行下一轮匹配。
2. 处理匹配
if (haystack[i] == needle[j]) {j++;
}
- 如果当前字符匹配成功,则将模式串 j 向右移动一位,准备匹配下一个字符。
3. 检查是否完全匹配
if (j == needle.size()) {return i - j + 1; // 返回匹配的起始位置
}
-
如果 j 达到 needle.size(),说明模式串的所有字符都已匹配。
-
计算出匹配的起始位置:
当前 haystack 的下标 i
模式串已经匹配了 j 个字符(即 needle.size())
所以起始位置是 i - j + 1
第四步: 没找到
如果整个主串都遍历完了,都没有返回,说明没有匹配成功,应在循环后返回 -1:
return -1;
完整C++ 代码实现
class Solution {
public:vector<int> buildNext(const string& pattern) {int m = pattern.size();vector<int> next(m, 0);int j = 0;for (int i = 1; i < m; i++) {while (j > 0 && pattern[i] != pattern[j]) {j = next[j - 1]; // 回退}if (pattern[i] == pattern[j]) {j++;}next[i] = j;}return next;}int strStr(string haystack, string needle) {if (needle.empty()) return 0;vector<int> next = buildNext(needle);int j = 0;for (int i = 0; i < haystack.size(); i++) {// 遇到不匹配的字符时,根据 next 数组跳转while (j > 0 && haystack[i] != needle[j]) {j = next[j - 1];}if (haystack[i] == needle[j]) {j++; // 匹配,j 向后}if (j == needle.size()) {return i - j + 1; // 找到匹配,返回起始下标}}return -1; // 没找到}
};
复杂度分析
-
时间复杂度:O(n+m),其中 n 是字符串 haystack 的长度,m 是字符串 needle 的长度。我们至多需要遍历两字符串一次。
-
空间复杂度:O(m),其中 m 是字符串 needle 的长度。我们只需要保存字符串 needle 的前缀函数。