LeetCode 28 找出字符串中第一个匹配项的下标
- [LeetCode 28 找出字符串中第一个匹配项的下标](#LeetCode 28 找出字符串中第一个匹配项的下标)
- [一、前置知识:`for ... else`](#一、前置知识:
for ... else) - 二、我的第一反应:先找可能的起点,再逐个字符比较
- [三、第一个问题:`break` 以后还是执行了 `return i`](#三、第一个问题:
break以后还是执行了return i) -
- [方法一:使用 `for ... else`](#方法一:使用
for ... else) - 方法二:自己用变量记录
- [方法一:使用 `for ... else`](#方法一:使用
- [四、第二个问题:`i` 不能一直走到 haystack 最后](#四、第二个问题:
i不能一直走到 haystack 最后) - [五、完整代码:`for ... else`](#五、完整代码:
for ... else) - [六、使用 `matched` 的写法](#六、使用
matched的写法) - [七、还能怎么优化 -- KMP](#七、还能怎么优化 -- KMP)
-
- [KMP 是怎么利用前面信息的?](#KMP 是怎么利用前面信息的?)
- [`lps` 是什么?](#
lps是什么?) - [先构造 lps](#先构造 lps)
-
- [如果不相同,为什么不能直接把 `length` 变成 0?](#如果不相同,为什么不能直接把
length变成 0?) - [如果已经退到 `length == 0` 还是不相同呢?](#如果已经退到
length == 0还是不相同呢?)
- [如果不相同,为什么不能直接把 `length` 变成 0?](#如果不相同,为什么不能直接把
- [再使用 `lps` 进行匹配](#再使用
lps进行匹配) - 完整代码
LeetCode 28 找出字符串中第一个匹配项的下标
题目给出两个字符串 haystack 和 needle,要求在 haystack 中找到 needle 第一次完整出现的位置。如果找不到,就返回 -1。
例如:
text
haystack = "sadbutsad"
needle = "sad"
sad 分别出现在下标 0 和 6,题目要求的是第一个匹配位置,所以返回:
text
0
所以, 这道题最直接的想法就是:把 haystack 中每一个可能的位置都当成起点,然后从这个位置开始,一个字符一个字符和 needle 比较。
一、前置知识:for ... else
在 Python 中,for 可以和 else 搭配:
python
for x in nums:
if 某个条件:
break
else:
# for 没有被 break 打断时执行
这里的 else 不是像往常一样和 if 配对,而是和 for 配对。
规则是:
text
for 正常遍历结束 → 执行 else
for 中途遇到 break → 不执行 else
例如:
python
for x in [1, 2, 3]:
if x == 4:
break
else:
print("没有找到")
整个循环都没有触发 break,因此最后会执行 else。
所以, 这种写法特别适合这样的逻辑:
我要检查一系列条件,只要其中一个失败就
break;如果一直没有失败,就说明全部检查通过。
这正好可以用在这道字符串匹配题里。
二、我的第一反应:先找可能的起点,再逐个字符比较
我的想法是先遍历 haystack:
python
for i in range(len(haystack)):
这里的 i 表示:
当前尝试把
haystack[i]当作needle的起点。
比如:
text
haystack = "sadbutsad"
needle = "sad"
如果:
text
i = 0
首先比较:
text
haystack[0] 和 needle[0]
如果第一个字符都不同,那当前位置肯定不可能匹配,可以直接:
python
continue
去尝试下一个起点。
如果第一个字符相同,再继续比较后面的字符:
python
for j in range(1, len(needle)):
此时可以设定:
text
haystack[i + j]
表示从当前起点 i 往后移动 j 位,而:
text
needle[j]
表示 needle 中对应位置的字符。
所以整体思路其实就是:
text
枚举起点 i
→ 第一个字符不同,直接换下一个起点
→ 第一个字符相同,继续比较后面的字符
→ 中间有一个字符不同,当前起点失败
→ 所有字符都相同,返回当前 i
我最开始按照这个思路写出了:
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
for i in range(len(haystack)):
if haystack[i] != needle[0]:
continue
else:
for j in range(1, len(needle)):
if haystack[i+j] != needle[j]:
break
return i
return -1
思路本身没有问题,但是这里有两个容易忽略的漏洞。
三、第一个问题:break 以后还是执行了 return i
原来的代码中:
python
for j in range(1, len(needle)):
if haystack[i + j] != needle[j]:
break
return i
我原本的想法是:
text
发现字符不同
→ break
→ 当前 i 失败
但实际上,break 只会退出里面的 for j,退出以后程序会继续往下执行,于是马上遇到:
python
return i
所以实际逻辑变成了:
text
第一个字符相同
→ 开始比较后面的字符
→ 中间发现不同
→ break
→ 还是 return i
也就是说,代码没有真正区分:
text
因为匹配失败而退出循环
和
因为所有字符都比较成功而结束循环
这里可以有两种解决方式。
方法一:使用 for ... else
也就是前面前置知识教的:
python
for j in range(1, len(needle)):
if haystack[i + j] != needle[j]:
break
else:
return i
如果中间有字符不同,就会触发 break,因此不会进入 else;
只有整个 for j 正常结束,也就是所有字符都匹配成功,才会执行:
python
return i
所以这里的逻辑刚好是:
text
出现不同
→ break
→ 当前 i 失败
从头到尾都没有 break
→ 所有字符匹配成功
→ return i
方法二:自己用变量记录
如果不用 for ... else,也可以自己用一个变量记录当前起点是否匹配成功:
python
matched = True
for j in range(1, len(needle)):
if haystack[i + j] != needle[j]:
matched = False
break
if matched:
return i
这里的 matched 表示:
当前这个起点
i到目前为止,有没有发现匹配失败。
可能会疑惑:为什么一开始就写:
python
matched = True
明明后面的字符还没有比较,怎么就先认为匹配成功了?
其实, 这里不是在说"已经证明成功",而是在表示:
先假设当前起点可以匹配,只要后面发现任何一个反例,就把它改成
False。
因为前面已经检查过:
python
if haystack[i] != needle[0]:
continue
所以能走到 matched = True 这里时,至少第一个字符已经相同。
接下来只需要检查剩余字符。
例如:
text
haystack = "sax..."
needle = "sad"
i = 0
第一个字符:
text
s == s
已经通过,所以先:
python
matched = True
然后继续检查:
text
a == a
仍然没有发现问题,matched 保持 True。
再检查:
text
x != d
这时候终于发现当前起点不成立,于是:
python
matched = False
break
离开内层循环后:
python
if matched:
因为现在是 False,所以不会 return i,外层循环会继续尝试下一个起点。
反过来,如果整个内层循环都没有找到任何不同:
text
第 1 位相同
第 2 位相同
第 3 位相同
......
那么就没有任何地方执行:
python
matched = False
所以 matched 一直保持 True。
这时候才说明:
所有需要检查的字符都通过了,因此当前
i确实是一个完整匹配的位置。
于是:
python
if matched:
return i
就是安全的。
可以把这个逻辑理解成:
text
matched = True → 暂时没有发现问题
发现任何一个字符不同 → matched = False
整个检查结束后还是 True
→ 从头到尾都没有发现问题
→ 当前起点匹配成功
这里还有一个很重要的细节:matched = True 必须放在每一次新的 i 尝试里面。
也就是说:
python
for i in ...:
matched = True
每换一个新的起点,都要重新开始判断。
否则前一个起点失败后留下的 False,可能会影响下一个起点。
这个方法最核心的就是:
matched = True是"目前还没有找到失败的证据";
只要出现一次不匹配,就立刻改成False。
两种方法本质一样:
text
for ... else → 用"有没有 break"判断是否匹配成功
matched → 自己使用布尔值保存匹配状态
四、第二个问题:i 不能一直走到 haystack 最后
原来写的是:
python
for i in range(len(haystack)):
但后面还要访问:
python
haystack[i + j]
所以如果 i 已经走得太靠后,而剩余字符数量又不足以放下整个 needle,就可能发生数组越界。
例如:
text
haystack = "abcde"
needle = "cde"
haystack 长度为 5,needle 长度为 3。
合法起点其实只有:
text
i = 0
i = 1
i = 2
因为当:
text
i = 2
检查的位置是:
text
2、3、4
刚好还能放下长度为 3 的 needle。
但如果:
text
i = 3
后面只剩下两个字符:
text
d e
无论内容是什么,都已经不可能匹配长度为 3 的 needle,所以这些位置根本没有必要继续尝试。
因此最后一个合法起点应该是:
text
len(haystack) - len(needle)
因为 range 的右边取不到,所以应该写成:
python
for i in range(len(haystack) - len(needle) + 1):
这样既避免越界,也直接排除了那些长度上就不可能成功的位置。
五、完整代码:for ... else
结合上面两个问题,可以写成:
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
for i in range(len(haystack) - len(needle) + 1):
if haystack[i] != needle[0]:
continue
for j in range(1, len(needle)):
if haystack[i + j] != needle[j]:
break
else:
return i
return -1
这里可以把两个循环的职责分开理解:
text
i → 枚举 needle 可能出现的起点
j → 从这个起点开始,检查 needle 中的每一个字符
如果当前起点失败,就让外层的 i 继续寻找下一个位置;
如果整个 j 循环都没有失败,就直接返回当前 i。
六、使用 matched 的写法
如果觉得 for ... else 比较陌生,也可以使用 matched:
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
for i in range(len(haystack) - len(needle) + 1):
if haystack[i] != needle[0]:
continue
matched = True
for j in range(1, len(needle)):
if haystack[i + j] != needle[j]:
matched = False
break
if matched:
return i
return -1
七、还能怎么优化 -- KMP
目前这套方法本质上属于暴力字符串匹配。
假设:
text
haystack 长度 = n
needle 长度 = m
最多需要尝试大约 n - m + 1 个起点,而每个起点最坏需要比较 m 个字符,所以最坏时间复杂度是:
text
O(n × m)
例如:
text
haystack = "aaaaaaaaaaaaaaaaab"
needle = "aaaaab"
很多起点都会出现:
text
前面连续匹配很多个 a
→ 到最后才发现不同
→ 换下一个起点
→ 又把前面的 a 重新比较一遍
这里会产生浪费:匹配失败以后,已经比较过的信息几乎全部丢掉了。
这和前面做其他优化题时的思路很像,可以继续问:
当前这次匹配虽然失败了,但是已经匹配成功的那一部分信息,能不能留给下一次使用?
这就会引出经典的 KMP(Knuth-Morris-Pratt)字符串匹配算法。
KMP 会提前分析 needle 本身的前缀和后缀关系,使得匹配失败时,不需要让 haystack 的比较位置完全退回重新开始,而是利用已经得到的信息继续匹配。
它可以把最坏时间复杂度从:
text
O(n × m)
降低到:
text
O(n + m)
不过对于这道简单题来说,先把现在这种"双循环逐字符匹配"的逻辑完全理解清楚已经足够。KMP 更适合作为后续字符串算法的扩展再单独学习。
这里就简单说说:
继续往下看,KMP 真正要解决的就是:
既然前面已经有一部分匹配成功了,那么失败以后,能不能不要全部推倒重来,而是利用这部分已经匹配成功的信息?
KMP 是怎么利用前面信息的?
例如:
text
haystack = "ababab..."
needle = "ababc"
从开头开始匹配:
text
haystack: a b a b a b ...
needle: a b a b c
✓ ✓ ✓ ✓ ×
前面的 "abab" 已经匹配成功,只是比较下一个字符时出现了:
text
haystack:a
needle: c
暴力方法会把当前起点判定为失败,然后换一个新的起点,再从 needle[0] 开始比较。这样做的问题是:前面已经成功比较过的 "abab" 信息全部被丢掉了。
KMP 会继续观察这段已经匹配成功的 "abab":
text
abab
^^ 前缀 "ab"
abab
^^ 后缀 "ab"
可以发现,它的前缀 "ab" 和后缀 "ab" 是相同的。
这意味着,虽然完整匹配失败了,但是 haystack 刚刚匹配过的末尾两个字符已经确定是 "ab",而 needle 开头两个字符也正好是 "ab"。
所以这两个字符没有必要重新比较,可以直接把 needle 调整到一个新的位置继续:
text
haystack: a b a b a ...
needle: a b a b c
✓ ✓
这里最关键的一点是:
haystack不需要回退,我们只需要知道needle应该退到哪里,才能继续利用前面已经匹配成功的信息。
为了提前知道"失败以后应该退到哪里",KMP 会根据 needle 构造一个 lps 数组。
lps 是什么?
LPS 全称是:
Longest Proper Prefix which is also Suffix
先解释一下这里的前缀和后缀。
对于一个字符串:
- Prefix(前缀):从字符串最左边开始截取的一段内容。
- Suffix(后缀):从字符串最右边开始截取的一段内容。
例如字符串 "abab" 的前缀有 "a"、"ab"、"aba",后缀有 "b"、"ab"、"bab"。
其中最长的相同前缀和后缀是 "ab",长度为 2。
这里的 Proper Prefix / Proper Suffix 表示不能把整个字符串自己算进去,否则任何字符串都可以直接拿自己和自己比较,就没有意义了。
所以:
lps[i]表示:对于needle[0:i+1]这一段字符串,它最长的相同前缀和后缀有多长。
例如:
text
needle = "ababc"
下标: 0 1 2 3 4
字符: a b a b c
lps: 0 0 1 2 0
当 i = 2 时,当前这一段是 "aba",它最长的相同前后缀是 "a":
text
前缀:"a"
后缀:"a"
所以:
text
lps[2] = 1
当 i = 3 时,当前这一段是 "abab",最长的相同前后缀是 "ab":
text
前缀:"ab"
后缀:"ab"
所以:
text
lps[3] = 2
这个 2 就是在告诉我们:如果已经成功匹配 "abab",但下一个字符失败,那么前面还有长度为 2 的 "ab" 可以继续复用,不需要完全从头开始。
先构造 lps
直接看构造代码会比较绕,所以先看我们到底在做什么。
假设前一个位置已经算出了:
text
lps[i - 1] = length
这意味着:
到
i - 1为止,已经存在长度为length的相同前缀和后缀。
现在加入新的 needle[i],我们首先会想:
原来这组相同前后缀,能不能再往后延长一个字符?
这就是为什么要去比较:
python
needle[i] == needle[length]
这里的 needle[i] 是当前新加入的字符,而 needle[length] 是前缀中下一个等待匹配的字符。
例如当前已经有:
text
"abab"
最长相同前后缀:"ab"
length = 2
如果再加入 "a",变成:
text
"ababa"
原来的 "ab" 想继续延长,就需要比较:
text
当前新字符:needle[4] = "a"
前缀下一个字符:needle[2] = "a"
两者相同,所以原来的:
text
"ab" == "ab"
可以延长成:
text
"aba" == "aba"
因此:
python
length += 1
lps[i] = length
此时 lps[4] = 3。
所以第一种情况就是:
python
if needle[i] == needle[length]:
length += 1
lps[i] = length
i += 1
意思就是:
当前字符能够接在已有的相同前后缀后面,所以相同前后缀长度增加 1。
如果不相同,为什么不能直接把 length 变成 0?
这里的"不相同",具体指的是:
python
needle[i] != needle[length]
那这两个位置为什么要比较?
因为此时我们已经知道:前面存在一组长度为 length 的相同前缀和后缀,现在想看看它们能不能再同时延长一个字符。
举个例子:
text
needle = "aabaaab"
假设现在准备计算 i = 5 这一位,也就是当前这一段:
text
a a b a a a
0 1 2 3 4 5
↑
i
在加入第 5 个字符之前,我们已经算出了:
text
"aabaa"
它最长的相同前缀和后缀是:
text
前缀:"aa"
后缀:"aa"
所以:
text
length = 2
可以把当前状态看成:
text
a a b a a
^^^^ 前缀 "aa"
^^^^ 后缀 "aa"
现在又来了一个新的字符:
text
needle[5] = "a"
我们首先想:
原来的
"aa" == "aa",能不能继续延长成长度 3?
如果想让长度从 2 变成 3,前缀这一边接下来应该是什么字符?
原来的前缀已经用了:
text
needle[0:2] = "aa"
所以下一个字符就是:
python
needle[length]
也就是:
text
needle[2] = "b"
而后缀这一边新加入的字符就是:
text
needle[i] = needle[5] = "a"
于是我们比较:
text
needle[i] = "a"
needle[length] = "b"
发现:
text
"a" != "b"
这就是代码里的:
python
needle[i] != needle[length]
它表达的不是"整个字符串失败了",而只是:
原来长度为 2 的
"aa"这组前后缀,不能继续延长成长度 3。
因为如果能延长,本来应该得到:
text
前缀:"aab"
后缀:"aaa"
但:
text
"aab" != "aaa"
所以长度 3 这条路失败了。
但为什么不能直接:
python
length = 0
因为:
长度 2 的方案失败,不代表长度 1 的方案也失败。
我们原来用的是:
text
"aa"
而 "aa" 自己还有一个更短的相同前缀和后缀:
text
"a"
也就是说,可以退一步,不再尝试:
text
长度 2:"aa"
而改成尝试:
text
长度 1:"a"
这个"更短的可用长度"之前其实已经算过了:
text
lps[1] = 1
所以代码写成:
python
length = lps[length - 1]
原来:
text
length = 2
变成:
text
length = lps[1] = 1
这时候 i 不动,仍然是:
text
i = 5
needle[i] = "a"
只是换成长度为 1 的方案重新试。
现在比较:
text
needle[i] = needle[5] = "a"
needle[length] = needle[1] = "a"
这次:
text
"a" == "a"
成功了。
所以原来的长度 1 可以继续延长:
text
length = 1 + 1 = 2
最终:
text
lps[5] = 2
再看当前字符串:
text
"aabaaa"
它确实有:
text
前缀:"aa"
后缀:"aa"
所以答案正好是:
text
lps[5] = 2
因此, 流程就是:
text
原来已经有 length = 2 → 前后缀都是 "aa"
想继续延长 → 比较 needle[i] 和 needle[length]
"a" != "b" → 长度 2 的方案无法继续延长
但不是直接清零 → 因为 "aa" 自己还有更短的可用前后缀 "a"
length = lps[length - 1] → length 从 2 退到 1
还是拿同一个 needle[i] = "a" 再试
"a" == "a" → 长度 1 的方案可以延长
最终 length = 2 → lps[i] = 2
所以, 写代码为:
python
elif length > 0:
length = lps[length - 1]
当前最长的前后缀没办法继续延长,那就退到下一个更短、已经被证明有效的前后缀,再拿当前字符重新尝试。
而 i 为什么不增加,也就很好理解了:因为我们还没有算完 lps[i],只是换了一个候选长度,当前这个字符还得继续试。
如果已经退到 length == 0 还是不相同呢?
这时候说明已经没有任何更短的前后缀可以继续尝试了,因此:
python
lps[i] = 0
i += 1
也就是:
当前这一段不存在相同的非空前缀和后缀,当前答案就是
0,然后继续计算下一格。
所以整个构造过程其实只有三个问题:
text
1. 当前最长前后缀还能继续延长吗?
能
→ length += 1
→ lps[i] = length
→ i += 1
2. 不能延长,但还有更短的前后缀可以尝试吗?
有
→ length = lps[length - 1]
→ i 不动
→ 用当前字符重新尝试
3. 已经没有任何前后缀可以尝试了吗?
是
→ lps[i] = 0
→ i += 1
对应代码就是:
python
lps = [0] * len(needle)
length = 0
i = 1
while i < len(needle):
if needle[i] == needle[length]:
length += 1
lps[i] = length
i += 1
elif length > 0:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
这里两个变量的职责就是:
i:当前正在计算哪一个lps[i]length:当前正在尝试的相同前后缀长度,同时也是前缀中下一个需要比较的位置
再使用 lps 进行匹配
lps 构造完成以后,就可以真正开始比较 haystack 和 needle。
python
i = 0
j = 0
while i < len(haystack):
if haystack[i] == needle[j]:
i += 1
j += 1
if j == len(needle):
return i - j
else:
if j > 0:
j = lps[j - 1]
else:
i += 1
这里:
i:当前检查haystack的哪个字符j:当前检查needle的哪个字符
如果:
python
haystack[i] == needle[j]
说明当前字符匹配成功,两个指针一起向后移动:
python
i += 1
j += 1
如果:
python
j == len(needle)
说明整个 needle 已经匹配完成。
此时 i 已经走到了匹配内容的后一个位置,而 j 正好等于 needle 的长度,所以起点就是:
python
i - j
如果字符不同,则分两种情况。
如果:
python
j == 0
说明连 needle 的第一个字符都没有匹配成功,前面没有任何信息可以继续利用,所以只能:
python
i += 1
继续检查 haystack 的下一个字符。
如果:
python
j > 0
说明前面已经有一部分字符匹配成功,这时候 haystack 的 i 不需要回退,只调整:
python
j = lps[j - 1]
意思就是:
根据刚才已经匹配成功的那一段,找到一个更短但仍然可以复用的前后缀,然后继续拿当前的
haystack[i]比较。
所以 KMP 和暴力方法最大的区别就是:
暴力:
text
匹配失败
→ 换一个起点
→ needle 从头重新比较
→ 前面已经匹配成功的信息基本全部丢掉
而 KMP 是:
text
匹配失败
→ haystack 不回退
→ 根据 lps 调整 needle 的位置
→ 继续复用前面已经匹配成功的信息
完整代码
python
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
# 1. 构造 lps
lps = [0] * len(needle)
length = 0
i = 1
while i < len(needle):
if needle[i] == needle[length]:
length += 1
lps[i] = length
i += 1
elif length > 0:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
# 2. KMP 匹配
i = 0
j = 0
while i < len(haystack):
if haystack[i] == needle[j]:
i += 1
j += 1
if j == len(needle):
return i - j
else:
if j > 0:
j = lps[j - 1]
else:
i += 1
return -1
所以 KMP 最值得记住的并不是 lps 的代码,而是两个"复用":
构造
lps时,如果最长前后缀失败,就利用之前算好的lps去寻找更短的前后缀;真正匹配时,如果needle匹配失败,又利用lps决定j应该退到哪里。
也就是说,KMP 从头到尾都在做同一件事:
已经算过的信息不要轻易丢掉,能复用就继续复用。
🚩 如果只是 Python 实际开发,不要求手写匹配算法,也可以直接使用:
python
haystack.find(needle)
它本身就会返回第一次出现的位置,找不到则返回 -1。
但在算法题里,自己实现这一遍的意义主要还是理解:
text
枚举起点
→ 逐字符验证
→ 失败后换起点
→ 成功后返回第一个匹配位置
以及进一步思考:
暴力算法中有哪些已经计算过的信息被重复丢弃了,而这些信息能不能被下一轮复用。