C++ 力扣 76.最小覆盖子串 题解 优选算法 滑动窗口 每日一题

文章目录

这是封面原图,还有AI生成的动图,嘿嘿:

一、题目描述

题目链接:最小覆盖子串

题目描述:

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"

输出:"BANC"

解释:最小覆盖子串 "BANC" 包含了字符串 t 的所有字符 'A'、'B'、'C'。
示例 2:

输入:s = "a", t = "a"

输出:"a"

解释:整个字符串 s 就是最小覆盖子串。
示例 3:

输入: s = "a", t = "aa"

输出: ""

解释: t 中有两个 'a',而 s 中只有一个 'a',所以不存在覆盖子串。
提示:

m == s.length

n == t.length

1 <= m, n <= 10⁵

s 和 t 由英文字母组成

二、为什么这道题值得弄懂?

作为滑动窗口在字符串覆盖问题中的经典代表,LeetCode 第 76 题的价值体现在:

  • 帮你掌握"动态调整窗口大小"的滑动窗口用法------与"找到字符串中所有字母异位词"的固定窗口不同,本题窗口需根据匹配情况灵活伸缩,是滑动窗口的另一核心应用场景;
  • 带你深化"字符频率匹配"的逻辑------不仅要统计频率,还要判断"窗口是否包含目标所有字符(频率≥目标频率)",更贴近实际场景中"覆盖"的需求;
  • 让你理解"优化暴力枚举"的本质------通过窗口的"右扩左缩"避免重复遍历,将原本 O(n²) 的暴力思路优化到 O(n) 级别,体会算法效率提升的关键。

学会这道题,能为解决"子串覆盖""字符包含"类问题提供通用思路,比如后续遇到"找到包含所有字符的最短子串""检查子串是否覆盖目标字符集"等问题,都能直接复用核心逻辑。

三、字符频率统计:哈希表的核心作用

要判断"子串是否涵盖 t 所有字符",首先需要明确"t 中每个字符需要出现多少次"以及"当前子串中每个字符实际出现多少次"------这正是哈希表的用武之地:通过建立"字符-频率"的映射,实现高效的统计与对比。

1. 用数组模拟哈希表的实现

本题中,st 的字符均为英文字母(ASCII 码范围 0-127),因此可用大小为 128 的数组直接模拟哈希表(比标准哈希表更高效):

  • 数组下标:对应字符的 ASCII 码(如 'A' 对应 65,'a' 对应 97);
  • 数组值:对应字符的出现次数。

以代码中 hashi1(统计 t 的频率)为例:

cpp 复制代码
int hashi1[128] = {0};  // 初始化为0,代表所有字符初始频率为0
for(auto ch : t) {
    hashi1[ch]++;  // 遍历t,每遇到一个字符,对应下标位置的值+1
}

比如 t = "ABC",遍历后 hashi1['A']=1hashi1['B']=1hashi1['C']=1,其余位置仍为 0------清晰记录了 t 中每个字符的出现次数。

同理,hashi2 数组用于统计当前窗口内的字符频率,通过对比 hashi2hashi1,即可判断窗口是否满足"覆盖"条件。

四、思路演进:从哈希+暴力到滑动窗口+哈希

1. 哈希+暴力:看似可行却低效

有了哈希表统计频率,暴力解法的思路会更清晰:

  • hashi1 预处理 t 的频率(O(m) 时间,m 为 t 长度);
  • 枚举 s 中所有子串 s[i...j](i≤j),用 hashi2 统计子串频率(O(j-i+1) 时间);
  • 对比 hashi2hashi1,若 hashi2[ch]≥hashi1[ch] 对所有 ch 成立,则记录子串长度(O(128) 时间)。

但暴力解法的致命问题是重复遍历 :比如当 i=0 时已遍历 s[0...5]i=1 时又需重新遍历 s[1...5],大量字符被多次统计,导致时间复杂度高达 O(n³)(n 为 s 长度),完全无法应对 10⁵ 级别的输入。

