XingHuiSamaの宝藏之地
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

Leetcode一百题——最小覆盖子串

2026-04-13 10:44:55
# Leetcode
# C++
# 题解
# 学术

给定两个字符串 s 和 t,长度分别是 m 和 n,返回 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 的子串中,
因此没有符合条件的子字符串,返回空字符串。

又是一道困难题,这题做了一个半小时才做出来,一开始只考虑对比,没想到频率。后面终于茅塞顿开,才勉强写出来

1. 算法核心思想:状态机流转与延迟截取

通过双指针(front 与 rear)维护一个大小可变的滑动窗口,利用“有效需求计数器”(valid)将字符串匹配问题抽象为状态机模型。

  • 状态控制:引入 valid 变量表示“尚未凑齐的字符总数”。通过 valid > 0(饥饿)与 valid == 0(吃饱)两种状态的交替,精确控制左右指针的移动时机,避免了高昂的数组全量对比开销。
  • 双映射记账:利用 unordered_map 快速验证字符是否属于目标集,利用定长数组 note 和 note2 结合大小写 ASCII 偏移量,精确记录目标字符频率与当前窗口字符频率。
  • 延迟截取(内存优化):在寻找全局最小子串的过程中,放弃直接使用 substr 开辟新内存,转而使用 minmark 数组记录历史最优解的起始与终止坐标,最后统一截取,从根源上杜绝了内存溢出。

2. 宏观运行机制

程序的生命周期在两个嵌套的内部 while 循环中交替进行:

  • 阶段一:右指针扩张(吸收状态) 当 valid > 0 时,系统处于未满足状态。右指针 rear 步进,读取字符。若该字符在目标集合中,系统计算其对应的数组映射索引 mark,并将其加入当前窗口记录 note2 中。若加入后该字符的总量未超过目标需求量 note,则记为一次有效收集,valid 减 1。满足 valid == 0 的瞬间,记录当前窗口坐标。
  • 阶段二:左指针收缩(排异状态) 当 valid == 0 达成,右指针停止,左指针 front 启动。系统尝试通过前移 front 来剔除多余字符以缩短窗口长度。若被剔除的字符属于目标集合,且剔除后其频率低于了目标下限,则破坏了覆盖条件,valid 递增。在每次合法剔除后,动态更新全局最优的 minmark 坐标组合。随后循环退回阶段一,直至遍历完整个字符串。
class 最小覆盖子串 {
public:
    string minWindow(string s, string t) {
        vector<int> note = vector<int>(26*2, 0);
        vector<int> note2 = vector<int>(26*2, 0);
        unordered_map<char, int> map;
        for (auto c : t) {
            map[c]++;
            if ('a' <= c && c <= 'z') {
                note[c - 'a']++;
            }
            else {
                note[c - 'A' + 26]++;
            }
        }

        int len = 0;
        int minLen = INT_MAX;
        string minRes = "";

        int front = 0;
        int rear = 0;
        int valid = t.size();
        int minmark[] = {0,0};

        while (rear < s.size()) {
            //首先先吃
            while (rear < s.size() && valid > 0) {//循环到吃饱为止
                int char1 = s[rear];
                if (map.find(char1) != map.end()) {//如果在map里面,需要判断清单是否满足,会增饱
                    int mark;
                    if ('a' <= char1 && char1 <= 'z') {
                        note2[char1 - 'a']++;
                        mark = char1 - 'a';
                    }
                    else {
                        note2[char1 - 'A' + 26]++;
                        mark = char1 - 'A' + 26;
                    }

                    //刚刚吃完和note对比看看有没有完成清单
                    if (note2[mark] <= note[mark]) {
                        valid--;
                    }
                }
                //不在map里面,直接吃,但是不增饱
                len++;
                rear++;

                //清单为0的时候开始截取
                if (valid == 0) {
                    if (minLen > len) {
                        minLen = len;
                        minmark[0] = front;
                        minmark[1] = rear;

                    }
                }
            }

            //front开始吐
            while (front < rear && valid == 0) {
                if (map.find(s[front]) != map.end()) {//当前的在清单里面
                    int char1 = s[front];//对应的note2变化,现在note2少了一个必然不相等,那就得移动右指针
                    int mark;
                    if ('a' <= char1 && char1 <= 'z') {
                        note2[char1 - 'a']--;
                        mark = char1 - 'a';
                    }
                    else {
                        note2[char1 - 'A' + 26]--;
                        mark = char1 - 'A' + 26;
                    }

                    if (note2[mark] < note[mark]) {
                        valid++;//吐完了清单也空了
                    }
                }
                front++;//不管吐了什么,反正一定会前移
                len--;

                if (valid == 0) {
                    if (minLen > len) {
                        minLen = len;
                        minmark[0] = front;
                        minmark[1] = rear;
                    }
                }
            }

        }

        return minRes = s.substr(minmark[0], minmark[1] - minmark[0]);
    }
};

3. 复杂度剖析

  • 时间复杂度:O(N+M)。其中 N 为字符串 s 的长度,M 为字符串 t 的长度。虽然存在内外嵌套的 while 循环,但 front 和 rear 指针均不回退,最多分别遍历字符串一次。数组索引偏移计算与状态判定均为 O(1) 时间开销。
  • 空间复杂度:O(Σ)。系统额外开辟了两个长度为 52 的定长数组和一个 unordered_map 用于记录目标集字符频次。其空间占用与目标字符串的字符种类数相关联,上限为常数级别,内存开销低。

avatar

XingHuiSama

在代码、学术与分子动力学模拟间穿梭的普通人。近期正埋头于 GROMACS 模拟研究与神经网络计算。

RECOMMENDED

GROMACS 2025 分子动力学模拟初探2222

2026-03-24 07:00:45

Computational Chemistry Tool 工具二介绍

2026-04-01 07:00:23

Leetcode一百题——字母异位词分组

2026-04-07 15:34:18

Table of Contents