#30 可变滑窗 困难

串联所有单词的子串

在 LeetCode 上查看 ↗

🎧 语音讲解

开车或通勤时可听,跟着思路走一遍

速度

📋 题目描述

给定一个字符串 s 和一个字符串数组 wordswords 中所有字符串 长度相同

s 中的 串联子串 是指一个包含 words 中所有字符串以任意顺序排列连接起来的子串。

返回所有串联子串在 s 中的开始索引。你可以以 任意顺序 返回答案。

示例 1

输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
子串 "barfoo"(下标 0)和 "foobar"(下标 9)都是 words 的某种排列连接,长度均为 6。

示例 2

输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
需要长度 16 的串联子串,s 中不存在满足条件的子串。

示例 3

输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
下标 6、9、12 分别对应 "foobarthe"、"barthefoo"、"thefoobar"。

💭 模拟答题者思考

1. 我先想暴力:枚举 s 中每个起点 i,切出长度 wordLen × |words| 的子串,再判断是否等于 words 的某种排列——要检查所有排列或逐词匹配,复杂度爆炸。

2. 重复在哪里?每个起点都在重新切词、重新比对。其实合法串联子串的长度固定,且每个单词长度相同,可以按「单词块」而不是单字符来滑窗。

3. 关键观察:若按字符下标 i 切块,只有 i % wordLen 相同的起点才属于同一套切分方式。所以对 offset = 0..wordLen-1 各跑一遍滑窗即可覆盖全部可能。

4. 滑窗逻辑类似「最小覆盖子串」:右扩加入一块单词,若某词超量就从左缩;当 valid == len(need) 时,left 即为一个合法串联子串起点。

5. 若右端切出的块不在 need 中,当前切分方式已不可能继续匹配,直接清空窗口并把 left 跳到 right 之后。

🧠 变量语义(先读这三句再编码)

变量类型语义(三句法)
need[w]map<string,int>定义:目标单词 w 在 words 中应出现的次数
维护:不变量,由 words 初始化
更新:不更新
window[w]map<string,int>定义:当前「按单词切分」的滑窗内,单词 w 的出现次数
维护:随窗口在 s 上按 wordLen 步长滑动而增减
更新:右端加入单词时 window[w]++;左端移出时 window[w]--
validint定义:窗口内已「恰好匹配」need 的单词种类数(window[w] == need[w]
维护:每轮后 valid 等于满足精确匹配的单词种类数
更新:某词计数从 need-1 变 need 时 valid++;从 need 变 need-1 时 valid--
offsetint定义:当前滑窗在 s 上的起始对齐偏移(0 到 wordLen-1)
维护:每个 offset 独立跑一遍「按单词步长」的滑窗,覆盖所有可能切分
更新:外层循环 offset++,内层从 offset 起按 wordLen 步进
left / rightint定义:当前窗口在 s 中的左右边界(字符下标)
维护:窗口始终覆盖连续 k 个单词块,总字符长 k × wordLen
更新right 每次 +wordLen 加入一块;超限时 left 循环 +wordLen 移出

⌨️ 落码步骤

1. 若 words 为空或 len(s) < wordLen × wordCount,直接返回空列表

2. 统计 need:遍历 words,need[w]++;记 wordLenwordCount

3. 对每个 offset ∈ [0, wordLen):初始化 left = offsetwindow = {}valid = 0

4. rightoffset 起每次 +wordLen:切出 word = s[right:right+wordLen]

5. 若 word 不在 need:清空 window、valid=0,left = right + wordLen(整块对齐后重启)

6. 否则 window[word]++,循环收缩:若 window[word] > need[word],移出 left 块并更新 valid,left += wordLen

7. 若 window[word] == need[word]valid++;当 valid == len(need) 时把 left 记入结果

8. 汇总所有 offset 的结果并返回

💻 代码实现

class Solution:
    def findSubstring(self, s: str, words: list[str]) -> list[int]:
        if not words:
            return []
        word_len = len(words[0])
        word_count = len(words)
        total_len = word_len * word_count
        if len(s) < total_len:
            return []

        need = {}
        for w in words:
            need[w] = need.get(w, 0) + 1

        result = []
        need_types = len(need)

        for offset in range(word_len):
            left = offset
            valid = 0
            window = {}

            right = offset
            while right + word_len <= len(s):
                word = s[right:right + word_len]

                if word not in need:
                    window.clear()
                    valid = 0
                    left = right + word_len
                else:
                    window[word] = window.get(word, 0) + 1
                    while window[word] > need[word]:
                        left_word = s[left:left + word_len]
                        if window[left_word] == need[left_word]:
                            valid -= 1
                        window[left_word] -= 1
                        left += word_len

                    if window[word] == need[word]:
                        valid += 1

                    if valid == need_types:
                        result.append(left)

                right += word_len

        return result
class Solution {
public:
    vector<int> findSubstring(string s, vector<string>& words) {
        vector<int> result;
        if (words.empty()) return result;

        int wordLen = words[0].size();
        int wordCount = words.size();
        int totalLen = wordLen * wordCount;
        if (s.size() < totalLen) return result;

        unordered_map<string, int> need;
        for (const string& w : words) need[w]++;

        int needTypes = need.size();

        for (int offset = 0; offset < wordLen; offset++) {
            int left = offset, valid = 0;
            unordered_map<string, int> window;

            for (int right = offset; right + wordLen <= s.size(); right += wordLen) {
                string word = s.substr(right, wordLen);

                if (!need.count(word)) {
                    window.clear();
                    valid = 0;
                    left = right + wordLen;
                } else {
                    window[word]++;
                    while (window[word] > need[word]) {
                        string leftWord = s.substr(left, wordLen);
                        if (window[leftWord] == need[leftWord]) valid--;
                        window[leftWord]--;
                        left += wordLen;
                    }
                    if (window[word] == need[word]) valid++;
                    if (valid == needTypes) result.push_back(left);
                }
            }
        }
        return result;
    }
};
// 时间 O(n × wordLen),空间 O(m)

📈 复杂度分析

时间复杂度 O(n × wordLen)
空间复杂度 O(m)

⚠️ 常见坑

必须对每个 offset ∈ [0, wordLen) 单独滑窗;只从 0 开始会漏掉如 "barfoo" 与 "foobar" 这类不同对齐的合法起点。

valid 只在 window[w] == need[w] 时 +1,超过 need 不算;收缩时须先判断 == need 再 --,与最小覆盖子串一致。

遇到不在 need 中的单词块要整块重置窗口,并把 left 跳到 right+wordLen,否则脏数据会误报合法起点。

🔍 必测边界 Case

Case 1:words 含重复词
words = ["word","good","best","word"],需要窗口内 "word" 恰好出现 2 次才算 valid。
Case 2:s 长度不足
len(s) < wordLen × wordCount → [](无需进入滑窗)
Case 3:words 为空
words = [] → [](按题意通常不会出现,但实现上应直接返回)