当前位置: 首页 > news >正文

字符串匹配(重点解析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 的前缀函数。

http://www.dtcms.com/a/320052.html

相关文章:

  • 6 大模块!重构物业运营方式
  • 跨境电商增长突围:多维变局下的战略重构与技术赋能
  • 数智先锋 | Bonree ONE 赋能通威股份有限公司提升全栈可观测性能力
  • 深入解析NVIDIA Nsight工具套件:原理、功能与实战指南
  • 房产证识别在房产行业的技术实现及应用原理
  • Python Socket 脚本深度解析与开发指南
  • 扣扣号码展示网站源码_号码售卖展示系统源码 全开源 带后台(源码下载)
  • 5、倒计时翻页效果
  • 工作任务管理
  • 《C语言》指针练习题--1
  • Python入门Day17:函数式编程(map/filter/reduce/lambda)
  • 浏览器渲染与GPU进程通信图解
  • Numpy科学计算与数据分析:Numpy数组操作入门:合并、分割与重塑
  • PWM常用库函数(STC8系列)
  • 【Linux基础知识系列】第八十七篇 - 使用df命令查看磁盘空间
  • 橙河网络:Cint站点如何注册?好做吗?
  • 街道垃圾识别准确率↑32%:陌讯多模态融合算法实战解析
  • 解锁制药新质生产力:合规与效率双赢的数字化转型之道
  • 基于肌电信号的神经网络动作识别系统
  • docker mysql 5.6
  • CSS--:root指定变量,其他元素引用
  • 【题解】洛谷P3172 [CQOI2015] 选数[杜教筛]
  • 【mtcnn】--论文详解重点001之窗口滑动~
  • 板块三章节4——iSCSI 服务器(待更新)
  • python数据结构与算法(基础)
  • 栅栏密码的加密解密原理
  • RISCV instr 第31-40章
  • 钢卷矫平机背后的材料科学
  • 10-netty基础-手写rpc-定义协议头-02
  • 进程、网络通信方法