LeetCode 30. 串联所有单词的子串
题目描述
题意分析
给定一个字符串和一组单词,要找出字符串中所有这样的起点:从该起点开始的一段连续子串,恰好是把这组单词全部各用一次、以任意顺序首尾相接拼出来的。返回所有起点,顺序不限。
题目给了一个极强的条件:所有单词长度完全相同。这意味着待匹配子串的长度是固定的,等于单词长度乘以单词个数;更重要的是,这段子串的内部切分点也是固定的——从起点开始每隔一个单词长度切一刀,切出来的每一块都必须恰好是某个单词,不存在跨越边界的匹配。
单词组里允许出现重复,所以判断依据不是「这些单词都出现过」而是「每个单词出现的次数分别对上」,必须用计数而非集合。
数据范围里字符串长度可到 $10^4$,单词个数可到 $5000$,单词长度不超过 $30$。这说明「枚举每个起点再逐块比对」这种 $O(n \cdot m)$ 的做法在最坏情形下会很吃力,需要让相邻起点之间复用已有的统计结果。
边界情形包括:字符串比目标子串还短,此时无解;单词组里全是同一个单词;字符串中出现了不属于单词组的块,它会切断所有跨越它的候选;以及答案起点可能相邻出现(比如单词全相同时,连续多个位置都合法)。
解法:按单词长度分组的滑动窗口
核心思路
所有单词等长,设长度为 $L$。合法子串从起点开始,每隔 $L$ 个字符切出一个单词,因此起点相差 $L$ 的候选拥有相同的切分边界;起点模 $L$ 不同的候选互不对齐。
枚举
offset = 0 ... L-1,每个偏移内以“一个单词”为单位滑动窗口。窗口维护:
need:目标中每个单词需要出现的次数;window:当前窗口中的次数;used:窗口包含的单词总数。加入一个合法单词后,若它的次数超过需求,就从左侧逐词移出,直到频次恢复合法。此时所有单词都不超量;若
used又恰好等于单词总数,则每个词频必然全部匹配,当前左端就是答案。遇到不在
need中的词块时,任何跨过它的窗口都不可能合法,直接清空状态并从下一块重新开始。
解题步骤
- 统计
words的目标词频,并计算单词长度与单词总数。- 对每个余数类分别初始化窗口,从
offset开始每次右移 $L$。- 当前词不在目标中时,清空窗口并把左端移到该词之后。
- 否则加入当前词;若该词超量,持续弹出左端词。
used == words.length时记录左端。随后主动弹出一个左端词,以继续寻找重叠答案。- 扫描完全部余数类后返回所有起点。
例如
s = "barfoofoo"、words = ["bar","foo"]:窗口先得到bar,foo,记录起点0;再加入第二个foo时会收缩到只剩该foo,不会错误保留超量单词。
代码实现
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> answer = new ArrayList<>();
if (words.length == 0) {
return answer;
}
int wordLength = words[0].length();
int wordCount = words.length;
if (s.length() < wordLength * wordCount) {
return answer;
}
Map<String, Integer> need = new HashMap<>();
for (String word : words) {
need.put(word, need.getOrDefault(word, 0) + 1);
}
for (int offset = 0; offset < wordLength; offset++) {
Map<String, Integer> window = new HashMap<>();
int left = offset;
int used = 0;
for (int right = offset;
right + wordLength <= s.length();
right += wordLength) {
String word = s.substring(right, right + wordLength);
if (!need.containsKey(word)) {
window.clear();
used = 0;
left = right + wordLength;
continue;
}
window.put(word, window.getOrDefault(word, 0) + 1);
used++;
while (window.get(word) > need.get(word)) {
String removed = s.substring(left, left + wordLength);
window.put(removed, window.get(removed) - 1);
left += wordLength;
used--;
}
if (used == wordCount) {
answer.add(left);
String removed = s.substring(left, left + wordLength);
window.put(removed, window.get(removed) - 1);
left += wordLength;
used--;
}
}
}
return answer;
}
}
func findSubstring(s string, words []string) []int {
answer := make([]int, 0)
if len(words) == 0 {
return answer
}
wordLength := len(words[0])
wordCount := len(words)
if len(s) < wordLength*wordCount {
return answer
}
need := make(map[string]int)
for _, word := range words {
need[word]++
}
for offset := 0; offset < wordLength; offset++ {
window := make(map[string]int)
left, used := offset, 0
for right := offset; right+wordLength <= len(s); right += wordLength {
word := s[right : right+wordLength]
if _, ok := need[word]; !ok {
clear(window)
left = right + wordLength
used = 0
continue
}
window[word]++
used++
for window[word] > need[word] {
removed := s[left : left+wordLength]
window[removed]--
left += wordLength
used--
}
if used == wordCount {
answer = append(answer, left)
removed := s[left : left+wordLength]
window[removed]--
left += wordLength
used--
}
}
}
return answer
}
复杂度分析
设字符串长度为 $n$、单词数为 $m$、单词长度为 $L$。
- 时间复杂度:$O((n+m)L)$。所有余数类合计处理 $O(n)$ 个词块,每个词块至多进窗、出窗各一次;截取和哈希长度为 $L$ 的单词需要 $O(L)$。
- 空间复杂度:$O(mL)$,两张词频表最多保存 $m$ 个长度为 $L$ 的不同单词;返回结果不计入额外空间。
关键点总结
- 等长单词使切分边界固定,按起点模 $L$ 分组后才能复用窗口。
- 重复单词要求维护词频,集合无法表达需求次数。
- “所有词频不超量 + 窗口词数等于 $m$”即可推出词频完全一致。
- 非目标词触发整窗重置,目标词超量只需从左侧收缩。
- 记录答案后弹出一个词,才能自然继续寻找重叠解。
易错点总结
- 只扫描
offset = 0:会漏掉起点不被 $L$ 整除的答案。- 用集合代替词频表:
words = ["a","a"]时无法判断重复次数。- 遇到非法词后只移动左端、不清空计数:窗口状态会与实际区间脱节。
- 收缩时忘记同步减少
used:可能产生长度不正确的假答案。- 子串右端没有检查
right + wordLength <= s.length():末尾会越界。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 438. 找到字符串中所有字母异位词 | 中等 | 匹配单位退化为单个字符,窗口定长且逐字符滑动,无需按余数分类 |
| 567. 字符串的排列 | 中等 | 与 438 同构但只需判断是否存在,可在找到首个匹配时提前返回 |
| 76. 最小覆盖子串 | 困难 | 只要求覆盖而非恰好用完,窗口可变长且目标是最短,收缩条件相反 |
| 3. 无重复字符的最长子串 | 中等 | 约束是窗口内不重复,收缩由重复字符驱动,不涉及目标计数表 |
| 209. 长度最小的子数组 | 中等 | 窗口状态是数值和而非计数表,靠单调性保证每个元素只进出一次 |
| 239. 滑动窗口最大值 | 困难 | 定长窗口但维护的是极值,需要单调队列而不是哈希计数 |