每日算法练习:LeetCode 30. 串联所有单词的子串 ✅

大家好,我是你们的算法小伙伴。今天我们来练习一道字符串处理的困难题 ------LeetCode 30. 串联所有单词的子串。这道题是「滑动窗口」的复杂变种,核心考察如何批量匹配固定长度的字符串 ,并通过哈希表统计来快速验证是否满足串联条件,是面试中数组与字符串结合的高难度标杆题。


题目描述

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

找出 s包含 words 中所有单词(任意顺序串联) 的子串的所有起始索引。

注意

  1. 子串必须是连续的;
  2. 必须包含 words 中的所有单词 ,且每个单词出现的次数与 words 中一致;
  3. 不需要按 words 中的顺序排列。

示例 1:

复制代码
输入:s = "barfoothefoobarman", words = ["foo","bar"]

输出:[0,9]

解释:子串 "barfoo" 开始于 0,包含 "bar" 和 "foo";子串 "foobar" 开始于 9,包含 "foo" 和 "bar"。

示例 2:

复制代码
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]

输出:[]

解释:没有满足条件的子串。

示例 3:

复制代码
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]

输出:[6,9,12]

提示:

  • 1 <= s.length <= 10⁴
  • 1 <= words.length <= 5000
  • 1 <= words[i].length <= 30
  • words[i]s 由小写英文字母组成

解题思路

核心分析

  1. 固定长度 :所有 words[i] 长度相同,记为 wordLen。因此,目标子串的总长度固定为 totalLen = words.length * wordLen
  2. 计数对比 :需要判断窗口内的单词组合是否与 words 的单词统计完全一致。
  3. 避免重复计算 :由于单词长度固定,遍历起始点时只需从 0wordLen-1 开始,即可覆盖所有可能情况。

方法:滑动窗口 + 双哈希表 + 种类匹配

核心步骤

  1. 先用哈希表 targetMap 统计 words 中每个单词的目标次数
  2. 单词长度 分组遍历起始点 i0 ~ wordLen-1),覆盖所有可能切分方式
  3. 滑动窗口 维护当前窗口内的单词计数 windowMap
  4. matchCount 记录已匹配完成的单词种类数
  5. 当窗口内单词超次数时,收缩左指针
  6. matchCount == 目标单词种类数 时,记录答案并滑动窗口

代码实现

复制代码
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 wordCount = words.length;
        int sLen = s.length();

        // 统计words中每个单词的目标次数
        Map<String, Integer> targetMap = new HashMap<>();
        for (String word : words) {
            targetMap.put(word, targetMap.getOrDefault(word, 0) + 1);
        }

        // 遍历所有可能的起始分组 0 ~ wordLen-1
        for (int i = 0; i < wordLen; i++) {
            int left = i;
            int matchCount = 0; // 已匹配完成的单词种类数
            Map<String, Integer> windowMap = new HashMap<>();

            // 右指针按单词长度步进
            for (int right = i; right <= sLen - wordLen; right += wordLen) {
                String currentWord = s.substring(right, right + wordLen);

                // 当前单词在目标列表中
                if (targetMap.containsKey(currentWord)) {
                    windowMap.put(currentWord, windowMap.getOrDefault(currentWord, 0) + 1);

                    // 该单词次数刚好达标,种类数+1
                    if (windowMap.get(currentWord).equals(targetMap.get(currentWord))) {
                        matchCount++;
                    }

                    // 次数超标,必须收缩左指针
                    while (windowMap.get(currentWord) > targetMap.get(currentWord)) {
                        String leftWord = s.substring(left, left + wordLen);
                        
                        // 如果这个单词刚好达标,移除后种类数-1
                        if (windowMap.get(leftWord).equals(targetMap.get(leftWord))) {
                            matchCount--;
                        }
                        
                        windowMap.put(leftWord, windowMap.get(leftWord) - 1);
                        left += wordLen;
                    }

                    // 所有单词种类都匹配成功 → 找到答案
                    if (matchCount == targetMap.size()) {
                        result.add(left);

                        // 记录后滑动窗口,继续寻找下一个
                        String leftWord = s.substring(left, left + wordLen);
                        if (windowMap.get(leftWord).equals(targetMap.get(leftWord))) {
                            matchCount--;
                        }
                        windowMap.put(leftWord, windowMap.get(leftWord) - 1);
                        left += wordLen;
                    }
                } else {
                    // 遇到无效单词,直接重置整个窗口
                    windowMap.clear();
                    matchCount = 0;
                    left = right + wordLen;
                }
            }
        }
        return result;
    }
}

代码详细讲解

1. 基础变量

  • wordLen:每个单词的长度
  • targetMap:记录每个单词需要出现多少次
  • matchCount核心变量 ,记录当前窗口内次数完全匹配的单词种类数

2. 为什么要遍历 0 ~ wordLen-1

因为单词是固定长度切分的,比如单词长度 = 4:

  • 从 0 开始切:0,4,8,12...
  • 从 1 开始切:1,5,9,13...
  • 从 2 开始切:2,6,10,14...
  • 从 3 开始切:3,7,11,15...

只需要遍历 wordLen 次,就能覆盖所有可能的合法窗口

3. 滑动窗口核心逻辑

  1. 右指针加入单词 → 更新计数
  2. 如果次数达标matchCount++
  3. 如果次数超标 → 进入 while 收缩左指针
  4. 所有种类都达标 → 记录答案,并滑动左指针继续找下一个

4. 最关键的 matchCount 设计

matchCount 表示:

当前窗口内,次数完全等于目标次数的单词有多少种

matchCount == targetMap.size()说明:所有单词都刚好匹配,不多不少→ 这就是合法子串。


复杂度分析

指标 复杂度 说明
时间复杂度 O(L×N) L = 单词长度,N = 字符串 s 长度,每个字符仅访问 2 次
空间复杂度 O(M) M=words 中不同单词数量

高频易错点总结

  1. 忘记按 wordLen 分组遍历 → 超时 / 漏解
  2. matchCount 统计错误 → 最容易错
  3. 收缩左指针时没有更新 matchCount → 答案错误
  4. 遇到无效单词没有重置窗口 → 计数混乱
  5. 找到答案后没有滑动左指针 → 重复记录答案

总结

这道题是滑动窗口的最高难度应用之一,也是面试高频困难题。核心思想:

  • 固定单词长度 → 分组遍历
  • 次数匹配 → 双哈希表
  • 快速判断是否合法 → matchCount 统计种类数
  • 窗口超标 → 收缩左指针

今天的每日算法练习就到这里,我们明天再见!👋

相关推荐
玉树临风ives2 小时前
atcoder ABC 453 题解
数据结构·c++·算法·图论·atcoder
田梓燊2 小时前
leetcode 48
算法·leetcode·职场和发展
mmz12072 小时前
深度优先搜索DFS2(c++)
c++·算法·深度优先
6Hzlia2 小时前
【Hot 100 刷题计划】 LeetCode 169. 多数元素 | C++ 哈希表基础解法
c++·leetcode·散列表
米粒12 小时前
力扣算法刷题 Day 38 (打家劫舍专题)
算法·leetcode·职场和发展
Robot_Nav2 小时前
RC-ESDF与Lazy Theta* 算法结合进行离线全局路径的生成
算法·全局规划·esdf
papership2 小时前
【入门级-算法-7、搜索算法:深度优先搜索】
算法·深度优先
山甫aa3 小时前
哈希集合-----从零开始的数据结构学习
数据结构·算法·哈希算法
say_fall3 小时前
有关算法的简单数学问题
数据结构·c++·算法·职场和发展·蓝桥杯