LeetCode 30. 串联所有单词的子串:从暴力到高效,滑动窗口优化详解

在 LeetCode 中,"串联所有单词的子串" 是一道经典的字符串匹配 + 滑动窗口题目,难度标记为「困难」,核心难点不在于"做对",而在于"做快"。很多新手能写出正确解法,但会面临超时问题;而高效解法的核心,在于吃透「滑动窗口的动态优化」和「避免重复计算」。

本文将从题目解析入手,先展示能正确运行但效率一般的基础版本,再一步步拆解优化思路,最终解析高效版本的核心逻辑,帮你理清"为什么优化后更快",以及"优化思路能复用在哪些场景"。

一、题目核心解析(先吃透需求,再动手编码)

题目描述(简化版)

给定字符串 s 和字符串数组 words,words 中所有单词长度相同。要求找出 s 中所有「串联子串」的起始索引------串联子串是指包含 words 中所有单词(任意顺序,每个单词仅用一次)的子串。返回所有符合条件的起始索引(顺序无要求)。

关键约束(决定编码思路)

  • words 中所有单词长度相同(记为 step),这是核心突破口(无需处理不同长度的单词拆分);

  • 串联子串的长度是固定的:step × words.length(记为 windowSize),因为要包含所有单词;

  • words 中可能有重复单词(比如 words = ["a","a"]),因此需要统计单词频率,而非简单判断"是否包含";

  • s 可能很长(比如 10^4 长度),words 单词数量可能很多(比如 10^3 个),因此效率至关重要。

示例辅助理解

输入:s = "barfoothefoobarman", words = ["foo","bar"]

输出:[0,9]

解释:step=3,windowSize=6;s[0:6] = "barfoo"(bar+foo)、s[9:15] = "foobar"(foo+bar),均为串联子串,起始索引为 0 和 9。

二、基础版本(正确但低效):findSubstring_1

先从最直观的思路入手,写出能正确运行的代码,再分析其低效点------这是新手进阶的关键:先保证正确性,再追求高效性。

核心思路(固定窗口 + 全量验证)

既然串联子串的长度是固定的 windowSize,我们可以用「固定大小的滑动窗口」遍历 s,对每个窗口进行"全量验证",判断窗口内的子串是否是 words 所有单词的串联。

  1. 预处理:统计 words 中每个单词的频率(用 Map 存储,记为 wordFreqMap),作为验证基准;

  2. 滑动窗口:left 从 0 遍历到 s.length - windowSize,每个 left 对应一个窗口 [left, left+windowSize-1];

  3. 窗口验证:将当前窗口按 step 拆分为 wordL 个单词(wordL 是 words 长度),统计每个单词的频率(临时 Map,记为 tempFreqMap);

  4. 判断有效:若临时频率 Map 与基准频率 Map 完全匹配(无遗漏、无超标),则 left 是有效起始索引,加入结果集。

完整代码(可直接运行)

typescript 复制代码
function findSubstring_1(s: string, words: string[]): number[] {
  const step: number = words[0].length;
  const wordL = words.length;
  const sL = s.length;
  const windowSize: number = wordL * step;
  const res: number[] = [];

  if (!s || wordL === 0) {
    return res;
  }

  // 基准频率表:统计words中每个单词的出现次数
  const wordFreqMap: Map<string, number> = new Map();
  for (const word of words) {
    wordFreqMap.set(word, (wordFreqMap.get(word) || 0) + 1);
  }

  // 滑动窗口:left遍历所有可能的起始位置
  for (let left = 0; left <= sL - windowSize; left++) {
    const tempFreqMap: Map<string, number> = new Map();
    let isValid = true;

    // 拆分当前窗口,全量统计单词频率
    for (let i = 0; i < wordL; i++) {
      const wordStart = left + i * step;
      const wordEnd = wordStart + step;
      const currentWord = s.slice(wordStart, wordEnd);

      // 无效情况1:当前单词不在words中
      if (!wordFreqMap.has(currentWord)) {
        isValid = false;
        break;
      }

      // 更新临时频率表
      tempFreqMap.set(currentWord, (tempFreqMap.get(currentWord) || 0) + 1);

      // 无效情况2:当前单词频率超过基准
      if (tempFreqMap.get(currentWord)! > wordFreqMap.get(currentWord)!) {
        isValid = false;
        break;
      }
    }

    // 窗口有效,记录起始索引
    if (isValid) {
      res.push(left);
    }
  }

  return res;
};

版本1的问题(低效根源)

