【LeetCode Hot100 刷题日记 (12/100)】76. 最小覆盖子串 —— 滑动窗口 、字符串、哈希表🪟

50 阅读4分钟

📌 题目链接:leetcode.cn/problems/mi…
🔍 难度:困难 | 🏷️ 标签:字符串、滑动窗口、哈希表
⏱️ 目标时间复杂度:O(|s| + |t|)
💾 空间复杂度:O(|t|)


题目分析

给定字符串 st,返回 s涵盖 t 所有字符(含重复)的最小子串。若不存在,返回空串 ""

关键点:

  • 子串必须连续;
  • 字符数量需 ≥ t 中对应字符数量;
  • 字符区分大小写;
  • 答案唯一(若有解)。

典型应用场景:文本关键词提取、日志分析、生物序列匹配等。


核心算法及代码讲解

本题采用 滑动窗口(Sliding Window) + 哈希表 解法。

🧠 算法思想

  • 用两个指针 l(左)、r(右)维护窗口 [l, r]
  • 扩张:移动 r,将字符加入窗口,直到窗口“可行”(包含 t 所需全部字符)。
  • 收缩:移动 l,尝试缩小窗口,同时保持可行性,以寻找更短解。
  • 记录最优:每次窗口可行时,更新最小长度与起始位置。

🗂️ 数据结构

  • ori:记录 t 中各字符的目标频次(只读)。
  • cnt:动态记录当前窗口中各字符的实际频次。
  • check():判断 cnt 是否满足 ori 的所有要求。

✅ 优化:check() 仅遍历 ori(而非整个字符集),效率更高。

⚙️ 代码逐行注释

class Solution {
public:
    unordered_map<char, int> ori, cnt;

    // 检查当前窗口是否覆盖 t
    bool check() {
        for (const auto &p : ori) {
            if (cnt[p.first] < p.second) {
                return false;
            }
        }
        return true;
    }

    string minWindow(string s, string t) {
        for (const auto &c : t) {
            ++ori[c]; // 初始化目标频次
        }

        int l = 0, r = -1;
        int len = INT_MAX, ansL = -1;

        while (r < (int)s.size()) {
            // 扩张右边界:仅关心 t 中出现的字符
            if (ori.find(s[++r]) != ori.end()) {
                ++cnt[s[r]];
            }

            // 收缩左边界:当窗口可行时
            while (check() && l <= r) {
                if (r - l + 1 < len) {
                    len = r - l + 1;
                    ansL = l; // 记录起始位置
                }
                if (ori.find(s[l]) != ori.end()) {
                    --cnt[s[l]]; // 移出窗口
                }
                ++l;
            }
        }

        return ansL == -1 ? string() : s.substr(ansL, len);
    }
};

解题思路

  1. 初始化:统计 t 的字符频次到 ori
  2. 滑动窗口
    • r 不断右移,将有效字符加入 cnt
    • 一旦 check() 成立,进入收缩阶段;
    • l 右移,尝试缩小窗口,同时更新最优解;
  3. 返回结果:若找到解,截取子串;否则返回空串。

💡 关键细节:

  • r 初始为 -1,先 ++r 再访问,避免越界;
  • 仅当字符在 ori 中才更新 cnt,忽略无关字符;
  • ansL == -1 表示无解。

算法分析

  • 时间复杂度:O(C·|s| + |t|),其中 C 为字符集大小(≤52),通常视为 O(|s| + |t|)。
  • 空间复杂度:O(|t|),用于存储 oricnt

面试高频考点

  • 滑动窗口模板:扩 → 查 → 缩 → 更新;
  • 哈希表设计:只关注目标字符;
  • 边界处理与空解判断;
  • 可行性验证优化(可引入 valid 计数器实现 O(1) 检查)。

代码

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    unordered_map<char, int> ori, cnt;

    bool check() {
        for (const auto &p: ori) {
            if (cnt[p.first] < p.second) {
                return false;
            }
        }
        return true;
    }

    string minWindow(string s, string t) {
        for (const auto &c: t) {
            ++ori[c];
        }

        int l = 0, r = -1;
        int len = INT_MAX, ansL = -1, ansR = -1;

        while (r < int(s.size())) {
            if (ori.find(s[++r]) != ori.end()) {
                ++cnt[s[r]];
            }
            while (check() && l <= r) {
                if (r - l + 1 < len) {
                    len = r - l + 1;
                    ansL = l;
                }
                if (ori.find(s[l]) != ori.end()) {
                    --cnt[s[l]];
                }
                ++l;
            }
        }

        return ansL == -1 ? string() : s.substr(ansL, len);
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 测试用例 1
    string s1 = "ADOBECODEBANC", t1 = "ABC";
    cout << "Test 1: " << sol.minWindow(s1, t1) << "\n"; // "BANC"
    
    // 测试用例 2
    string s2 = "a", t2 = "a";
    cout << "Test 2: " << sol.minWindow(s2, t2) << "\n"; // "a"
    
    // 测试用例 3
    string s3 = "a", t3 = "aa";
    cout << "Test 3: \"" << sol.minWindow(s3, t3) << "\"\n"; // ""
    
    return 0;
}

🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第53题 —— 最大子数组和(简单)

🔹 题目:给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
🔹 核心思路Kadane 算法——动态规划思想,维护以当前位置结尾的最大子数组和。
🔹 考点:动态规划、贪心、前缀和优化。
🔹 难度:简单,但极其经典,是理解“局部最优推导全局最优”的典范!

💡 提示:不要用暴力 O(n²),Kadane 算法只需一次遍历 O(n) 时间、O(1) 空间!