题目描述

✅ 30. 串联所有单词的子串

image-20260928235210235

image-20260928235210236

题意分析

在字符串 s 中找出所有符合条件的连续子串:它们恰好由 words 中的全部单词按某种顺序直接连接而成。每次出现都必须使用一次,单词之间不能夹杂其他字符,返回这些子串的起始下标,答案顺序不限。

所有单词长度相同。若词长为 L、单词总数为 m,答案子串长度就固定为 m * L。单词可以重复,因此需要匹配每个词的出现次数;不同答案可以重叠,不能找到一个后就跳过整段。

解法:按单词长度分组的滑动窗口

核心思路

[!blue]

等长单词让切分边界固定。一个合法起点确定后,后面的词块都间隔 L 个字符。将起点按除以 L 的余数分成 L 组,对每个 offset 独立扫描完整词块,就能像按字符滑动一样复用窗口,同时覆盖所有可能的起始下标。

need 保存每个目标单词需要出现多少次,window 保存当前窗口的词频,left 是窗口起点,used 是窗口中的词块数量。右端每次加入一个长度为 L 的新词,窗口保持连续,左端也只能按整词移动。

若新词不在目标词表中,任何跨过这个词块的候选都不可能合法。将窗口替换为空计数表,令 used = 0,并把 left 移到该词之后,重新开始扫描。这里重新创建空表,不反复清理旧表保留的大容量,避免曾经扩大的窗口给后续每次重置带来额外扫描。

若新词属于目标,先增加它的计数与 used。此前所有词频都不超额,所以此时只有刚加入的这个词可能超额;只要它的次数仍高于需求,就持续移出左端词,并同步更新计数、left 和 used,直到恢复合法数量范围。被移出的前缀不可能构成以当前右端结束的合法答案,因为它仍包含超额的词。

恢复之后,窗口里每种词的数量都不超过需求。若总词数又恰好等于 m,所有需求之和已被填满,就能推出每种词的次数都恰好匹配,记录 left 即可,无需再遍历整张词频表比较。

记录答案后只移出最左的一个词,再继续读取后面的词块。这样下一个窗口仍能复用已匹配的后缀,不会丢失重叠答案。每个偏移组都独立维护计数,最终汇总全部起点。

解题步骤

  1. 统计 words 中每个词的需求次数;若文本不足总长度,直接返回空结果。
  2. 枚举 offset = 0..L - 1,从该起点创建独立窗口,每次右移一个词长。
  3. 遇到非目标词时重建空窗口,并把左端放到该词之后。
  4. 遇到目标词时加入窗口;若刚加入的词超额,就逐词移出左端,直到次数合法。
  5. used == m 时记录左端起点,再移出一个左端词,继续搜索重叠结果。
  6. 所有偏移组结束后返回答案。

代码实现

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 = new HashMap<>();
                    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 {
                // 非目标词切断当前候选,计数、词数和左边界必须一起重置。
                window = make(map[string]int)
                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)$。构造需求表需处理 m 个词;所有偏移组共扫描 $O(n)$ 个词块,每块至多入窗、出窗各一次,截取或哈希词块最多需要 $O(L)$。重置使用新空表,不扫描旧表的历史容量。
  • 空间复杂度:$O(mL)$,需求表与当前窗口最多保存 m 种目标词及次数,不计返回结果。

关键点总结

[!green]

  • 按词长余数分组,让同一组的窗口始终以完整单词为单位移动。
  • 非目标词切断整段候选,目标词超额只需要删除窗口前缀。
  • 所有词频不超额且总词数恰好够用,就等价于完整频次匹配。

易错点总结

[!yellow]

  • 只扫描从零开始的一组,会漏掉其他切词边界对应的答案。
  • 用集合代替词频表,无法表达同一单词需要多次出现的情况。
  • 遇到非目标词时只移动左端,却保留旧计数或旧 used,会使状态与实际窗口脱节。
  • 用一次 if 收缩代替 while,可能尚未移出旧的超额词,就继续接受错误窗口。
  • 移出左端词时忘记递减 used,会把不足指定长度的窗口误认为完整串联。
  • 记录答案后跳过整个窗口,会漏掉与当前答案重叠的起点;这里只移出一个词。
  • 读取词块前没有检查 right + L <= s.length(),会在文本尾部截取越界。

相似题目

题目 难度 关联与区别
438. 找到字符串中所有字母异位词 中等 同样用定长窗口比较频次,本题窗口单位是等长单词,需要按单词长度分别扫描偏移。
76. 最小覆盖子串 困难 同样维护需求计数,原题允许任意长度的覆盖窗口,本题串联长度由词数和词长固定。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/69908263
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!