第一题:找出字符串中第一个匹配项的下标
来源: https://leetcode.cn/problems/find-the-index-of-the-first-occurrence-in-a-string/
题目: 给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回 -1。
这个题我刚开始想到的是暴力法,遍历第一个字符串,遍历每个位置,直到找出完全匹配的第一个位置。
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
n = len(haystack)
m = len(needle)
if m == 0:
return 0
# 遍历所有可能的起始位置
for i in range(n - m + 1):
if haystack[i:i+m] == needle:
return i
return -1
时间复杂度为O((n-m)*m),最坏的情况就是需要逐个字符比较,得优化一下。
所以我用了KMP算法,先处理needle生成前缀函数(部分匹配表),在匹配失败时跳过不必要的比较,从而实现线性时间复杂度。
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
n = len(haystack)
m = len(needle)
if m == 0:
return 0
# 1. 构建前缀函数数组 pi
pi = [0] * m
for i in range(1, m):
j = pi[i-1]
while j > 0 and needle[i] != needle[j]:
j = pi[j-1]
if needle[i] == needle[j]:
j += 1
pi[i] = j
# 2. 利用 pi 进行匹配
j = 0
for i in range(n):
while j > 0 and haystack[i] != needle[j]:
j = pi[j-1]
if haystack[i] == needle[j]:
j += 1
if j == m:
return i - m + 1
return -1
记录一下KMP 算法的核心 ------前缀函数:
前缀函数也叫部分匹配表,是针对模式串needle生成的一个数组pi,其中的每一个元素pi[i]表示:
对于needle[0...i]这个字串,最长的相等真前缀和真后缀的长度。
真前缀是指不包含子串本身的前缀,比如"abc"的真前缀是"a"、"ab";真后缀的定义也一样。
举个例子,以needle = "ababc"为例,我们写出它的前缀函数数组:
|---------|--------------------------------|---------|
| 子串 | 最长相等真前缀&真后缀 | pi[i] |
| "a" | 无(只有一个字符) | 0 |
| "ab" | 无("a" != "b") | 0 |
| "aba" | "a"(前缀)= "a"(后缀) | 1 |
| "abab" | "ab"(前缀) = "ab"(后缀) | 2 |
| "ababc" | 无("abab" != "bc","aba" != "c") | 0 |
最终pi = [0,0,1,2,0]。
前缀函数的核心作用:避免重复比较。
暴力匹配失败时,会把needle总体后移一位,重新从头比较,而前缀函数可以做到:失败后不从头开始,直接跳到pi[j-1]的位置继续比较(j是当前匹配失败的位置)。
举个例子:haystack = "abababc",needle = "ababc":
1、匹配到haystack[4] = "a",needle[4] = "c"时失败,前四位"abab"都匹配;
2、前缀函数表明pi[3] = 2("abab"的最长相等前后缀长度是2);
3、直接把needle后移,让needle[2]对齐haystack[4],继续比较,跳过了前两位"ab"的重复比较。
现在来拆解一下前缀函数的生成逻辑:
python
pi = [0] * m
for i in range(1, m):
j = pi[i-1]
while j > 0 and needle[i] != needle[j]:
j = pi[j-1]
if needle[i] == needle[j]:
j += 1
pi[i] = j
构造方法上面已经写了。
关键细节 :return i - m + 1 的推导
假设needle的长度是m,当j = m时,说明needle = [0,...,m-1]均匹配成功,此时 i 是主串中最后一个匹配字符的下标,起始下标 = 最后一个匹配下标 - (模式串长度 - 1) = i - (m-1) = i - m + 1。
例:主串 abcabc,模式串 abc,匹配到 i=2 时 j=3,起始下标 = 2 - 3 + 1 = 0,符合预期。
举个例子模拟一下整个过程:
主串:haystack = "abababc",模式串:needle = "ababc"
第一步:构建pi数组(前缀函数)
过程在上面,pi = [0,0,1,2,0];
| 步骤 | i | j(初始 = pi [i-1]) | while 循环判断(j>0 且 needle [i]≠needle [j]) | 字符匹配判断 | j 更新后 | pi[i] | 最终 pi 数组 |
|---|---|---|---|---|---|---|---|
| 1 | 1 | j=pi[0]=0 | j=0,不进入循环 | needle [1]=B vs needle [0]=A → 不匹配 | j=0 | 0 | [0,0,0,0,0] |
| 2 | 2 | j=pi[1]=0 | j=0,不进入循环 | needle [2]=A vs needle [0]=A → 匹配 | j=1 | 1 | [0,0,1,0,0] |
| 3 | 3 | j=pi[2]=1 | j=1>0?是;needle [3]=B vs needle [1]=B → 匹配,不进入循环 | 匹配 | j=2 | 2 | [0,0,1,2,0] |
| 4 | 4 | j=pi[3]=2 | j=2>0?是;needle [4]=C vs needle [2]=A → 不匹配 → 执行 j=pi [1]=0现在 j=0,退出循环 | needle [4]=C vs needle [0]=A → 不匹配 | j=0 | 0 | [0,0,1,2,0] |
第二步:kmp匹配过程
初始化:j=0(模式串指针),遍历 i 从 0 到 6(主串所有字符):
| 步骤 | i | haystack[i] | while 循环判断(j>0 且 haystack [i]≠needle [j]) | 字符匹配判断 | j 更新后 | j==m? | 结果 |
|---|---|---|---|---|---|---|---|
| 1 | 0 | A | j=0,不进入循环 | A == A → 匹配 | j=1 | 1≠5 | 继续 |
| 2 | 1 | B | j=1>0?是;B == B → 匹配,不进入循环 | B == B → 匹配 | j=2 | 2≠5 | 继续 |
| 3 | 2 | A | j=2>0?是;A == A → 匹配,不进入循环 | A == A → 匹配 | j=3 | 3≠5 | 继续 |
| 4 | 3 | B | j=3>0?是;B == B → 匹配,不进入循环 | B == B → 匹配 | j=4 | 4≠5 | 继续 |
| 5 | 4 | A | j=4>0?是;A vs needle [4]=C → 不匹配 → 执行 j=pi [3]=2现在 j=2>0,再判断:A vs needle [2]=A → 匹配,退出循环 | A == A → 匹配 | j=3 | 3≠5 | 继续 |
| 6 | 5 | B | j=3>0?是;B vs needle [3]=B → 匹配,不进入循环 | B == B → 匹配 | j=4 | 4≠5 | 继续 |
| 7 | 6 | C | j=4>0?是;C vs needle [4]=C → 匹配,不进入循环 | C == C → 匹配 | j=5 | 5==5 | 匹配成功 |
第三步:计算匹配起始下标
当j = 5即m = 5时触发返回,i - m + 1 = 2。
验证结果:主串 haystack 中,从下标 2 开始的子串是 "ABABC",和模式串完全匹配(haystack[2:7] = "ABABC"),结果正确。
步骤 4 结束后 j=4,步骤 5 中 haystack[4]=A 和 needle[4]=C 不匹配,所以触发回退:j = pi[j-1] = pi[3] = 2(这就是 KMP 的核心 ------ 利用 pi 数组避免主串指针 i 回退);
回退后 j=2,此时 haystack[4]=A 和 needle[2]=A 匹配,所以 j 继续增加到 3,而不是从头开始匹配(如果是暴力匹配,i 会回退到 1,效率低)。
ok,记录完毕,打了几天游戏,我又有灵感了,继续研究下一题。