LeetCode 30:串联所有单词的子串(滑动窗口) —— 题解

👋 欢迎阅读

一.题目

30. 串联所有单词的子串 - 力扣(LeetCode)

🎯 欢迎来到「串联所有单词的子串」题解之旅! 本文将带你从"在一长串字符中寻找连续单词组合"这一直观场景出发,深入理解分组滑动窗口 + 哈希计数 的巧妙运用,并掌握如何按单词长度分组扫描定位所有串联子串的起点

在开始之前,建议你先:

  • 了解题目背景 :这是 LeetCode 30 题,给定字符串 s 和单词数组 words,找出 s 中恰好由 words 所有单词各一次串联 形成的子串起始下标。本质上,串联子串长度固定为单词长度 × 单词个数 ,问题转化为定长窗口内的单词频次比对

  • 明确学习目标 :掌握按长度分组 + 滑动窗口 技术,理解有效计数 count 的维护原理,并熟练处理窗口超长时的出窗口逻辑

  • 准备好环境 :建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证示例(如 s = "barfoothefoobarman"words = ["foo","bar"] 输出 [0, 9])。

本文将从问题转化、分组枚举、进窗口、出窗口、更新结果 到代码实现,层层递进。即使你对分组滑动窗口 还不熟悉,我们也会从"把字符串按单词长度切成多列,每列独立滑窗"这一直觉出发,让你轻松抓住核心思想 ------分组对齐起点,单词频次匹配即命中。现在,让我们一起滑动单词窗口,找出所有串联子串的起点吧! 📍🔍

二.做题思路

一、问题分析(前置分析)

  • 题目要求:在 s 中找出所有由 words每个单词恰好一次 串联而成的子串,返回起始下标(单词顺序任意)。
  • 关键约束:words所有单词等长 (记为 len);串联子串总长度固定为 len * m;结果只需起始下标
  • 核心思路:串联子串长度固定,用定长滑动窗口 + 单词频次哈希 ,配合外层按余数分组覆盖所有可能的起点。

二、算法策略(分组滑动窗口 + 哈希计数)

核心步骤:

  1. 统计 words 各单词频次到 hash1,记录 len(单词长度)和 m(单词个数)。
  2. 外层分组 :起点下标对 len 取模,枚举 i ∈ [0, len),每组独立初始化窗口(hash2 清空、left = right = icount = 0)。
  3. 进窗口 :取 s.substr(right, len) 加入 hash2,若 hash2[word] <= hash1[word]count++
  4. 出窗口 :当窗口字符宽度 right - left + 1 > len * m 时,移除 s.substr(left, len),若移除前 hash2[word] <= hash1[word]count--left += len
  5. 更新结果 :若 count == m,记录 left 为串联子串起点。

示例执行过程s = "barfoothefoobarman"words = ["foo","bar"],len=3,m=2,窗口总宽=6):

步骤 变量变化 操作 结果
right=0 hash2bar=1, count=1 进 "bar" 未命中
right=3 hash2foo=1, count=2 进 "foo",窗口宽=6 命中,记录 left=0
right=6 count 2→1, left 0→3 进 "the" 不计;出 "bar" 减 count 未命中
right=9 count=1, left 3→6 进 "foo" 超配额;出 "foo" 不减 未命中
right=12 count 1→2, left 6→9 进 "bar";出 "the" 不减 命中,记录 left=9
right=15 count 2→1, left 9→12 进 "man" 不计;出 "foo" 减 count 未命中

i=1i=2 分组扫描后均无命中,最终返回 [0, 9],与题目示例一致。

三、正确性说明(简单版本)

  • 覆盖所有起点 :任意合法起点下标对 len 取模必然落在 [0, len),外层枚举全部余数即覆盖所有可能起点,不漏解
  • 窗口恒为 m 个单词 :每次右移 len 后只要宽度超过 len * m 立即收缩左端,被判断的窗口始终恰好容纳 m 个单词,不重不漏
  • count 语义可靠 :count 统计窗口中未超出 hash1 配额的单词个数,count == m 等价于窗口频次与 words 完全一致,不错解
  • 出窗口顺序安全 :先依据移除前的频次判断是否减 count,再真正减频次,保证 count 始终准确。