2. 为什么滑动窗口能优化?避免重复遍历的核心逻辑

滑动窗口的本质是通过"右扩左缩"的动态调整,让每个字符仅被遍历一次。为什么能做到?我们通过反推"暴力解法的重复场景"来理解:

假设用 leftright 表示子串的左右边界,暴力解法中,当 right 固定时,left 从 0 递增;当 left 固定时,right 又从 left 递增------这会导致两种重复:

  • 场景1right 不动,left 右移后窗口仍满足条件。比如 s="ABBC"t="ABC",当 left=0right=3 时窗口满足条件,left=1right=3 时窗口仍满足(子串 "BBC" 不对,此处仅为举例逻辑)。暴力解法会重新统计 left=1 时的频率,而滑动窗口可直接基于 left=0 的频率更新,无需重复计算。
  • 场景2right 不动,left 右移后窗口不满足条件。此时暴力解法会让 rightleft 重新开始递增,而滑动窗口中 right 始终单向移动,只需继续右扩即可,避免"回到起点"的重复。

滑动窗口通过"right 右扩找可能的覆盖窗口,left 左缩求最短窗口"的单向移动逻辑,彻底避免了上述重复,让时间复杂度降至 O(n)。

五、滑动窗口的核心操作:进窗口、判断、出窗口

滑动窗口的实现需明确三个关键步骤:如何将字符加入窗口(进窗口)、如何判断窗口是否有效(判断)、如何将字符移出窗口(出窗口)。结合哈希表和 count 变量(优化判断效率),流程如下:

1. 进窗口:更新频率,标记满足的字符种类

right 右移时,字符 in = s[right] 进入窗口,需做两件事:

  • 更新频率hashi2[in]++(窗口内该字符的频率+1);
  • 判断是否满足种类 :若 hashi2[in] == hashi1[in],则 count++

这里的 count 是"满足'频率≥t 中频率'的字符种类数"。比如 t'A' 需出现 2 次,当窗口中 'A' 的频率从 1 增至 2 时,hashi2['A'] 恰好等于 hashi1['A'],说明 'A' 这个种类从"未满足"变为"满足",因此 count 加 1。

2. 判断:通过 count 快速确认窗口有效性

count == kindskindst 中不同字符的种类数)时,说明窗口内所有字符的频率均≥t 中对应字符的频率------窗口有效,可尝试左缩优化。

为什么不用直接对比两个数组?若每次判断都遍历 128 个字符,时间复杂度会增加 O(128n);而 count 只需 O(1) 即可判断,是关键优化点。

3. 出窗口:更新频率,修正满足的字符种类

当窗口有效时,left 右移,字符 out = s[left] 移出窗口,需做两件事:

  • 判断是否失去满足种类 :若 hashi2[out] == hashi1[out],则 count--(移出前该字符频率恰好满足,移出后会不足);
  • 更新频率hashi2[out]--(窗口内该字符的频率-1)。

注意顺序:必须先判断再更新频率。比如 hashi2[out] 原本等于 hashi1[out],若先减 1,会错误地认为"移出前不满足",导致 count 未正确减少。

4. 与 438 题的 count 对比:种类 vs 数量

我们之前一篇文章讨论过438.找到字符串中所有字母异位词这道题的优化办法和今天的类似但也有不同

本题的 count 与"找到字符串中所有字母异位词"(438 题)的 count 作用相似,但本质不同:

  • 438 题:count 统计"频率与 p 完全相等的字符数量"(需严格一一对应),最终 count 等于 p 的长度时窗口有效;
  • 本题:count 统计"频率≥t 中频率的字符种类数"(只需覆盖,无需相等),最终 count 等于 t 的字符种类数时窗口有效。

两者的差异源于问题目标:438 题找"字母异位词"(字符数量和种类完全相同),本题找"覆盖子串"(字符种类和频率≥目标)。

六、题目拆解:抓住关键约束

