题目描述

✅ 76. 最小覆盖子串

image-20260928190903875

image-20260928190903876

题意分析

在 s 中找一个最短的连续子串,使 t 中每个字符在子串里的出现次数都不少于它在 t 中的次数。覆盖只关心字符和数量,不要求出现顺序与 t 一致,也允许包含额外字符。

返回的是子串本身;不存在覆盖时返回空串,存在时题目保证最短答案唯一。两个字符串都非空,只包含大小写英文字母,大小写需要区分;重复字符必须逐个满足,不能只检查字符种类。

解法:滑动窗口维护字符欠账

核心思路

[!blue]

固定左端后,向右扩张只会增加字符,不会让已经满足的需求失效;固定右端后,缩短左侧才可能得到更短答案。这种单向变化适合滑动窗口:缺字符时扩张,覆盖后收缩。

对当前窗口 [left, right],定义 need[c] = t 中 c 的数量 - 窗口中 c 的数量。正数表示还缺,零表示刚好够,负数表示多余。再用 missing 记录所有正欠账之和,也就是还缺多少个字符;初始窗口为空,所以 missing = t.length()。

字符 in 入窗时,总要执行 need[in]--。只有入窗前 need[in] > 0,它才补上一个缺口,missing 才减一;本来就够用的字符继续增加,不会抵消其他字符的欠账。因此 missing == 0 恰好表示窗口覆盖了全部需求。

窗口覆盖后,先记录答案,再尝试移出左端字符。移出 out 时先执行 need[out]++;若增加后为正,说明刚移走的是必需字符,missing 加一,窗口重新缺失,必须停止收缩。否则移走的只是多余字符,可以继续缩短。

左指针不需要回退:每个被移走的左端点,都已经和当时的右端点组成过合法窗口并参与答案比较;以后右端点更靠右,以同一个左端点组成的窗口只会更长。持续扩张和收缩即可找到最短答案,同时两个指针都只遍历 s 一次。

解题步骤

  1. 统计 t 的字符频次作为初始 need,令 missing = t.length()、left = 0。用 bestStart 保存答案起点,bestLen = s.length() + 1 表示尚未找到答案。
  2. 右指针逐个加入字符:先依据旧的 need[in] 判断是否减少 missing,再将 need[in] 减一。
  3. 当 missing == 0 时,用当前长度 right - left + 1 更新最短答案,然后移出左端字符、右移 left,恢复欠账并判断是否重新缺失。
  4. 重复第 3 步直到窗口不再覆盖,再继续扩张右端。扫描结束后,若 bestLen 仍大于 s.length() 则返回空串,否则截取保存的区间。

代码实现

class Solution {
    public String minWindow(String s, String t) {
        int[] need = new int[128];

        for (int i = 0; i < t.length(); i++) {
            need[t.charAt(i)]++;
        }

        int missing = t.length();
        int left = 0;
        int bestStart = 0;
        int bestLen = s.length() + 1;

        for (int right = 0; right < s.length(); right++) {
            char in = s.charAt(right);

            // 加入前确实欠缺这个字符,缺少总数才减少。
            if (need[in] > 0) {
                missing--;
            }

            need[in]--;

            while (missing == 0) {
                int len = right - left + 1;

                // 移出左端之前先记录合法窗口;缺少数量按字符出现次数计算。
                if (len < bestLen) {
                    bestStart = left;
                    bestLen = len;
                }

                char out = s.charAt(left++);

                // 先恢复欠账,再判断移出后是否重新缺少一个字符。
                need[out]++;

                if (need[out] > 0) {
                    missing++;
                }
            }
        }

        return bestLen > s.length() ? "" : s.substring(bestStart, bestStart + bestLen);
    }
}
func minWindow(s string, t string) string {
    need := make([]int, 128)
    for i := 0; i < len(t); i++ {
        need[t[i]]++
    }

    missing := len(t)
    left, bestStart, bestLen := 0, 0, len(s)+1

    for right := 0; right < len(s); right++ {
        in := s[right]
        // 加入前确实欠缺这个字符,缺少总数才减少。
        if need[in] > 0 {
            missing--
        }
        need[in]--

        for missing == 0 {
            length := right - left + 1
            // 移出左端之前先记录合法窗口;缺少数量按字符出现次数计算。
            if length < bestLen {
                bestStart, bestLen = left, length
            }

            out := s[left]
            left++
            // 先恢复欠账,再判断移出后是否重新缺少一个字符。
            need[out]++
            if need[out] > 0 {
                missing++
            }
        }
    }

    if bestLen > len(s) {
        return ""
    }
    return s[bestStart : bestStart+bestLen]
}

复杂度分析

  • 时间复杂度:$O(\lvert s\rvert + \lvert t\rvert)$。统计 t 扫描一次,窗口中每个字符最多进入一次、离开一次;内层收缩的总次数不超过 s 的长度,因此不是平方复杂度。
  • 空间复杂度:$O(1)$,不计返回结果。题目字符仅为大小写英文字母,长度为 128 的数组足以按字符编码直接计数,数组大小不随输入增长。

关键点总结

[!green]

  • missing 统计缺少的字符总数,重复字符也要分别计数。
  • 入窗时先判断旧的 need,出窗时先增加 need 再判断。
  • need 的负值表示多余字符,不影响窗口合法性。
  • 答案只在窗口合法、移出左端之前更新;用 while 持续收缩才能去掉全部可删除前缀。

易错点总结

[!yellow]

  • 只统计字符种类而忽略出现次数,会错误处理 t 中的重复字符。
  • 入窗时先减少 need 再判断,会漏掉刚好补齐的字符。
  • 收缩只执行一次而不是持续执行,会留下可删除的前缀。
  • 若把 bestLen 初值设为 s.length(),当前返回判断将无法区分从未找到覆盖与整串恰好为答案,可能在无解时错误返回原串。
  • 题目包含大小写字母,不能使用长度 26 且按 c - 'a' 索引的数组。

相似题目

题目 难度 关联与区别
209. 长度最小的子数组 中等 同样求满足约束的最短窗口,本题的约束是字符多重集合覆盖,原题是正数总和。
567. 字符串的排列 中等 原题要求固定长度与精确频次,本题允许额外字符并尽量缩短覆盖窗口。
438. 找到字符串中所有字母异位词 中等 用字符需求计数判断窗口是否覆盖目标;本题收缩窗口寻找最短覆盖,该题固定长度后记录所有异位词起点。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/78922337
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!