在 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 所有单词的串联。
-
预处理:统计 words 中每个单词的频率(用 Map 存储,记为 wordFreqMap),作为验证基准;
-
滑动窗口:left 从 0 遍历到 s.length - windowSize,每个 left 对应一个窗口 [left, left+windowSize-1];
-
窗口验证:将当前窗口按 step 拆分为 wordL 个单词(wordL 是 words 长度),统计每个单词的频率(临时 Map,记为 tempFreqMap);
-
判断有效:若临时频率 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点:
-
left 每次递增1,导致相邻窗口大量重叠,拆分单词时重复计算(比如 left=0 和 left=1 的窗口,大部分子串重叠,但都要重新拆分 wordL 次);
-
每个窗口都是"独立验证",不复用前一个窗口的计算结果(比如前一个窗口统计过的单词频率,下一个窗口完全重新统计);
-
存在无意义验证:比如 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. 最小覆盖子串)的通用优化方向:
-
当遇到"固定长度单词拆分"时,优先考虑「按单词长度分组」,砍掉无效遍历;
-
当窗口内的元素需要统计频率时,优先用「动态滑动窗口 + 增量更新」,避免全量重算;
-
用"匹配计数器"(如 matchCount)替代全量对比,提升验证效率。
进阶思考
如果 words 中单词长度不相同,该如何修改思路?(提示:无法固定窗口大小,需动态调整窗口,且不能按 step 分组,难度会提升,但核心还是"频率统计 + 动态窗口")
最后,记住:算法优化的核心不是"写复杂代码",而是"看透低效的根源",针对性地减少无意义的计算------这道题的两个版本,核心逻辑一致,但优化后的代码,正是因为避开了"重复拆分"和"无效验证",才实现了效率的飞跃。