结合题目要求和示例,核心要素如下:

  1. 输入是两个字符串 s(主串)和 t(目标串),长度均可达 10⁵(需严格控制时间复杂度)。
  2. 核心约束是"子串需涵盖 t 所有字符",即:
    • 子串中每种字符的出现次数至少t 中对应字符的出现次数相同;
    • 子串需是 s 中连续的一段。
  3. 目标是找到最短的满足条件的子串,若不存在则返回空字符串。

关键点提炼

  • 窗口大小动态:子串长度不固定,需从"可能覆盖"的窗口中找到最短的那个;
  • 匹配核心是"覆盖" :窗口内字符频率≥t 中对应字符频率,无需严格相等;
  • 效率要求极高:暴力枚举所有子串必然超时,需用滑动窗口实现"一次遍历+动态调整";
  • 需记录最短子串:不仅要判断是否覆盖,还要实时更新"当前最短子串的长度和起始索引"。

七、算法实现:右扩左缩的动态调整

实现代码

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
        int hashi1[128] = {0};
        int kinds = 0;
        // 预处理t:统计频率并记录字符种类数
        for(auto ch : t)
        {
            if(hashi1[ch] == 0)
            {
                kinds++;
            }
            hashi1[ch]++;
        }

        int hashi2[128] = {0};
        int count = 0;  // 记录窗口中满足"频率≥t中频率"的字符种类数
        int minlen = INT_MAX, start = -1;  // 最短子串的长度和起始索引
        // 滑动窗口:left左边界,right右边界
        for(int left = 0, right = 0; right < s.size(); right++)
        {
            // 右扩窗口:将s[right]加入窗口
            char in = s[right];
            hashi2[in]++;
            // 若加入后该字符频率恰好等于t中频率,说明该种类从"未满足"变为"满足",count+1
            if(hashi2[in] == hashi1[in])
                count++;

            // 当窗口已覆盖t所有字符时,尝试左缩窗口找最短子串
            while(count == kinds)
            {
                // 更新最短子串:若当前窗口更短,则记录长度和起始索引
                if(right - left + 1 < minlen)
                {
                    minlen = right - left + 1;
                    start = left;
                }
                // 左缩窗口:将s[left]移出窗口
                char out = s[left];
                // 若移出前该字符频率等于t中频率,说明该种类从"满足"变为"未满足",count-1
                if(hashi2[out] == hashi1[out])
                    count--; 
                hashi2[out]--;
                left++;
            }
        }
        // 根据start判断是否找到有效子串
        if(start == -1)
            return "";
        else return s.substr(start, minlen);
    }
};

代码细节拆解:

  1. 预处理 t 的频率与种类
    hashi1 数组统计 t 中每个字符的出现次数,kinds 记录 t 中不同字符的种类数(例如 t="ABC"kinds=3)。这一步是后续判断"是否覆盖"的基准。

  2. 右扩窗口与频率更新
    right 指针右移,每次将 s[right] 加入窗口(hashi2[in]++)。当 hashi2[in] 恰好等于 hashi1[in] 时,count 加 1------这意味着该字符的频率从"不足"变为"满足",有效种类数增加。

  3. 左缩窗口与最短子串更新

    count == kinds 时,窗口已覆盖 t 所有字符,进入 while 循环左缩:

    • 先判断当前窗口是否更短,若是则更新 minlen(最短长度)和 start(起始索引);
    • 再将 s[left] 移出窗口:若移出前 hashi2[out] 等于 hashi1[out],则 count 减 1(该字符频率从"满足"变为"不足"),随后更新 hashi2[out]-- 并左移 left
  4. 结果处理

    start 仍为 -1,说明未找到有效子串,返回空字符串;否则返回 s 中从 start 开始、长度为 minlen 的子串。

时间复杂度与空间复杂度

时间复杂度

O(m + n),其中 m 是 t 的长度,n 是 s 的长度:

  • 预处理 t 耗时 O(m);
  • 滑动窗口中 rightleft 均单向移动,每个字符仅被访问 2 次(进入和离开窗口),耗时 O(n);
  • 其余操作均为常数级,总耗时可忽略。

空间复杂度

