Leetcode 30. 串联所有单词的子串
Leetcode 30. 串联所有单词的子串 是一道经典的字符串滑动窗口问题。这题考察字符串切分、哈希表匹配以及滑动窗口的灵活应用。解题难度较高,需要对循环和判断逻辑有良好的掌握。
题目描述
给定一个字符串 s
和一组长度都相同的单词数组 words
,找出 s
中恰好可以由数组中所有单词连接在一起的连续子字符串的起始索引。这些单词在子字符串中可以以任意顺序排列,但每个单词必须完全匹配。
示例
输入: s = "barfoothefoobarman", words = ["foo","bar"]
输出: [0,9]
解释: 子串是 "barfoo" 和 "foobar",它们的起始索引分别为 0 和 9。
输入: s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出: []
输入: s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出: [6,9,12]
解法 1:滑动窗口法
思路
将问题转化为固定宽度为 totalLen = words.length * wordLen
的滑动窗口问题:
- 窗口大小:
- 由于单词长度都一样,窗口的大小固定为
totalLen
。 - 可以遍历
s
的每个可能起点,从中截取长度为totalLen
的窗口。
- 由于单词长度都一样,窗口的大小固定为
- 匹配机制:
- 用一个 HashMap 记录
words
中每个单词的频次 (wordFreq
)。 - 每个窗口逐个截取长度为
wordLen
的单词片段,统计其出现频次 (currentFreq
)。 - 如果当前窗口内所有单词的出现频次完全匹配
wordFreq
,则当前窗口为有效窗口。
- 用一个 HashMap 记录
- 滑动优化:
- 滑动窗口通常从左到右依次移动,通过减少重新计算提升效率。
代码模板
import java.util.*;
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 totalLen = wordLen * words.length; // 总窗口长度
// 单词频次统计
Map<String, Integer> wordFreq = new HashMap<>();
for (String word : words) {
wordFreq.put(word, wordFreq.getOrDefault(word, 0) + 1);
}
// 滑动窗口
for (int i = 0; i < wordLen; i++) { // 启动多个滑动窗口
int left = i, right = i, count = 0;
Map<String, Integer> currentFreq = new HashMap<>();
while (right + wordLen <= s.length()) {
String word = s.substring(right, right + wordLen);
right += wordLen;
// 处理当前单词
if (wordFreq.containsKey(word)) {
currentFreq.put(word, currentFreq.getOrDefault(word, 0) + 1);
count++;
// 如果某单词超出频次限制,缩小窗口
while (currentFreq.get(word) > wordFreq.get(word)) {
String leftWord = s.substring(left, left + wordLen);
currentFreq.put(leftWord, currentFreq.get(leftWord) - 1);
left += wordLen;
count--;
}
// 检查是否完全匹配
if (count == words.length) {
result.add(left);
}
} else {
// 非法单词,重置窗口状态
currentFreq.clear();
count = 0;
left = right;
}
}
}
return result;
}
}
复杂度分析
- 时间复杂度:
- 单词数为
n
,单词长度为wordLen
,字符串长度为m
。 - 滑动窗口遍历最多
m / wordLen
次,每次窗口内的频率判断需要 O(n)。 - 总时间复杂度为 O(m * n / wordLen)。
- 单词数为
- 空间复杂度: O(n)
- 需要存储
wordFreq
和currentFreq
两个 HashMap。
- 需要存储
适用场景
- 适用于输入字符串较大,但单词数量较少的情况。
- 高效处理,能够正确识别多种模式。
解法 2:分块扫描
思路
- 类似滑动窗口,但优化起点扫描的策略。由于单词长度固定,字符串可以按照
wordLen
等分。 - 对每个可能的起点进行独立的分块滑动扫描,减少重复逻辑。
代码模板
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 totalLen = wordLen * words.length;
if (s.length() < totalLen) {
return result;
}
Map<String, Integer> wordFreq = new HashMap<>();
for (String word : words) {
wordFreq.put(word, wordFreq.getOrDefault(word, 0) + 1);
}
for (int i = 0; i < wordLen; i++) {
Map<String, Integer> currentFreq = new HashMap<>();
int left = i, count = 0;
for (int right = i; right + wordLen <= s.length(); right += wordLen) {
String word = s.substring(right, right + wordLen);
if (wordFreq.containsKey(word)) {
currentFreq.put(word, currentFreq.getOrDefault(word, 0) + 1);
count++;
while (currentFreq.get(word) > wordFreq.get(word)) {
String removedWord = s.substring(left, left + wordLen);
currentFreq.put(removedWord, currentFreq.get(removedWord) - 1);
count--;
left += wordLen;
}
if (count == words.length) {
result.add(left);
}
} else {
currentFreq.clear();
count = 0;
left = right + wordLen;
}
}
}
return result;
}
}
复杂度分析
- 时间复杂度: O(m * n / wordLen)。
- 空间复杂度: O(n) (存储单词字典)。
解法 3:暴力法
思路
- 枚举字符串的所有子串,判断是否等于
words
的任意排列。 - 将字符串切分为固定大小的单词并比较频次。
代码模板
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 totalLen = wordLen * words.length;
Map<String, Integer> wordFreq = new HashMap<>();
for (String word : words) {
wordFreq.put(word, wordFreq.getOrDefault(word, 0) + 1);
}
for (int i = 0; i <= s.length() - totalLen; i++) {
String sub = s.substring(i, i + totalLen);
Map<String, Integer> currentFreq = new HashMap<>();
boolean isValid = true;
for (int j = 0; j < sub.length(); j += wordLen) {
String word = sub.substring(j, j + wordLen);
currentFreq.put(word, currentFreq.getOrDefault(word, 0) + 1);
if (!wordFreq.containsKey(word) || currentFreq.get(word) > wordFreq.get(word)) {
isValid = false;
break;
}
}
if (isValid) {
result.add(i);
}
}
return result;
}
}
复杂度分析
- 时间复杂度: O(m * n)
- 对于每个起点,我们需要遍历每个单词,再比较频次。
- 空间复杂度: O(n)
快速 AC 策略
- 首选滑动窗口法 (解法 1):
- 时间效率高,代码清晰,非常适合面试中快速实现。
- 可以考虑分块扫描 (解法 2):
- 针对特定输入大小,可以优化性能。
- 避免使用暴力法 (解法 3):
- 尽管清晰直观,但性能差,不适合大规模输入。
在面试或实际开发中,熟练掌握滑动窗口模板,能够快速解决这类问题!