在字符串处理领域,"子串匹配"是经典基础问题,LeetCode 28 题就围绕这一核心展开。题目要求在主串 haystack 中找到子串 needle 第一个匹配项的起始下标,若不存在则返回 -1。这道题不仅考察对字符串遍历的基础理解,更能延伸出高效算法的设计思路。本文将详细解析两种实现方案:优化后的暴力匹配法与经典的 KMP 算法,带你从基础到进阶吃透这道题。
一、题目核心解读
1. 题目边界条件
-
若
needle为空字符串,按行业惯例返回 0(虽题目输入可能不包含此情况,但代码需兼容); -
若
haystack长度小于needle,直接返回 -1(长度不足无法匹配); -
匹配需严格连续,返回第一个完全匹配子串的起始下标。
2. 示例场景
-
输入:
haystack = "sadbutsad", needle = "sad",输出:0(子串从下标 0 开始匹配); -
输入:
haystack = "leetcode", needle = "leeto",输出:-1(无匹配子串); -
输入:
haystack = "abcdabcdabcdef", needle = "abcdabcdabcdef",输出:4(需跨部分匹配后复用前缀)。
二、实现方案一:优化后的暴力匹配法(单循环版)
1. 代码实现
typescript
function strStr_1(haystack: string, needle: string): number {
const hL = haystack.length;
const nL = needle.length;
if (nL === 0) return 0;
if (hL < nL) return -1;
let resIndex = -1;
for (let i = 0; i < hL; i++) {
if (resIndex !== -1) {
// 当前匹配的位置(相对于needle的下标)
const matchPos = i - resIndex;
// 若已匹配完needle所有字符,提前终止
if (matchPos >= nL) break;
// 字符不匹配,回退i并重置匹配状态
if (haystack[i] !== needle[matchPos]) {
i = resIndex;
resIndex = -1;
}
} else {
// 找到匹配起始字符,记录起始下标
if (haystack[i] === needle[0]) {
resIndex = i;
}
}
}
// 最终校验:确保匹配长度足够
return (resIndex !== -1 && (hL - resIndex) >= nL) ? resIndex : -1;
};
2. 核心逻辑解析
该方法试图用单循环完成匹配,核心思路是"记录起始匹配下标,逐字符验证,失败则回退":
-
resIndex:存储潜在的匹配起始下标,初始为 -1(无潜在匹配); -
当
resIndex !== -1时,说明正处于匹配验证阶段,通过matchPos = i - resIndex计算当前验证的是needle的第几个字符; -
若验证失败,将
i回退到resIndex(利用 for 循环的i++,实际下一轮从resIndex + 1开始,避免重复遍历),同时重置resIndex; -
循环结束后,需校验
resIndex对应的子串长度是否足够(避免末尾部分匹配但长度不足的情况)。
3. 优劣分析
优点:空间复杂度 O(1),无额外空间开销,实现逻辑直观,适合短字符串场景;
缺点 :时间复杂度仍为 O(n*m)(n 为 haystack 长度,m 为 needle 长度),最坏情况下(如主串和子串均为大量重复字符,如 "aaaaa...a")会有大量重复比较,效率较低。
三、实现方案二:KMP 算法(高效子串匹配)
KMP 算法是解决子串匹配问题的经典高效算法,核心优势是通过预处理子串,让主串指针永不回退,将时间复杂度优化至 O(n+m),适合长字符串或高频匹配场景。
1. 核心原理铺垫:最长相等前后缀(LPS 数组)
KMP 算法的核心是利用子串 needle 自身的前后缀规律,构建 LPS 数组(最长相等前后缀数组):
-
前缀:子串中不包含最后一个字符的所有开头子串(如 "ABCD" 的前缀为 "A"、"AB"、"ABC");
-
后缀:子串中不包含第一个字符的所有结尾子串(如 "ABCD" 的后缀为 "D"、"CD"、"BCD");
-
最长相等前后缀长度:对于子串的前 k 个字符,前缀和后缀中长度最长且内容完全相同的子串长度(若无则为 0)。
LPS 数组的作用:当匹配失败时,告诉我们needle 的指针应回退到哪个位置,而非从头开始,从而复用已匹配的前缀部分。
2. 代码实现
typescript
function strStr_2(haystack: string, needle: string): number {
const hLen = haystack.length;
const nLen = needle.length;
if (nLen === 0) return 0;
if (hLen < nLen) return -1;
// 构建LPS数组(最长相等前后缀数组)
const buildLPS = (s: string): number[] => {
const len = s.length;
const lps = new Array(len).fill(0);
let prevLPS = 0; // 前一个位置的最长相等前后缀长度
let i = 1; // 从第二个字符开始遍历(第一个字符LPS必为0)
while (i < len) {
if (s[i] === s[prevLPS]) {
// 字符匹配,延长最长相等前后缀长度
prevLPS++;
lps[i] = prevLPS;
i++;
} else {
if (prevLPS !== 0) {
// 不匹配且prevLPS>0,回退prevLPS(核心复用逻辑)
prevLPS = lps[prevLPS - 1];
} else {
// prevLPS=0,无匹配前缀,LPS[i]设为0
lps[i] = 0;
i++;
}
}
}
return lps;
};
const lps = buildLPS(needle);
let i = 0; // haystack指针(永不回退)
let j = 0; // needle指针(按LPS回退)
while (i < hLen) {
if (haystack[i] === needle[j]) {
// 字符匹配,双指针前进
i++;
j++;
// j到达needle末尾,匹配成功,返回起始下标
if (j === nLen) {
return i - j;
}
} else {
if (j !== 0) {
// 匹配失败,j按LPS回退(复用前缀)
j = lps[j - 1];
} else {
// j=0,无匹配前缀,仅前进主串指针
i++;
}
}
}
return -1;
};
3. 关键步骤拆解
(1)LPS 数组构建过程
以 needle = "abcdabcdabcdef"为例,构建的 LPS 数组为 [0,0,0,0,1,2,3,4,5,6,7,8,0,0]:
-
下标 4(字符 'a'):前 4 个字符 "abcd" 的前缀与当前字符匹配,LPS[4] = 1;
-
下标 11(字符 'd'):前 11 个字符 "abcdabcdabc" 的最长相等前后缀为 "abcdabcd"(长度 8),LPS[11] = 8;
-
下标 12(字符 'e'):无相等前后缀,LPS[12] = 0。
(2)核心匹配逻辑
以示例 haystack = "abcdabcdabcdabcdef", needle = "abcdabcdabcdef" 为例:
-
初始匹配:i、j 从 0 开始,连续匹配到 i=12、j=12 时,
haystack[12] = 'a'与needle[12] = 'e'不匹配; -
回退操作:j=12≠0,执行
j = lps[11] = 8(复用前 11 个字符的最长前缀 "abcdabcd"); -
继续匹配:i=12 与 j=8 匹配(均为 'a'),后续双指针连续前进,直至 j=14(等于 needle 长度 14),返回
i-j = 18-14 = 4,匹配成功。
4. 优劣分析
优点:时间复杂度 O(n+m),主串和子串仅需遍历一次;主串指针永不回退,避免重复比较,长字符串场景下效率远超暴力匹配;
缺点:逻辑较复杂,需额外构建 LPS 数组(空间复杂度 O(m)),适合对性能有要求的场景,简单场景下不如暴力匹配直观。
四、两种方案对比与选型建议
| 方案 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 优化暴力匹配 | O(n*m) | O(1) | 实现简单、无额外空间开销 | 最坏情况效率低 | 短字符串、日常简单业务场景 |
| KMP 算法 | O(n+m) | O(m) | 高效、长字符串表现优异 | 逻辑复杂、需理解 LPS 数组 | 长字符串、高频匹配、性能敏感场景 |
五、常见问题与注意事项
1. 暴力匹配的回退陷阱
原暴力匹配若不回退 i,会导致跳过部分字符(如匹配 "hello" 和 "ll" 时可能漏匹配)。优化后通过 i = resIndex 配合 for 循环的 i++,实现正确回退,避免漏判。
2. KMP 中 j 回退的核心逻辑
代码中 j = lps[j - 1] 是关键:当 needle[j] 匹配失败时,lps[j-1] 是 needle[0..j-1] 的最长相等前后缀长度,将 j 设为该值,即可复用已匹配的前缀部分,无需从头开始。
3. 边界条件不可遗漏
无论哪种方案,都需优先处理 needle 为空、主串长度小于子串的情况,避免后续无效计算或数组越界。
六、总结
LeetCode 28 题看似简单,却能串联起基础遍历与高效算法的设计思路。优化后的暴力匹配法适合快速实现与简单场景,而 KMP 算法则展现了"利用数据自身规律优化性能"的核心思想------通过预处理子串的前后缀信息,将时间复杂度从 O(n*m) 降至线性级别。
在实际开发中,若业务场景以短字符串为主,暴力匹配足够高效;若需处理大量长文本匹配(如日志分析、文本检索),KMP 算法是更优选择。掌握这两种方案,不仅能顺利解决本题,更能为复杂字符串处理问题打下基础。