四、实现细节(边界防护)

  • 初始化:hash1(words 频次)、len = words[0].size()m = words.size();每组 ihash2 清空、left = right = icount = 0
  • 边界防护:外层循环 i ∈ [0, len);内层循环条件 right + len <= n 保证 substr 不越界 ;收缩判断用字符宽度 right - left + 1 > len * m
  • 复杂度:时间 O(n × len) (外层 len 组 × 每组约 n/len 次迭代 × 每次 substr O(len)),空间 O(m × len)(哈希表存储单词)。
  • 关键判断if (hash2[in] <= hash1[in]) count++(进窗口配额判断)、if (right - left + 1 > len * m)(窗口收缩判断)、if (count == m)(命中判断)。

五、返回值(目标映射)

  • 返回 v:所有满足条件的串联子串起始下标 。由于窗口从左向右扫描、分组按余数递增枚举,结果自然升序排列,对应题目"返回所有起始索引"。

三.代码

cpp 复制代码
class Solution
{
public:
    vector<int> findSubstring(string s, vector<string>& words)
    {
        vector<int> v;                          // 结果数组:记录所有串联子串的起始下标
        unordered_map<string, int> hash1;       // words 的频次表

        // 1. 预处理:统计 words 中每个单词的出现次数
        for (const auto& w : words)
        {
            hash1[w]++;
        }

        int n = s.size();                       // 主串长度
        int len = words[0].size();              // 单词长度(words 中所有单词等长)
        int m = words.size();                   // 单词个数

        // 2. 外层分组:串联子串的起点对 len 取模,枚举所有余数 i
        for (int i = 0; i < len; i++)
        {
            unordered_map<string, int> hash2;   // 每组独立:窗口内单词频次表
            // 3. 滑动窗口:进窗口 -> 判断/出窗口 -> 更新结果(步长为 len)
            for (int left = i, right = i, count = 0; right + len <= n; right += len)
            {
                // ---------- 进窗口 ----------
                string in = s.substr(right, len);   // 进入窗口的单词
                hash2[in]++;
                if (hash2[in] <= hash1[in])
                {
                    count++;                        // 该单词仍在 words 的"配额"内
                }

                // ---------- 判断 / 出窗口 ----------
                if (right - left + 1 > len * m)     // 窗口宽度超过串联总长,必须收缩
                {
                    string out = s.substr(left, len); // 即将离开窗口的单词
                    if (hash2[out] <= hash1[out])
                    {
                        count--;                     // 移除前该单词曾计入配额
                    }
                    hash2[out]--;                    // 窗口频次 -1
                    left += len;                     // 左指针右移一个单词
                }

                // ---------- 更新结果 ----------
                if (count == m)                      // 有效单词数等于 m,频次完全匹配
                {
                    v.push_back(left);               // 记录当前窗口起点
                }
            }
        }
        return v;                                    // 4. 返回所有串联子串起始下标
    }
};

四、易错点分析

难点1:外层分组循环的必要性

cpp 复制代码
for (int i = 0; i < len; i++)
{
    unordered_map<string, int> hash2;
    for (int left = i, right = i, count = 0; right + len <= n; right += len)
    { ... }
}

串联子串的起点下标对 len 取模后的余数 决定了它属于哪个"列"。单次滑动窗口只能覆盖起点余数固定的位置,所以必须枚举 i ∈ [0, len) 的全部余数。漏掉任何一个分组都会漏解,这是本题与 438 题最本质的区别,也是最容易忽略的设计。

难点2:进窗口时"先加频次,再判断配额"

cpp 复制代码
string in = s.substr(right, len);
hash2[in]++;
if (hash2[in] <= hash1[in])
{
    count++;
}

count 统计的是窗口中未超出 words 配额 的单词个数。判断必须基于加入后的最新频次 ,所以要先 hash2[in]++ 再比较;顺序颠倒会用旧频次误判,导致 count 偏小、漏解

难点3:出窗口时 count-- 基于"移除前"的频次

cpp 复制代码
if (hash2[out] <= hash1[out])
{
    count--;
}
hash2[out]--;
left += len;

这里判断的是该单词被移除前 是否处于配额内。若先 hash2[out]-- 再判断,频次已减少,原本超配额的单词可能被误判为"在配额内",导致 count 漏减、结果错乱 。判断与修改的先后顺序是本题最易写错的地方。

难点4:收缩条件用"字符宽度"而非"单词个数"

cpp 复制代码
if (right - left + 1 > len * m)

leftright字符下标right - left + 1 是窗口的字符宽度,必须与总长 len * m 比较。如果误写成 right - left + 1 > m 或直接用单词计数比较,窗口要么永远不收缩,要么被过度收缩,命中条件彻底失效

