LeetCode 30|串联所有单词的子串(滑动窗口进阶)

39 阅读3分钟

这道题是字符串滑动窗口里非常经典、也非常容易写崩的一题。如果你能真正吃透它,后面遇到“固定模式匹配 + 统计次数”的题,基本都能一眼看穿。


一、题目要求

给定:

  • 一个字符串 s
  • 一个字符串数组 words

要求找出 s 中所有起始下标,使得从该下标开始的子串:

  1. 子串长度 = words.length * 单词长度
  2. 子串中 恰好包含 words 中所有单词各一次
  3. 单词顺序可以任意,但不能多也不能少

示例:

输入:
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 中不同单词的数量

六、总结

这道题本质可以总结为一句话:

固定长度 + 按单词步进的滑动窗口 + 哈希表计数