O(1)(常数级):

  • 用于统计频率的 hashi1hashi2 数组大小固定为 128,与输入长度无关;
  • 其他变量(kindscount 等)均为单个整数,占用空间恒定。

八、实现过程中的坑点总结

  1. count 的更新条件错误

    • 错误 :当 hashi2[in] ≥ hashi1[in] 时就给 count 加 1(例如 t'A' 出现 2 次,窗口中 'A' 从 2 次增至 3 次时,再次 count++)。
      这会导致 count 超过 kinds(例如 kinds=3count=4),后续无法正确判断"是否覆盖"。
    • 解决 :仅当 hashi2[in] == hashi1[in]count++(即"恰好满足频率"时才标记该种类已满足,后续频率增加不重复计数)。
  2. 左缩窗口时的顺序错误

    • 错误 :先执行 hashi2[out]--,再判断是否需要减少 count
      例如 t'A' 出现 2 次,窗口中 'A' 原本是 2 次:若先减为 1,再判断"1 == 2",会错误地认为不需要减 count,但实际移出前是满足条件的,应该减 count
    • 解决 :先判断"移出前的频率是否等于 hashi1[out]",再更新 hashi2[out]--。即先执行 if (hashi2[out] == hashi1[out]) count--;,再执行 hashi2[out]--;
  3. 未初始化 minlenstart 导致结果错误

    • 错误 :未将 minlen 初始化为较大值(如 INT_MAX),或未将 start 初始化为 -1(例如初始化为 0)。
      s 中无覆盖子串,start 仍为 0,会错误返回 s[0];若初始 minlen 太小,可能无法更新到更短的子串。
    • 解决 :严格初始化 minlen = INT_MAXstart = -1,最后通过 start == -1 判断是否找到有效子串。
  4. 忽略 t 中字符在 s 中不存在的情况

    • 错误 :未考虑"t 中有 s 没有的字符"(例如 s="ABC"t="ABD"),此时 count 永远无法等于 kinds,但未处理 start 仍为 -1 的情况。
    • 解决 :最终通过 if(start == -1) return "" 统一处理"无有效子串"的情况,无需单独判断字符是否存在。

九、下题预告

明天将讲解 力扣704.二分查找,这是算法入门阶段最基础也最核心的查找算法之一,看似简单却暗藏不少细节。

提前思考方向:

  • 二分查找的核心逻辑是什么?为什么它的时间复杂度能做到 O(log n)?
  • 实现时如何确定"左闭右闭""左闭右开"等不同的区间定义?不同区间定义下,leftright 的初始化、循环条件以及中间值 mid 的更新方式有何差异?
  • 当数组中不存在目标值时,如何避免死循环并正确返回结果?

如果这道题的解析对你有帮助,不妨持续关注,明天一起攻克二分查找的核心要点!

如果觉得这篇解析有帮助,不妨:

🌟 点个赞,让更多人看到这份清晰思路

⭐ 收个藏,下次复习时能快速找回灵感

👀 加个关注,明天见!

相关推荐
PAK向日葵2 小时前
【算法导论】XHS 0824 笔试题解
算法·面试
2501_924534893 小时前
智慧零售商品识别误报率↓74%!陌讯多模态融合算法在自助结算场景的落地优化
大数据·人工智能·算法·计算机视觉·目标跟踪·视觉检测·零售
盖雅工场3 小时前
连锁零售排班难?自动排班系统来解决
大数据·人工智能·物联网·算法·零售
Greedy Alg3 小时前
LeetCode 438. 找到字符串中所有的字母异位词
算法·leetcode·职场和发展
lifallen8 小时前
Hadoop MapReduce 任务/输入数据 分片 InputSplit 解析
大数据·数据结构·hadoop·分布式·算法
熙xi.9 小时前
数据结构 -- 哈希表和内核链表
数据结构·算法·散列表
Ghost-Face9 小时前
并查集提高——种类并查集(反集)
算法
董董灿是个攻城狮10 小时前
5分钟搞懂大模型微调的原始能力退化问题
算法