LeetCode 30:Substring with Concatenation of All Words 题解(含 C 语言 uthash 实现)

题目描述与核心约束

给定字符串 s 和一个字符串数组 words,其中所有单词长度相同,返回所有起始下标,使得从该下标起、长度为 len(words) * len(word) 的子串,刚好是 words 中所有单词恰好一次、任意顺序的拼接。[page:2]

关键约束:

  • 所有 words[i] 长度相同。
  • 答案子串长度恒定:totalLen = wordLen * wordsSize。[page:2]

一、直观暴力思路:枚举起点 + 词频匹配

先从最容易想到的办法说起,有助于理解后面的优化。

  1. 预处理:
    • wordLen = len(words[0])
    • wordsSize = len(words)
    • totalLen = wordLen * wordsSize
    • len(s) < totalLen,直接返回空。
  2. 用哈希表 need 统计每个单词需要出现的次数(处理重复单词)。[page:2]

然后枚举每个可能的起点 i

  • i0len(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)。

每一步:

  1. 如果 right + wordLen > s_len,说明再也取不到完整单词,结束当前 offset 的循环。

  2. 取当前单词:

    text 复制代码
    word = s[right : right + word_len]
  3. 查询它在 word_need 中的需求次数 need

    • need == 0
      • 说明这个 word 根本不在目标单词集合里;
      • 当前窗口内无论有啥都不可能组成合法答案;
      • 直接清空当前窗口:
        • 清空 word_seen
        • count = 0
        • left = right + wordLen
        • 然后继续下一轮(right 下一轮也会自增)。
    • need > 0
      • word 加入窗口:
        • word_seen[word]++
        • count++
  4. 如果加入后,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
  5. 此时所有单词的出现次数都不超过需求;如果此时:

    • count == wordsSize
      • 当前窗口正好包含 wordsSize 个单词,且没有任何单词超额;
      • 说明 [left, right + wordLen) 就是一个合法窗口;
      • 记录答案:ans.push(left)
      • 然后为了继续向右寻找下一个方案,从左边再丢掉一个单词:
        • left_word = s[left : left + wordLen]
        • word_seen[left_word]--
        • count--
        • left += word_len
  6. 最后:在循环结构中,每处理完当前 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_needO(wordsSize * wordLen)
    • 对每个 offset,left/right 都只往前走,不回退,一共大约 s_len / wordLen 步。
    • wordLen 个 offset,所以整体 word 步长的遍历次数为 O(s_len)
    • 每步进行(长度为 wordLen 的)哈希比较和更新,所以整体时间可以写为 O(s_len * wordLen)。[page:2]
  • 空间复杂度:

    • word_needword_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);

主要步骤:

  1. 边界处理:
    • s == NULLwords == NULLwordsSize == 0,返回 NULL
    • word_len == 0total_len > s_len,返回 NULL
  2. 构建 word_need:(用 map_inc 累加)
  3. 预估答案长度上界,malloc 足够大小的 ans 数组。
  4. 外层循环 offset in [0 .. word_len-1]
    • 每个 offset 开始前,用 map_free(&word_seen) 清空窗口哈希。
    • 维护 left = offsetcount = 0
    • 内层循环 rightoffset 开始,每次增 word_len
      • word = s + right
      • need = 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_decleft += word_lencount--
  5. 遍历完所有 offset,释放 word_needword_seen,返回 ansreturnSize

代码结构大致如下(伪代码版):

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;
}

七、小结:从"暴力枚举"到"分组滑窗"的进化

整个思路的演进可以概括为三步:

  1. 暴力枚举起点
    • 每个起点取长度 totalLen 的子串,切成 wordsSize 段,做词频匹配。
  2. 利用固定 word 长度,按 offset 分组
    • 只在合法的单词边界起点上枚举,把问题映射到单词数组。
  3. 在单词数组上滑动窗口
    • 使用 left/right 和词频哈希表 word_seen,每次只增减一个单词的计数,避免重复统计整个窗口。

在工程实现上,C 语言版本配合 uthash 可以一边保持较高性能,一边让代码结构清晰、可维护。


如果你之后写博客,可以把这篇再按个人风格润色,比如:

  • 加几张图解释 offset 分组和窗口滑动;
  • 加一两个详细 trace(手动走一遍 s = "barfoofoobarthefoobarman" 的过程);
  • 对比一下"暴力版"和"滑动窗口版"的复杂度和关键差别。

这样就是一篇非常完整的题解了。

相关推荐
hacker7075 小时前
Visual Studio安装教程(C#开发版)
ide·c#·visual studio
爱编码的小八嘎5 小时前
C语言完美演绎9-6
c语言
样例过了就是过了5 小时前
LeetCode热题100 最小路径和
c++·算法·leetcode·动态规划
SKY -dada5 小时前
Understand 使用教程
开发语言·c#·流程图·软件构建·敏捷流程·代码复审·源代码管理
SunnyByte6 小时前
线性表——单链表的增删查改操作
c语言·单链表
SunnyByte6 小时前
线性表——双向链表
c语言·链表
jimy16 小时前
C 语言的 static 关键字作用
c语言·开发语言·算法
风筝在晴天搁浅7 小时前
LeetCode 143.重排链表
算法·leetcode·链表
承渊政道7 小时前
【动态规划算法】(子数组系列问题建模与解题思路精讲)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法