这个版本能正确通过所有测试用例,但在 s 较长、wordL 较大时会超时,核心问题是「大量重复计算」和「无意义遍历」,具体拆解为3点:

  1. left 每次递增1,导致相邻窗口大量重叠,拆分单词时重复计算(比如 left=0 和 left=1 的窗口,大部分子串重叠,但都要重新拆分 wordL 次);

  2. 每个窗口都是"独立验证",不复用前一个窗口的计算结果(比如前一个窗口统计过的单词频率,下一个窗口完全重新统计);

  3. 存在无意义验证:比如 step=3 时,left=1、2 的窗口拆分出的单词,必然不是3个字符(words 单词都是3个字符),这些验证完全多余。

时间复杂度:O(N × M),其中 N 是 s 中窗口的数量(约 s.length),M 是 words 的长度。当 s.length=104、wordL=103 时,总计算量达到 10^7,容易超时。

三、优化版本(高效不超时):findSubstring_2

针对版本1的低效问题,我们做两处核心优化:「按 step 分组遍历」+「动态滑动窗口」,将时间复杂度优化到接近 O(N)(N 为 s 的长度),彻底解决超时问题。

核心优化思路(吃透这2点,效率翻倍)

优化1:按 step 分组,砍掉无效遍历

因为 words 中所有单词长度都是 step,所以「只有起始位置在 0 ~ step-1 范围内的窗口,才有可能拆分出有效单词」。超出这个范围的起始位置,拆分出的单词必然和某个分组重复,且不可能有效。

举例:step=3,只需处理 start=0、1、2 三个分组:

  • 分组0:起始位置 0、3、6、9...(只有这些位置可能是有效索引);

  • 分组1:起始位置 1、4、7、10...(即使验证,拆分出的单词也不可能在 words 中,会快速重置窗口);

  • 分组2:起始位置 2、5、8、11...(同上)。

这一步直接砍掉了 (s.length - windowSize) × (step-1)/step 次无效验证,step 越大,优化效果越明显。

优化2:动态滑动窗口,增量更新频率表

版本1的窗口是"固定大小、独立验证",而优化版本的窗口是「动态大小、增量更新」------窗口的右边界和左边界都按 step 递增,每次只更新"变化的单词",复用之前的计算结果。

同时,引入「matchCount」变量,记录"频率符合基准的单词数量",无需每次对比两个 Map 全量键值对,快速判断窗口是否有效。

完整代码(高效不超时)

typescript 复制代码
function findSubstring_2(s: string, words: string[]): number[] {
  const step: number = words[0].length;
  const wordL = words.length;
  const sL = s.length;
  const windowSize: number = wordL * step;
  const res: number[] = [];

  if (!s || wordL === 0) {
    return res;
  }

  // 基准频率表(和版本1一致)
  const wordFreqMap: Map<string, number> = new Map();
  for (const word of words) {
    wordFreqMap.set(word, (wordFreqMap.get(word) || 0) + 1);
  }

  // 优化1:按step分组,只处理0~step-1个起始位置
  for (let start = 0; start < step; start++) {
    const tempFreqMap: Map<string, number> = new Map(); // 当前窗口的频率表
    let left = start; // 窗口左边界(动态调整)
    let matchCount = 0; // 已匹配的单词数量(频率符合基准)

    // 优化2:右边界按step递增,动态滑动窗口
    for (let right = start; right <= sL - step; right += step) {
      const currentWord = s.slice(right, right + step);
      const currentWordBaseCount = wordFreqMap.get(currentWord) || 0;

      // 情况1:当前单词不在words中,重置窗口
      if (currentWordBaseCount === 0) {
        tempFreqMap.clear();
        matchCount = 0;
        left = right + step; // 左边界跳至下一个单词,避免无效遍历
        continue;
      }

      // 更新临时频率表
      const currentWordTempCount = (tempFreqMap.get(currentWord) || 0) + 1;
      tempFreqMap.set(currentWord, currentWordTempCount);

      // 情况2:当前单词频率未超标,匹配数+1;超标则收缩左窗口
      if (currentWordTempCount <= currentWordBaseCount) {
        matchCount++;
      } else {
        // 收缩左窗口,直到当前单词频率不超标
        while (tempFreqMap.get(currentWord)! > currentWordBaseCount) {
          const leftWord = s.slice(left, left + step);
          const leftWordTempCount = tempFreqMap.get(leftWord)! - 1;
          tempFreqMap.set(leftWord, leftWordTempCount);

          // 若移出的单词之前是匹配的,匹配数-1
          if (leftWordTempCount < wordFreqMap.get(leftWord)!) {
            matchCount--;
          }

          left += step; // 左边界右移一个单词长度
        }
      }

      // 情况3:匹配数等于单词总数,窗口有效,记录起始索引
      if (matchCount === wordL) {
        res.push(left);

        // 收缩左窗口,继续寻找下一个有效窗口(复用当前计算结果)
        const leftWord = s.slice(left, left + step);
        tempFreqMap.set(leftWord, tempFreqMap.get(leftWord)! - 1);
        matchCount--;
        left += step;
      }
    }
  }

  return res;
}