难点5:每组 i 必须重置 hash2 与 count

cpp 复制代码
unordered_map<string, int> hash2;
for (int left = i, right = i, count = 0; ...)

每个分组是独立 的滑动过程,hash2count 必须在每组开始时重新初始化。若复用上一组的残留状态,窗口频次会叠加出错,count 永远无法正确达到 m

五、流程图

🎯 闭幕

🎉 恭喜你完成了「串联所有单词的子串」问题的学习!

为了巩固知识并进一步拓展,建议你:

🚀 动手实践

在 LeetCode 上提交代码,尝试不同的测试用例。

💡 深入思考

  • 本题采用 分组滑动窗口 ,外层循环枚举起点对 len余数i 从 0 到 len-1)。为什么要分组枚举?如果只用一个从 0 开始的窗口,会遗漏哪些情况?

  • 代码中 count 变量记录窗口内 有效单词数 (即频次不超过 hash1 的单词个数)。为什么 count == m 就能确定窗口是串联子串 ?这与上一题"字母异位词"的 count 逻辑有何联系?

  • 窗口收缩条件为 right - left + 1 > len * m,这里 rightleft字符下标 ,而窗口每次移动的步长是 len这个条件是否准确反映了窗口内单词数量超过 m 如果 left 不是 len 的倍数,会有什么影响?

  • 哈希表 hash1hash2 使用 unordered_map<string, int>如果 words 中存在大量重复单词,计数逻辑是否仍然正确?请举例说明。

  • 外层循环 i0len-1为什么是 i < len 而不是 i <= len 如果 len 很大(例如 100),时间复杂度会变成 O(len * n/len) = O(n),是否仍然高效?

如果你觉得本文对你有所帮助,欢迎:

👍 点赞 / 收藏

👤 关注作者 ,获取更多题解

💬 留言交流你的疑问或优化思路


📌 深入思考答案

  • 分组枚举的原因 :因为窗口必须按 单词边界 对齐,而子串的起始位置可能落在任意余数上。单窗口只能覆盖从 0 开始的对齐方式,会遗漏从 1、2、...、len-1 开始的匹配,因此需枚举所有余数。

  • count == m 的等价性count 统计的是窗口内频次不超过 hash1 的单词个数,且窗口总单词数 = m(长度限制保证),因此所有单词的频次都恰好匹配,即完全匹配。该逻辑与"字母异位词"中的 count 原理相同。

  • 收缩条件正确性right - left + 1 是当前窗口的 字符长度 ,比较 > len*m 即窗口单词数超过 m,与 left 是否对齐无关,因为 left 始终与 right 保持同步步长 len 移动,因此条件准确。

  • 重复单词处理hash1 中重复单词计数 >1,窗口内同样计数,count 仅在频次不超过 hash1 时才累加,因此能正确处理重复单词。

  • i < len 的原因 :因为余数只有 0,1,...,len-1len 种,循环 i 从 0 到 len-1 刚好覆盖所有起始位置模 len 的可能性;若 i == len 则与 i=0 重复,无需。复杂度为 O(n),因为每个字符被访问常数次,依然高效。

祝你在 算法之路 上越走越稳,早日攻克每一道难题!下次见 🚀✨

相关推荐
白狐_7981 小时前
408 数据结构|完全二叉树两道典型题:第 6 层叶结点数与叶结点总数
数据结构·算法
lucas_AI1 小时前
喂张白纸也能吐出证件号?文档 MLLM 的"关系级泄露"被测出来了
人工智能·算法·掘金技术征文
手写码匠2 小时前
华为云Flexus+DeepSeek征文|Dify 多 Agent 灰度发布实战:让每一次变更都“小步快跑、随时可回滚“
人工智能·深度学习·算法·aigc
小小龙学IT2 小时前
oneTBB 开源并行编程库深度解析:从工作窃取调度器到 flow graph 实战
c++·开源
Nil2082 小时前
leetcode 48旋转图像
算法·leetcode·职场和发展
嘟嘟07173 小时前
顺时针螺旋填充 n×n 矩阵:手撕 generateMatrix 的四个 for 循环
javascript·算法·面试
Nil2083 小时前
leetcode 206反转链表
算法·leetcode·链表
Kstheme3 小时前
大模型内部是怎么运作的?从「一个神经元」拆到「残差流」
算法
郝学胜_神的一滴3 小时前
并查集深度入门:从玄学抽象到 QuickFind & QuickUnion 源码实战
数据结构·算法