👋 欢迎阅读

一.题目
🎯 欢迎来到「串联所有单词的子串」题解之旅! 本文将带你从"在一长串字符中寻找连续单词组合"这一直观场景出发,深入理解分组滑动窗口 + 哈希计数 的巧妙运用,并掌握如何按单词长度分组扫描 来定位所有串联子串的起点。
在开始之前,建议你先:
-
了解题目背景 :这是 LeetCode 30 题,给定字符串
s和单词数组words,找出s中恰好由words所有单词各一次串联 形成的子串起始下标。本质上,串联子串长度固定为单词长度 × 单词个数 ,问题转化为定长窗口内的单词频次比对。 -
明确学习目标 :掌握按长度分组 + 滑动窗口 技术,理解有效计数 count 的维护原理,并熟练处理窗口超长时的出窗口逻辑。
-
准备好环境 :建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证示例(如
s = "barfoothefoobarman",words = ["foo","bar"]输出[0, 9])。
本文将从问题转化、分组枚举、进窗口、出窗口、更新结果 到代码实现,层层递进。即使你对分组滑动窗口 还不熟悉,我们也会从"把字符串按单词长度切成多列,每列独立滑窗"这一直觉出发,让你轻松抓住核心思想 ------分组对齐起点,单词频次匹配即命中。现在,让我们一起滑动单词窗口,找出所有串联子串的起点吧! 📍🔍
二.做题思路
一、问题分析(前置分析)
- 题目要求:在
s中找出所有由words中每个单词恰好一次 串联而成的子串,返回起始下标(单词顺序任意)。 - 关键约束:
words中所有单词等长 (记为len);串联子串总长度固定为len * m;结果只需起始下标。 - 核心思路:串联子串长度固定,用定长滑动窗口 + 单词频次哈希 ,配合外层按余数分组覆盖所有可能的起点。
二、算法策略(分组滑动窗口 + 哈希计数)
核心步骤:
- 统计
words各单词频次到hash1,记录len(单词长度)和m(单词个数)。 - 外层分组 :起点下标对
len取模,枚举i ∈ [0, len),每组独立初始化窗口(hash2清空、left = right = i、count = 0)。 - 进窗口 :取
s.substr(right, len)加入hash2,若hash2[word] <= hash1[word]则 count++。 - 出窗口 :当窗口字符宽度
right - left + 1 > len * m时,移除s.substr(left, len),若移除前hash2[word] <= hash1[word]则 count-- ,left += len。 - 更新结果 :若
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=1、i=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();每组i内hash2清空、left = right = i、count = 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)
left、right是字符下标 ,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; ...)
每个分组是独立 的滑动过程,
hash2、count必须在每组开始时重新初始化。若复用上一组的残留状态,窗口频次会叠加出错,count 永远无法正确达到 m。
五、流程图

🎯 闭幕

🎉 恭喜你完成了「串联所有单词的子串」问题的学习!
为了巩固知识并进一步拓展,建议你:
🚀 动手实践
在 LeetCode 上提交代码,尝试不同的测试用例。
💡 深入思考
-
本题采用 分组滑动窗口 ,外层循环枚举起点对
len的 余数 (i从 0 到len-1)。为什么要分组枚举?如果只用一个从 0 开始的窗口,会遗漏哪些情况? -
代码中
count变量记录窗口内 有效单词数 (即频次不超过hash1的单词个数)。为什么count == m就能确定窗口是串联子串 ?这与上一题"字母异位词"的count逻辑有何联系? -
窗口收缩条件为
right - left + 1 > len * m,这里right和left是 字符下标 ,而窗口每次移动的步长是len。这个条件是否准确反映了窗口内单词数量超过m? 如果left不是len的倍数,会有什么影响? -
哈希表
hash1和hash2使用unordered_map<string, int>,如果words中存在大量重复单词,计数逻辑是否仍然正确?请举例说明。 -
外层循环
i从0到len-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-1共len种,循环i从 0 到len-1刚好覆盖所有起始位置模len的可能性;若i == len则与i=0重复,无需。复杂度为 O(n),因为每个字符被访问常数次,依然高效。
祝你在 算法之路 上越走越稳,早日攻克每一道难题!下次见 🚀✨