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:

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

每一步:

  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_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);

主要步骤:

  1. 边界处理:
    • s == NULL 或 words == NULL 或 wordsSize == 0,返回 NULL。
    • word_len == 0 或 total_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 = offset,count = 0。
    • 内层循环 right 从 offset 开始,每次增 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_dec,left += word_len,count--。
  5. 遍历完所有 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;
}

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

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

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

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


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

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

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

相关推荐
晓蛋3 天前
c语言指的是什么意思
c语言·编译器·编程开发·集成开发环境·程序实例
小羊没烦恼!3 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
傲世仙尊3 天前
目录即文件-Ext文件系统收尾篇
linux·c语言
神仙别闹3 天前
基于C#+MySQL实现(WinForm)个人聊天室软件
c#
牵猫散步的鱼儿3 天前
重载、重写(覆盖)、重定义区别
c语言
phltxy3 天前
C 语言指针:从内存地址到灵活的数据访问
c语言
phltxy3 天前
C 语言中的数据存储:从类型到二进制位
c语言
旖旎夜光3 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
kybs19913 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
wuminyu3 天前
HotSpot的轻量锁与膨胀后的ObjectMonitor状态重建原理
java·linux·c语言·jvm·c++