题目描述与核心约束
给定字符串
s和一个字符串数组words,其中所有单词长度相同,返回所有起始下标,使得从该下标起、长度为len(words) * len(word)的子串,刚好是words中所有单词恰好一次、任意顺序的拼接。[page:2]
关键约束:
- 所有
words[i]长度相同。 - 答案子串长度恒定:
totalLen = wordLen * wordsSize。[page:2]
一、直观暴力思路:枚举起点 + 词频匹配
先从最容易想到的办法说起,有助于理解后面的优化。
- 预处理:
wordLen = len(words[0])wordsSize = len(words)totalLen = wordLen * wordsSize- 若
len(s) < totalLen,直接返回空。
- 用哈希表
need统计每个单词需要出现的次数(处理重复单词)。[page:2]
然后枚举每个可能的起点 i:
i从0到len(s) - totalLen:- 取子串
sub = s[i .. i + totalLen)。 - 每
wordLen切一块,一共wordsSize块:- 第
j块对应s[i + j * wordLen : i + (j + 1) * wordLen]。
- 第
- 用局部哈希表
seen统计这些块的词频,如果中途遇到:- 某块不在
need里,或 - 某词出现次数超过
need,
就判定起点i不合法。
- 某块不在
- 否则
i是一个有效起点。
- 取子串
问题:
- 每个起点都要重新统计
wordsSize个单词,重复工作多,最坏复杂度接近O((n - totalLen) * wordsSize)。
二、利用"固定单词长度":按 offset 分组
题目给的一个非常重要的条件是:所有单词长度相同。[page:2]
记:
wordLen = len(words[0])
那么任意合法起点 i 必须是按 wordLen 对齐的 ,也就是你从 i 开始每次走 wordLen 个字符,刚好可以切出 wordsSize 个完整单词。
于是我们可以按 wordLen 的余数将所有起点分成 wordLen 组:
- offset = 0:考虑
i = 0, 0 + wordLen, 0 + 2*wordLen, ... - offset = 1:考虑
i = 1, 1 + wordLen, 1 + 2*wordLen, ... - ...
- offset = wordLen - 1。[page:2]
每一组 offset 上,我们都只在「单词边界」的起点上滑动窗口,这样就不会出现截断单词的问题。
三、把字符串看成「单词数组」
在固定 offset 下,可以把字符串抽象成一个"单词数组":
以 wordLen = 3, offset = 0 为例:
- 第 0 个单词:
w0 = s[0 : 3] - 第 1 个单词:
w1 = s[3 : 6] - 第 2 个单词:
w2 = s[6 : 9] - ...
在这个"单词数组"上,一个合法窗口就变成:长度为 wordsSize 的连续子数组 [k, k + wordsSize),里面的单词组成一个满足词频约束的多重集合。
这正是一个经典的滑动窗口问题:
- 在一个数组上,找所有长度为
wordsSize的连续区间,使得元素频率等于给定的need频率表。
只是这里的"元素"是长度为 wordLen 的子串。
四、滑动窗口(left/right)详细逻辑
现在只看一个 offset,假设已经有:
word_need:words 的词频哈希表。- 在当前 offset 链上:
left:窗口左边界(字符下标),初始为offset。right:窗口右边界(字符下标),初始化为offset,每次前进wordLen。word_seen:当前窗口内的词频。count:当前窗口内的单词个数(等价于(right - left) / wordLen)。
每一步:
-
如果
right + wordLen > s_len,说明再也取不到完整单词,结束当前 offset 的循环。 -
取当前单词:
textword = s[right : right + word_len] -
查询它在
word_need中的需求次数need:- 若
need == 0:- 说明这个
word根本不在目标单词集合里; - 当前窗口内无论有啥都不可能组成合法答案;
- 直接清空当前窗口:
- 清空
word_seen count = 0left = right + wordLen- 然后继续下一轮(
right下一轮也会自增)。
- 清空
- 说明这个
- 若
need > 0:- 把
word加入窗口:word_seen[word]++count++
- 把
- 若
-
如果加入后,
word_seen[word] > need,说明当前word超额了:- 从
left开始不断丢单词,直到word_seen[word] <= need:left_word = s[left : left + word_len]word_seen[left_word]--count--left += word_len
- 从
-
此时所有单词的出现次数都不超过需求;如果此时:
count == wordsSize:- 当前窗口正好包含
wordsSize个单词,且没有任何单词超额; - 说明
[left, right + wordLen)就是一个合法窗口; - 记录答案:
ans.push(left)。 - 然后为了继续向右寻找下一个方案,从左边再丢掉一个单词:
left_word = s[left : left + wordLen]word_seen[left_word]--count--left += word_len
- 当前窗口正好包含
-
最后:在循环结构中,每处理完当前
word后,right都会+= word_len,进入下一次迭代。
在整个 offset 上:
right只往右走一次到底。left也只往右走且不回退。
所以每个 offset 的时间是 "单词数" 的线性级别,总体对所有 offset 是 O(s_len / wordLen * wordLen) = O(s_len),再乘上每一步处理 word 的哈希代价 O(wordLen),总复杂度近似可认为是 O(s_len * wordLen)。[page:2]
五、时间与空间复杂度分析
-
时间复杂度:
- 构建
word_need:O(wordsSize * wordLen)。 - 对每个 offset,
left/right都只往前走,不回退,一共大约s_len / wordLen步。 - 有
wordLen个 offset,所以整体 word 步长的遍历次数为O(s_len)。 - 每步进行(长度为
wordLen的)哈希比较和更新,所以整体时间可以写为O(s_len * wordLen)。[page:2]
- 构建
-
空间复杂度:
word_need和word_seen的大小都与"不同 word 的数量"成正比,最坏为O(wordsSize * wordLen)。
六、C + uthash 的实现要点
你给出的 C 代码是一份比较"工业级"的实现,这里提炼其中几个关键点。
1. 使用 uthash 存储词频
定义节点结构:
c
struct word_node {
char *word; // 自己持有的字符串副本
int count; // 当前计数
UT_hash_handle hh;
};
几组辅助函数:
map_inc:计数 +1,如果不存在则新建节点、拷贝字符串。map_dec:计数 -1,不删除节点(词频在窗口中会频繁增减)。map_get:查询当前计数,不存在则返回 0。map_free:释放整张哈希表,包括节点内部的字符串副本。
这里用的是 uthash 的一个重要特性:
HASH_FIND/HASH_ADD_KEYPTR支持显式长度的 key,可以直接用(s + i, word_len)作为 key,而不需要额外构造临时字符串缓冲区。
同时,为了避免 hash 持有指向 s(或者栈上临时 buffer)的悬空指针,每个节点都维护一份自己的 malloc 字符串副本,再用这个副本作为哈希 key。
2. 主函数结构
函数签名:
c
int* findSubstring(char* s, char** words, int wordsSize, int* returnSize);
主要步骤:
- 边界处理:
s == NULL或words == NULL或wordsSize == 0,返回NULL。word_len == 0或total_len > s_len,返回NULL。
- 构建
word_need:(用map_inc累加) - 预估答案长度上界,
malloc足够大小的ans数组。 - 外层循环
offsetin[0 .. word_len-1]:- 每个 offset 开始前,用
map_free(&word_seen)清空窗口哈希。 - 维护
left = offset,count = 0。 - 内层循环
right从offset开始,每次增word_len:word = s + rightneed = map_get(word_need, word, word_len)need == 0:map_free(&word_seen)重置窗口;count = 0;left = right + word_len;continue;
- 否则:
map_inc(&word_seen, word, word_len);count++;while map_get(word_seen, word, word_len) > need:- 从
left开始map_dec+left += word_len+count--;
- 从
- 若
count == wordsSize:- 记录
ans[ans_size++] = left; - 将最左边单词
map_dec,left += word_len,count--。
- 记录
- 每个 offset 开始前,用
- 遍历完所有 offset,释放
word_need和word_seen,返回ans和returnSize。
代码结构大致如下(伪代码版):
c
int* findSubstring(char* s, char** words, int wordsSize, int* returnSize) {
// ... 边界判断 ...
build word_need;
int *ans = malloc(max_ans * sizeof(int));
int ans_size = 0;
struct word_node *word_seen = NULL;
for (int offset = 0; offset < word_len; offset++) {
map_free(&word_seen);
int left = offset;
int count = 0;
for (int right = offset; right + word_len <= s_len; right += word_len) {
const char *word = s + right;
int need = map_get(word_need, word, word_len);
if (need == 0) {
map_free(&word_seen);
count = 0;
left = right + word_len;
continue;
}
map_inc(&word_seen, word, word_len);
count++;
while (map_get(word_seen, word, word_len) > need) {
const char *left_word = s + left;
map_dec(&word_seen, left_word, word_len);
count--;
left += word_len;
}
if (count == wordsSize) {
ans[ans_size++] = left;
const char *left_word = s + left;
map_dec(&word_seen, left_word, word_len);
count--;
left += word_len;
}
}
}
map_free(&word_need);
map_free(&word_seen);
*returnSize = ans_size;
return ans;
}
七、小结:从"暴力枚举"到"分组滑窗"的进化
整个思路的演进可以概括为三步:
- 暴力枚举起点 :
- 每个起点取长度
totalLen的子串,切成wordsSize段,做词频匹配。
- 每个起点取长度
- 利用固定 word 长度,按 offset 分组 :
- 只在合法的单词边界起点上枚举,把问题映射到单词数组。
- 在单词数组上滑动窗口 :
- 使用
left/right和词频哈希表word_seen,每次只增减一个单词的计数,避免重复统计整个窗口。
- 使用
在工程实现上,C 语言版本配合 uthash 可以一边保持较高性能,一边让代码结构清晰、可维护。
如果你之后写博客,可以把这篇再按个人风格润色,比如:
- 加几张图解释 offset 分组和窗口滑动;
- 加一两个详细 trace(手动走一遍
s = "barfoofoobarthefoobarman"的过程); - 对比一下"暴力版"和"滑动窗口版"的复杂度和关键差别。
这样就是一篇非常完整的题解了。