问题描述
LeetCode 第 28 题要求我们实现 strStr()
函数。给定两个字符串 haystack
和 needle
,我们需要在 haystack
字符串中找出 needle
字符串第一次出现的位置(索引值)。如果 needle
不是 haystack
的一部分,则返回 -1
。
示例:
- 输入: haystack = “sadbutsad”, needle = “sad”
- 输出: 0
-
解释: “sad” 在索引 0 和 6 处出现。第一次出现是在索引 0 ,所以我们返回 0 。
- 输入: haystack = “leetcode”, needle = “leeto”
- 输出: -1
- 解释: “leeto” 没有在 “leetcode” 中出现,所以返回 -1 。
这是一个经典的字符串匹配问题。本文将详细分析四种不同的解法,从最直观的暴力匹配到最高效的 KMP 算法,再到利用 Java 内置函数,帮助你全面理解这个问题。
解法一:暴力匹配 (Brute Force)
这是最容易想到的方法。我们遍历 haystack
中的每个字符,并将其视为潜在的匹配起点。然后,从这个起点开始,逐一比较 haystack
的后续字符与 needle
的字符是否相同。
代码实现
class Solution {
public int strStr(String haystack, String needle) {
int m = needle.length();
int n = haystack.length();
if (m > n) return -1;
// i 只需遍历到 n-m 即可,因为再往后 haystack 剩余的长度已不足以容纳 needle
for (int i = 0; i <= n - m; i++) {
int j = 0;
while (j < m && haystack.charAt(i + j) == needle.charAt(j)) {
j++;
}
// 如果 j 成功遍历完 needle 的所有字符,说明匹配成功
if (j == m) {
return i;
}
}
return -1;
}
}
算法分析
这里的条件 i <= n - m
是因为我们要在 haystack
中寻找 needle
的完整匹配。假设 haystack
长度为 n
,needle
长度为 m
,那么从 haystack
的第 i
个位置开始,只有当 i + m - 1 < n
(即 i <= n - m
)时,haystack
剩余的子串长度才足够与 needle
进行完整比较。如果 i > n - m
,那么从这个位置开始,haystack
剩下的字符已经不足以容纳一个完整的 needle
,所以无需再尝试匹配。
举例说明:
haystack = "abcde"
,长度n = 5
needle = "cd"
,长度m = 2
- 最后一次合法的起点是
i = 3
,因为haystack[3..4] = "de"
,再往后就不够 2 个字符了。
因此,循环条件写作 i <= n - m
,确保每一次匹配尝试都不会越界,并且不会做无意义的比较。
- 执行流程:
- 外层循环
i
控制haystack
的起始匹配点。 - 内层循环
j
负责逐个字符地比较haystack.substring(i, i+m)
与needle
。 - 如果内层循环因为字符不匹配而中断,则外层循环
i
增加 1,从下一个位置重新开始匹配。 - 如果内层循环顺利完成(
j
的值等于m
),说明找到了一个完整的匹配,立即返回当前的起始索引i
。
- 外层循环
- 时间复杂度: O(n * m)
n
是haystack
的长度,m
是needle
的长度。- 在最坏的情况下(例如
haystack
= “aaaaaaaaab”,needle
= “aaab”),对于haystack
的每一个起始位置,我们都需要比较m
次。总共大约需要(n-m) * m
次比较,因此时间复杂度为 O(n * m)。
- 空间复杂度: O(1)
- 我们只使用了几个额外的变量 (
i
,j
,n
,m
),所以空间消耗是常数级别的。
- 我们只使用了几个额外的变量 (
解法二:KMP 算法
KMP (Knuth-Morris-Pratt) 算法是一种高效的字符串匹配算法,它通过预处理 needle
字符串来避免在匹配过程中进行不必要的回溯。其核心思想是,当发生字符不匹配时,能够利用已经匹配过的信息,将 needle
字符串向右“滑动”尽可能远的距离,而不是仅仅移动一位。
KMP 核心:LPS 数组
KMP 算法的关键在于一个预先计算好的 LPS (Longest Proper Prefix which is also Suffix) 数组,有时也叫 next
数组。对于 needle
中的任意位置 i
,lps[i]
表示 needle
的子串 needle[0...i]
中,最长的相等的前缀和后缀的长度。
- 前缀 (Prefix): 指不包含最后一个字符的所有子串。
- 后缀 (Suffix): 指不包含第一个字符的所有子串。
示例: 对于 needle = "aabaaf"
i=0
, “a”: 前后缀为空,lps[0]=0
i=1
, “aa”: 最长相等前后缀为 “a”,长度 1,lps[1]=1
i=2
, “aab”: 无相等前后缀,lps[2]=0
i=3
, “aaba”: 最长相等前后缀为 “a”,长度 1,lps[3]=1
i=4
, “aabaa”: 最长相等前后缀为 “aa”,长度 2,lps[4]=2
i=5
, “aabaaf”: 无相等前后缀,lps[5]=0
所以,lps
数组为[0, 1, 0, 1, 2, 0]
。
代码实现
class Solution {
public int strStr(String haystack, String needle) {
int m = needle.length();
int n = haystack.length();
if (m > n) return -1;
if (m == 0) return 0;
int[] lps = buildLPS(needle);
int i = 0; // haystack 的指针
int j = 0; // needle 的指针
while (i < n) {
if (haystack.charAt(i) == needle.charAt(j)) {
i++;
j++;
if (j == m) {
return i - j; // 匹配成功,返回起始位置
}
} else {
if (j > 0) {
// 发生不匹配,根据 lps 数组移动 needle 指针 j
// i 不需要回溯
j = lps[j - 1];
} else {
// j 已经是 0,说明 needle 的第一个字符就不匹配
// 直接移动 haystack 指针 i
i++;
}
}
}
return -1;
}
// 构建 LPS 数组
private int[] buildLPS(String needle) {
int m = needle.length();
int[] lps = new int[m];
int length = 0; // 当前最长相等前后缀的长度
int i = 1;
while (i < m) {
if (needle.charAt(i) == needle.charAt(length)) {
length++;
lps[i] = length;
i++;
} else {
if (length > 0) {
// 回溯到上一个可能匹配的位置
length = lps[length - 1];
} else {
lps[i] = 0;
i++;
}
}
}
return lps;
}
}
算法分析
- 执行流程:
- 预处理: 调用
buildLPS
函数为needle
生成lps
数组。这个过程本身也是一个动态规划。 - 匹配:
- 使用指针
i
遍历haystack
,指针j
遍历needle
。i
永远不会回退。 - 如果
haystack[i] == needle[j]
,两个指针都向前移动。 - 如果不匹配,
j
会根据lps[j-1]
的值进行回退,相当于needle
向右滑动了一段距离,而i
保持不变。这个回退操作利用了needle
自身的对称性,跳过了暴力解法中冗余的比较。
- 使用指针
- 预处理: 调用
- 时间复杂度: O(n + m)
buildLPS
函数的时间复杂度为 O(m)。strStr
主函数的匹配过程,虽然看起来有回溯,但可以证明指针i
和j
的移动总次数是线性的,所以时间复杂度为 O(n)。- 总时间复杂度为 O(n + m)。
- 空间复杂度: O(m)
- 需要一个
lps
数组来存储预处理结果,其大小与needle
的长度成正比。
- 需要一个
解法三:使用 Java 内置 indexOf
方法
在实际开发中,最直接、最推荐的方法就是使用语言标准库提供的内置函数。Java 的 String
类提供了 indexOf
方法,其功能与 strStr
完全相同。
代码实现
class Solution {
public int strStr(String haystack, String needle) {
// 无需手动处理 m > n 的情况,indexOf 内部会处理
return haystack.indexOf(needle);
}
}
算法分析
- 执行流程:
indexOf
方法的底层实现经过了高度优化。在不同的 JDK 版本中,其实现可能有所不同。通常,它会结合多种算法的优点。对于较短的needle
,它可能使用类似于暴力匹配的算法;对于较长的needle
,它可能采用更高级的算法(如 Boyer-Moore 或其变体),这些算法通常比 KMP 在平均情况下的表现更好。
- 时间复杂度: 平均 O(n * m),但实践中非常快
- 理论上,其最坏时间复杂度仍然是 O(n * m),但在绝大多数实际应用场景中,由于底层的优化和 native 实现,它的性能远超我们自己实现的暴力匹配,通常被认为是解决此问题的最佳实践。
- 空间复杂度: O(1)
- 从用户的角度看,没有额外的空间开销。
解法四:使用 substring
和 startsWith
这种方法也是一种变相的暴力匹配,通过循环截取 haystack
的子字符串,然后检查该子字符串是否以 needle
开头。
代码实现
class Solution {
public int strStr(String haystack, String needle) {
int m = needle.length();
int n = haystack.length();
if (m > n) return -1;
for (int i = 0; i <= n - m; i++) {
// 截取子串并检查
if (haystack.substring(i).startsWith(needle)) {
return i;
}
}
return -1;
}
}
算法分析
- 执行流程:
- 循环遍历
haystack
的所有可能起始点i
。 - 在每次循环中,
haystack.substring(i)
会创建一个新的字符串对象。这是一个非常耗时的操作。 startsWith(needle)
再对这个新生成的子串进行字符比较。
- 循环遍历
- 时间复杂度: O(n * m),但常数因子很大
substring()
操作在现代 Java 中(Java 7u6 之后)需要复制字符数组,其时间复杂度为 O(k),其中 k 是子串的长度。在这里,子串长度最大为n
。startsWith()
的复杂度为 O(m)。- 循环执行
n-m
次,每次都创建一个新对象并进行比较,导致其性能通常比第一种暴力解法还要差。频繁的内存分配和垃圾回收会带来巨大的额外开销。
- 空间复杂度: O(n)
- 在循环的每一步中,
substring(i)
都会创建一个新的字符串。最长的子串长度为n
,因此空间复杂度为 O(n)。
- 在循环的每一步中,
总结与对比
解法 | 时间复杂度 | 空间复杂度 | 实现难度 | 推荐指数 |
---|---|---|---|---|
暴力匹配 | O(n * m) | O(1) | ★☆☆☆☆ (简单) | ★★☆☆☆ (作为基础理解) |
KMP 算法 | O(n + m) | O(m) | ★★★★★ (复杂) | ★★★★☆ (面试、算法竞赛) |
内置 indexOf |
O(n * m) (理论) | O(1) | ☆☆☆☆☆ (极简) | ★★★★★ (实际开发首选) |
substring + startsWith |
O(n * m) (高开销) | O(n) | ★☆☆☆☆ (简单) | ☆☆☆☆☆ (不推荐) |
结论
- 日常开发: 毫不犹豫地使用 解法三 (
indexOf
)。它最简洁、可读性最好,并且性能经过了高度优化。 - 求职面试/算法学习: 解法二 (KMP) 是必须掌握的。它展示了你对高级字符串算法的理解,是优化字符串匹配问题的标准答案。解法一 (暴力匹配) 也需要会写,并能分析其复杂度,作为优化前的基准。
- 应避免的方案: 解法四 (
substring
+startsWith
) 是一个典型的反例。虽然代码看起来很直观,但由于在循环中创建了大量临时对象,其性能和资源消耗都非常差,应该避免使用。