【力扣100题】63.最小覆盖子串

题目描述

给你两个字符串 st,长度分别是 mn,返回 s 中的最短窗口子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""

测试用例保证答案唯一。

示例

复制代码
示例 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 的子串中,因此没有符合条件的子字符串,返回空字符串。

提示:

  • m == s.length
  • n == t.length
  • 1 <= m, n <= 10^5
  • s 和 t 由英文字母组成

进阶:

你能设计一个在 O(m + n) 时间内解决此问题的算法吗?


解题思路总览

方法 核心思想 时间复杂度 空间复杂度 备注
滑动窗口(双指针) 扩张-收缩双指针,维护窗口 O(m + n) O(1) 推荐解法
暴力枚举 枚举所有子串再检查 O(m^2 * n) O(1) 会超时
优化暴力 先记录t中字符位置再枚举 O(m * n) O(n) 不够优

一、核心解法:滑动窗口(双指针)

核心思想

使用滑动窗口的思想,维护一个包含 t 中所有字符的窗口:

复制代码
1. 右指针扩张,直到窗口包含 t 中所有字符
2. 收缩左指针,找到最短的满足条件的窗口
3. 记录答案,继续扩张右指针

关键洞察

复制代码
滑动窗口的关键:
1. 右指针不断右移,扩大窗口
2. 当窗口满足条件时,收缩左指针尝试优化
3. 使用两个哈希表:
   - need: t 中每个字符需要的次数
   - window: 当前窗口中每个字符出现的次数

当 window 满足 need 时,说明窗口有效。

图解

复制代码
s = "ADOBECODEBANC", t = "ABC"

need = {A:1, B:1, C:1}

滑动窗口过程:

初始状态:
  left=0, right=0
  window = {}
  needCnt = 3

扩张 right=0, s[0]='D':
  D 不在 need 中,跳过

扩张 right=1, s[1]='D':
  D 不在 need 中,跳过

扩张 right=2, s[2]='O':
  O 不在 need 中,跳过

扩张 right=3, s[3]='B':
  B 在 need 中
  window[B]=1, need[B]=1, count=1
  count=3? 否,继续扩张

扩张 right=4, s[4]='E':
  E 不在 need 中,跳过

扩张 right=5, s[5]='C':
  C 在 need 中
  window[C]=1, need[C]=1, count=2

扩张 right=6, s[6]='O':
  O 不在 need 中,跳过

扩张 right=7, s[7]='D':
  O 不在 need 中,跳过

扩张 right=8, s[8]='E':
  E 不在 need 中,跳过

扩张 right=9, s[9]='B':
  B 在 need 中
  window[B]=2, need[B]=1, count=2 (B已经满足,不增加count)

扩张 right=10, s[10]='A':
  A 在 need 中
  window[A]=1, need[A]=1, count=3
  count=3 == need.size(),窗口有效!

收缩 left:
  left=0: 窗口 [0,10] = "ADOBECODEBA"
  不满足,收缩 left=1

  left=1: 窗口 [1,10] = "DOBECODEBA"
  不满足,收缩 left=2

  ...

  left=9: 窗口 [9,10] = "BA"
  满足!记录答案 "BA",长度=2

  left=10: 收缩,窗口 [10,10] = "A"
  不满足,继续扩张

继续扩张 right=11, s[11]='N':
  N 不在 need 中,跳过

扩张 right=12, s[12]='C':
  C 在 need 中
  window[C]=2, need[C]=1, count=3 (C已满足)
  窗口有效!

收缩 left:
  left=10: 窗口 [10,12] = "ANC"
  不满足,收缩 left=11

  left=11: 窗口 [11,12] = "NC"
  不满足,收缩 left=12

  left=12: 窗口 [12,12] = "C"
  不满足,收缩 left=13

right 到达末尾,停止

答案: "BANC"(长度为4,比 "BA" 更长但更靠后)

二、算法流程图

复制代码
输入: s = "ADOBECODEBANC", t = "ABC"

初始化:
  need = {A:1, B:1, C:1}
  window = {}
  left = 0, right = 0
  count = 0 (已满足的字符种类数)
  minLen = INF, start = 0

扩张 right=3 (遇到B):
  window[B]++, count=1

扩张 right=5 (遇到C):
  window[C]++, count=2

扩张 right=10 (遇到A):
  window[A]++, count=3
  窗口有效!count == need.size()

