LeetCode 28. 找出字符串中第一个匹配项的下标:两种实现与深度解析

在字符串处理领域,"子串匹配"是经典基础问题,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" 为例:

  1. 初始匹配:i、j 从 0 开始,连续匹配到 i=12、j=12 时,haystack[12] = 'a'needle[12] = 'e' 不匹配;

  2. 回退操作:j=12≠0,执行 j = lps[11] = 8(复用前 11 个字符的最长前缀 "abcdabcd");

  3. 继续匹配: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 算法是更优选择。掌握这两种方案,不仅能顺利解决本题,更能为复杂字符串处理问题打下基础。

相关推荐
xzl042 小时前
小智服务端chat入口工具调用流程
java·服务器·前端
血小板要健康2 小时前
118. 杨辉三角,力扣
算法·leetcode·职场和发展
小码吃趴菜2 小时前
Shell脚本编程
前端·chrome
_OP_CHEN2 小时前
【算法基础篇】(五十一)组合数学入门:核心概念 + 4 种求组合数方法,带你快速熟悉组合问题!
c++·算法·蓝桥杯·排列组合·组合数学·组合数·acm/icpc
漫随流水2 小时前
leetcode回溯算法(491.非递减子序列)
数据结构·算法·leetcode·回溯算法
心.c2 小时前
Vue3+Node.js实现文件上传并发控制与安全防线 进阶篇
前端·javascript·vue.js·安全·node.js
睡一觉就好了。2 小时前
排序--直接排序,希尔排序
数据结构·算法·排序算法
_pinnacle_2 小时前
多维回报与多维价值矢量化预测的PPO算法
神经网络·算法·强化学习·ppo·多维价值预测
Yzzz-F2 小时前
P3842 [TJOI2007] 线段
算法