优化版本核心逻辑拆解

我们以"分组遍历"为框架,拆解动态窗口的工作流程,帮你理解"增量更新"的核心:

1. 分组遍历(外层循环)

外层循环控制分组(start 从 0 到 step-1),每个分组内的 left 和 right 都按 step 递增,确保拆分出的单词长度符合要求,避免无意义拆分。

2. 动态窗口操作(内层循环)

内层循环控制右边界 right 移动,每次加入一个新单词,然后根据单词情况调整窗口,核心是"只更新变化的部分":

  • 单词不在 words 中:直接重置窗口(清空临时频率表、重置 matchCount、左边界跳至下一个单词),避免后续无效计算;

  • 单词在 words 中:更新临时频率表,若频率未超标,matchCount+1;若超标,收缩左边界,直到频率符合要求(收缩时同步更新 matchCount);

  • 匹配数达标(matchCount = wordL):记录 left(有效起始索引),然后收缩左边界,继续寻找下一个有效窗口(复用当前频率表,无需重新统计)。

3. matchCount 的作用(关键优化)

版本1中,验证窗口有效需要遍历两个 Map 对比,而优化版本用 matchCount 直接判断:当 matchCount 等于 wordL 时,说明窗口内所有单词的频率都符合基准,无需额外对比------这一步直接减少了 O(M) 的时间复杂度(M 为 words 长度)。

四、两个版本对比(清晰看到优化效果)

对比项 基础版本(findSubstring_1) 优化版本(findSubstring_2)
时间复杂度 O(N × M)(N:s长度,M:words长度) 接近 O(N)(仅遍历s一次,无重复计算)
窗口类型 固定大小,独立验证 动态大小,增量更新
重复计算 大量(相邻窗口重复拆分、重算频率) 极少(仅更新变化的单词,复用计算结果)
验证方式 全量对比两个 Map matchCount 快速判断
适用场景 短字符串、少量单词(无超时风险) 长字符串、大量单词(高效不超时)

五、总结与拓展(举一反三)

核心收获

这道题的优化,本质上是「减少重复计算」和「复用中间结果」的思路,这也是滑动窗口类题目(比如 LeetCode 76. 最小覆盖子串)的通用优化方向:

  1. 当遇到"固定长度单词拆分"时,优先考虑「按单词长度分组」,砍掉无效遍历;

  2. 当窗口内的元素需要统计频率时,优先用「动态滑动窗口 + 增量更新」,避免全量重算;

  3. 用"匹配计数器"(如 matchCount)替代全量对比,提升验证效率。

进阶思考

如果 words 中单词长度不相同,该如何修改思路?(提示:无法固定窗口大小,需动态调整窗口,且不能按 step 分组,难度会提升,但核心还是"频率统计 + 动态窗口")

最后,记住:算法优化的核心不是"写复杂代码",而是"看透低效的根源",针对性地减少无意义的计算------这道题的两个版本,核心逻辑一致,但优化后的代码,正是因为避开了"重复拆分"和"无效验证",才实现了效率的飞跃。

相关推荐
-Try hard-2 小时前
数据结构|概念及单向有头链表
数据结构·算法·vim
历程里程碑2 小时前
子串----和为K的子数组
大数据·python·算法·leetcode·elasticsearch·搜索引擎·哈希算法
Aaron15882 小时前
通信灵敏度计算与雷达灵敏度计算对比分析
网络·人工智能·深度学习·算法·fpga开发·信息与通信·信号处理
2301_790300962 小时前
C++中的命令模式
开发语言·c++·算法
2301_822376942 小时前
C++中的解释器模式
开发语言·c++·算法
xhbaitxl2 小时前
算法学习day31-贪心算法
学习·算法·贪心算法
爱学习的阿磊2 小时前
C++代码冗余消除
开发语言·c++·算法
YuTaoShao2 小时前
【LeetCode 每日一题】2976. 转换字符串的最小成本 I
算法·leetcode·职场和发展
木卫二号Coding2 小时前
Docker-构建自己的Web-Linux系统-Ubuntu:22.04
linux·前端·docker