收缩 left:
  left=0 -> "ADOBECODEBA" 无效
  left=1 -> "DOBECODEBA" 无效
  ...
  left=9 -> "BA" 有效!记录 minLen=2, start=9
  left=10 -> "A" 无效

继续扩张 right=12 (遇到C):
  window[C]++, count=3
  窗口有效!

收缩 left:
  left=11 -> "ANC" 无效
  left=12 -> "C" 无效

right 到达末尾,结束

输出: "BANC" (start=9, minLen=4)

三、完整代码实现

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> need, window;

        // 记录 t 中每个字符需要的次数
        for (char c : t) {
            need[c]++;
        }

        int left = 0, right = 0;
        int count = 0;  // 已满足的字符种类数
        int start = 0, minLen = INT_MAX;

        while (right < s.size()) {
            char c = s[right++];

            // 如果字符在 need 中,加入 window
            if (need.count(c)) {
                window[c]++;

                // 当 window[c] 达到 need[c] 时,count++
                if (window[c] == need[c]) {
                    count++;
                }
            }

            // 当窗口满足条件时,收缩左边界
            while (count == need.size()) {
                // 更新最小窗口
                if (right - left < minLen) {
                    start = left;
                    minLen = right - left;
                }

                // 收缩左边界
                char d = s[left++];

                // 如果字符在 need 中,从 window 中移除
                if (need.count(d)) {
                    // 注意:先判断再--
                    if (window[d] == need[d]) {
                        count--;  // 移除后不再满足
                    }
                    window[d]--;
                }
            }
        }

        return minLen == INT_MAX ? "" : s.substr(start, minLen);
    }
};

四、逐行解析

cpp 复制代码
unordered_map<char, int> need, window;
  • need:记录 t 中每个字符需要的次数
  • window:记录当前窗口中每个字符出现的次数
cpp 复制代码
for (char c : t) {
    need[c]++;
}
  • 初始化 need,统计 t 中每个字符出现的次数
cpp 复制代码
int left = 0, right = 0;
int count = 0;
int start = 0, minLen = INT_MAX;
  • left, right:滑动窗口的左右边界
  • count:当前窗口中已满足 need 条件的字符种类数
  • start, minLen:最小窗口的起始位置和长度
