这道题是字符串滑动窗口里非常经典、也非常容易写崩的一题。如果你能真正吃透它,后面遇到“固定模式匹配 + 统计次数”的题,基本都能一眼看穿。
一、题目要求
给定:
- 一个字符串
s - 一个字符串数组
words
要求找出 s 中所有起始下标,使得从该下标开始的子串:
- 子串长度 =
words.length * 单词长度 - 子串中 恰好包含 words 中所有单词各一次
- 单词顺序可以任意,但不能多也不能少
示例:
输入:
s = "barfoothefoobarman"
words = ["foo","bar"]
输出:
[0, 9]
二、核心思路分析
1. 题目中的三个关键信息
words中 所有单词长度相同- 目标子串的长度是固定的
- 子串必须能被 按单词长度整齐切割
这直接决定了:
- 不能用普通的「一个字符一个字符滑动窗口」
- 必须 按单词长度进行滑动
2. 使用两个 HashMap
need:记录每个单词需要出现的次数window:记录当前窗口中每个单词出现的次数
本质是:
判断当前窗口是否是
need的一个“合法排列”
3. 为什么要“多起点滑动窗口”
假设单词长度是 wordLen = 3
字符串切割方式可能是:
i = 0: bar | foo | the | foo | bar
i = 1: arf | oot | hef | oob | arm
i = 2: rfo | oth | efo | oba | rma
只有 对齐单词边界 的切割方式才有可能成功
因此:
起点必须是 0 ~ wordLen - 1
这是这道题最容易被忽略、但最关键的一点。
三、完整代码(Java)
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> res = new ArrayList<>();
if (s == null || words == null || words.length == 0) {
return res;
}
int wordLen = words[0].length();
int wordCount = words.length;
int totalLen = wordLen * wordCount;
if (s.length() < totalLen) {
return res;
}
// 1. 统计每个单词需要的次数
HashMap<String, Integer> need = new HashMap<>();
for (String word : words) {
need.put(word, need.getOrDefault(word, 0) + 1);
}
// 2. 多起点滑动窗口
for (int i = 0; i < wordLen; i++) {
HashMap<String, Integer> window = new HashMap<>();
int left = i;
int count = 0;
for (int right = i; right + wordLen <= s.length(); right += wordLen) {
String word = s.substring(right, right + wordLen);
if (need.containsKey(word)) {
window.put(word, window.getOrDefault(word, 0) + 1);
count++;
// 3. 某个单词出现次数超了,缩窗口
while (window.get(word) > need.get(word)) {
String leftWord = s.substring(left, left + wordLen);
window.put(leftWord, window.get(leftWord) - 1);
left += wordLen;
count--;
}
// 4. 窗口刚好匹配
if (count == wordCount) {
res.add(left);
}
} else {
// 5. 遇到非法单词,直接重置窗口
window.clear();
count = 0;
left = right + wordLen;
}
}
}
return res;
}
}
四、逐行关键逻辑解析
1. need 表:目标单词频率
HashMap<String, Integer> need = new HashMap<>();
作用:
- 记录每个单词 最多允许出现几次
- 用来判断窗口是否合法
2. 外层循环:多起点扫描
for (int i = 0; i < wordLen; i++) {
作用:
- 保证单词切割一定是完整的
- 防止漏掉合法答案
这是整道题的“地基”。
3. right 指针:按单词扩展窗口
String word = s.substring(right, right + wordLen);
每次移动:
- 都是一个完整单词
- 不会出现半个单词的问题
4. 为什么要 while 缩窗口
while (window.get(word) > need.get(word)) {
说明:
- 当前单词出现次数 超过了允许值
- 必须不断右移
left,直到窗口重新合法
这一步是保证:
- 单词数量对
- 每个单词的次数也对
5. 命中答案的条件
if (count == wordCount) {
res.add(left);
}
count 表示:
- 当前窗口中合法单词的总数
当它等于 words.length:
- 说明窗口中 不多不少,刚好匹配
6. 非法单词直接清空窗口
window.clear();
count = 0;
left = right + wordLen;
这是一个优化点:
- 碰到不在
words里的单词 - 当前窗口必然失败
- 直接清空,比慢慢 shrink 更高效
五、时间与空间复杂度
-
时间复杂度:
O(n)- 每个单词最多被加入和移除一次
-
空间复杂度:
O(m)m是 words 中不同单词的数量
六、总结
这道题本质可以总结为一句话:
固定长度 + 按单词步进的滑动窗口 + 哈希表计数