大家好,我是你们的算法小伙伴。今天我们来练习一道字符串处理的困难题 ------LeetCode 30. 串联所有单词的子串。这道题是「滑动窗口」的复杂变种,核心考察如何批量匹配固定长度的字符串 ,并通过哈希表统计来快速验证是否满足串联条件,是面试中数组与字符串结合的高难度标杆题。
题目描述
给定一个字符串 s 和一个字符串数组 words。words 中所有字符串长度相同。
找出 s 中 包含 words 中所有单词(任意顺序串联) 的子串的所有起始索引。
注意:
- 子串必须是连续的;
- 必须包含
words中的所有单词 ,且每个单词出现的次数与words中一致; - 不需要按
words中的顺序排列。
示例 1:
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:子串 "barfoo" 开始于 0,包含 "bar" 和 "foo";子串 "foobar" 开始于 9,包含 "foo" 和 "bar"。
示例 2:
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:没有满足条件的子串。
示例 3:
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
提示:
1 <= s.length <= 10⁴1 <= words.length <= 50001 <= words[i].length <= 30words[i]和s由小写英文字母组成
解题思路
核心分析
- 固定长度 :所有
words[i]长度相同,记为wordLen。因此,目标子串的总长度固定为totalLen = words.length * wordLen。 - 计数对比 :需要判断窗口内的单词组合是否与
words的单词统计完全一致。 - 避免重复计算 :由于单词长度固定,遍历起始点时只需从
0到wordLen-1开始,即可覆盖所有可能情况。
方法:滑动窗口 + 双哈希表 + 种类匹配
核心步骤:
- 先用哈希表
targetMap统计words中每个单词的目标次数 - 按单词长度 分组遍历起始点
i(0 ~ wordLen-1),覆盖所有可能切分方式 - 用滑动窗口 维护当前窗口内的单词计数
windowMap - 用
matchCount记录已匹配完成的单词种类数 - 当窗口内单词超次数时,收缩左指针
- 当
matchCount == 目标单词种类数时,记录答案并滑动窗口
代码实现
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> result = new ArrayList<>();
if (s == null || s.length() == 0 || words == null || words.length == 0) {
return result;
}
int wordLen = words[0].length();
int wordCount = words.length;
int sLen = s.length();
// 统计words中每个单词的目标次数
Map<String, Integer> targetMap = new HashMap<>();
for (String word : words) {
targetMap.put(word, targetMap.getOrDefault(word, 0) + 1);
}
// 遍历所有可能的起始分组 0 ~ wordLen-1
for (int i = 0; i < wordLen; i++) {
int left = i;
int matchCount = 0; // 已匹配完成的单词种类数
Map<String, Integer> windowMap = new HashMap<>();
// 右指针按单词长度步进
for (int right = i; right <= sLen - wordLen; right += wordLen) {
String currentWord = s.substring(right, right + wordLen);
// 当前单词在目标列表中
if (targetMap.containsKey(currentWord)) {
windowMap.put(currentWord, windowMap.getOrDefault(currentWord, 0) + 1);
// 该单词次数刚好达标,种类数+1
if (windowMap.get(currentWord).equals(targetMap.get(currentWord))) {
matchCount++;
}
// 次数超标,必须收缩左指针
while (windowMap.get(currentWord) > targetMap.get(currentWord)) {
String leftWord = s.substring(left, left + wordLen);
// 如果这个单词刚好达标,移除后种类数-1
if (windowMap.get(leftWord).equals(targetMap.get(leftWord))) {
matchCount--;
}
windowMap.put(leftWord, windowMap.get(leftWord) - 1);
left += wordLen;
}
// 所有单词种类都匹配成功 → 找到答案
if (matchCount == targetMap.size()) {
result.add(left);
// 记录后滑动窗口,继续寻找下一个
String leftWord = s.substring(left, left + wordLen);
if (windowMap.get(leftWord).equals(targetMap.get(leftWord))) {
matchCount--;
}
windowMap.put(leftWord, windowMap.get(leftWord) - 1);
left += wordLen;
}
} else {
// 遇到无效单词,直接重置整个窗口
windowMap.clear();
matchCount = 0;
left = right + wordLen;
}
}
}
return result;
}
}
代码详细讲解
1. 基础变量
wordLen:每个单词的长度targetMap:记录每个单词需要出现多少次matchCount:核心变量 ,记录当前窗口内次数完全匹配的单词种类数
2. 为什么要遍历 0 ~ wordLen-1
因为单词是固定长度切分的,比如单词长度 = 4:
- 从 0 开始切:0,4,8,12...
- 从 1 开始切:1,5,9,13...
- 从 2 开始切:2,6,10,14...
- 从 3 开始切:3,7,11,15...
只需要遍历 wordLen 次,就能覆盖所有可能的合法窗口。
3. 滑动窗口核心逻辑
- 右指针加入单词 → 更新计数
- 如果次数达标 →
matchCount++ - 如果次数超标 → 进入
while收缩左指针 - 所有种类都达标 → 记录答案,并滑动左指针继续找下一个
4. 最关键的 matchCount 设计
matchCount 表示:
当前窗口内,次数完全等于目标次数的单词有多少种
当 matchCount == targetMap.size()说明:所有单词都刚好匹配,不多不少→ 这就是合法子串。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(L×N) | L = 单词长度,N = 字符串 s 长度,每个字符仅访问 2 次 |
| 空间复杂度 | O(M) | M=words 中不同单词数量 |
高频易错点总结
- 忘记按 wordLen 分组遍历 → 超时 / 漏解
- matchCount 统计错误 → 最容易错
- 收缩左指针时没有更新 matchCount → 答案错误
- 遇到无效单词没有重置窗口 → 计数混乱
- 找到答案后没有滑动左指针 → 重复记录答案
总结
这道题是滑动窗口的最高难度应用之一,也是面试高频困难题。核心思想:
- 固定单词长度 → 分组遍历
- 次数匹配 → 双哈希表
- 快速判断是否合法 → matchCount 统计种类数
- 窗口超标 → 收缩左指针
今天的每日算法练习就到这里,我们明天再见!👋