cpp 复制代码
while (right < s.size()) {
    char c = s[right++];
  • 右指针不断右移,扩张窗口
cpp 复制代码
if (need.count(c)) {
    window[c]++;
    if (window[c] == need[c]) {
        count++;
    }
}
  • 如果当前字符在 need 中,加入 window
  • 当 window[c] 达到 need[c] 时,count++(这个字符已经满足条件)
cpp 复制代码
while (count == need.size()) {
  • 当窗口满足条件时(count == need.size()),收缩左边界尝试优化
cpp 复制代码
if (right - left < minLen) {
    start = left;
    minLen = right - left;
}
  • 更新最小窗口
cpp 复制代码
char d = s[left++];
if (need.count(d)) {
    if (window[d] == need[d]) {
        count--;
    }
    window[d]--;
}
  • 收缩左边界
  • 如果移除的字符在 need 中,先判断是否会让 count 减少
  • 然后从 window 中移除该字符

五、count 变量的作用

复制代码
count 表示当前窗口中满足 need 条件的字符种类数。

例如 t = "ABC",need = {A:1, B:1, C:1}

初始: count = 0
遇到 A: window[A]=1 == need[A], count=1
遇到 B: window[B]=1 == need[B], count=2
遇到 C: window[C]=1 == need[C], count=3

当 count == need.size() 时,说明所有字符都满足了条件。

收缩时:
  移除 A: window[A]=0 != need[A], count 不变
  移除 B: window[B]=0 != need[B], count 不变
  移除 C: window[C]=0 != need[C], count-- (变为2)

count-- 只发生在 window[d] == need[d] 时,即移除后刚好不再满足。

六、与第 567 题(字符串的排列)对比

维度 第 76 题 最小覆盖子串 第 567 题 字符串的排列
问题类型 找最短覆盖子串 检查是否存在排列
窗口大小 可变 固定为 t 的长度
答案 最短子串 布尔值
方法 滑动窗口,记录最小 滑动窗口,固定大小

七、复杂度分析

方法 时间复杂度 空间复杂度 备注
滑动窗口 O(m + n) O(1) 推荐,最多 128 个字符
暴力枚举 O(m^2 * n) O(1) 会超时
优化暴力 O(m * n) O(n) 不够优

详细分析:

复制代码
时间复杂度:
  right 指针最多移动 m 次
  left 指针最多移动 m 次
  每次移动都是 O(1) 操作
  总计:O(2m) = O(m)

空间复杂度:
  need 和 window 最多存储 128 个字符(ASCII)
  O(1)(常数空间)

八、边界情况分析

情况 处理方式
t 比 s 长 直接返回 ""
t = "a", s = "a" 返回 "a"
t 中有重复字符 need[c]++ 累加,count 只在达到 need 时才增加
s 中无 t 的字符 right 遍历完,count 始终 < need.size(),返回 ""

示例:t 有重复字符

复制代码
s = "AAAB", t = "AAB"

need = {A:2, B:1}

扩张过程:
  遇到 A: window[A]=1, need[A]=2, count=0
  遇到 A: window[A]=2, need[A]=2, count=1
  遇到 A: window[A]=3, count=1 (已达标不再增加)
  遇到 B: window[B]=1, need[B]=1, count=2
  count=2 == need.size()=2,窗口有效

收缩:
  移除第一个 A: window[A]=2, count=2 (仍满足)
  移除第二个 A: window[A]=1, need[A]=2, count-- (变为1)
  窗口无效,继续扩张

九、面试追问 FAQ

问题 回答要点
Q: 为什么 count 只在 window[c] == need[c] 时增加? 因为只有当 window[c] 达到 need[c] 时,这个字符才算满足条件
Q: 为什么先判断 window[d] == need[d] 再 --? 如果移除后刚好不满足条件,count 才需要减少
Q: 为什么不直接把 window[d] == need[d] 的情况 --? 因为只有刚好不满足时才减,如果 window[d] > need[d] 就不需要减
Q: 时间复杂度为什么是 O(m + n)? right 和 left 最多各移动 m 次,每次 O(1)
Q: 空间复杂度为什么是 O(1)? need 和 window 只存储字符,ASCII 只有 128 个
Q: 如何处理 Unicode 字符? 用 unordered_map 代替固定数组,复杂度不变

十、相关题目

题目编号 题目名称 难度 核心差异
76 最小覆盖子串 困难 基础题,滑动窗口
567 字符串的排列 中等 检查是否存在覆盖
438 找到字符串中所有字母异位词 中等 找所有异位词
3 无重复字符的最长子串 中等 最长无重复子串
30 串联所有单词的子串 困难 滑动窗口 + 单词

十一、总结

要点 内容
核心思想 滑动窗口,左右指针扩张收缩
关键变量 count 表示满足条件的字符种类数
判断条件 count == need.size() 时窗口有效
更新逻辑 每次扩张后检查是否有效,有效则收缩尝试优化
时间复杂度 O(m + n)(每个指针最多移动 m 次)
空间复杂度 O(1)(字符集大小固定为 128)
易错点 count-- 的时机:只在 window[d] == need[d] 时

最小覆盖子串是滑动窗口的经典问题,通过维护一个满足 need 条件的窗口,并不断收缩优化,实现了 O(m + n) 的时间复杂度。


相关推荐
AllData公司负责人1 小时前
亲测丝滑,体验跃迁|AllData通过集成开源项目RustFS,多模态数据存储新范式
java·大数据·数据库·算法·数据分析·rustfs
磊 子1 小时前
AVL树的讲解
数据结构·算法
Trouvaille ~1 小时前
【Redis篇】Hash 哈希:字段级操作与对象存储的最佳实践
数据库·redis·后端·算法·缓存·哈希算法·键值对
悠仁さん1 小时前
数据结构 树 二叉树 堆 (链式二叉树模拟实现篇)
数据结构·算法
z200509302 小时前
今日算法(带回文问题的回溯)
算法·leetcode·回溯
洛水水2 小时前
【力扣100题】55.编辑距离
算法·leetcode·动态规划
洛水水2 小时前
【力扣100题】62.滑动窗口最大值
数据结构·算法·leetcode
IronMurphy2 小时前
算法五十一 64. 最小路径和
算法
醒醒该学习了!2 小时前
Prompt提示词——带有深度思考模型的提示方法(理论篇)
人工智能·算法·prompt