【LeetCode 热题 100】76. 最小覆盖子串——(解法一)滑动窗口+数组

Problem: 76. 最小覆盖子串
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。

文章目录

  • 整体思路
  • 完整代码
  • 时空复杂度
    • 时间复杂度:O(|S| + |t|)
    • 空间复杂度:O(k) 或 O(1)

整体思路

这段代码旨在解决一个经典的字符串问题:最小窗口子串 (Minimum Window Substring)。问题要求在主字符串 S 中,找出一个包含目标字符串 t 所有字符的、长度最短的连续子串。

该算法采用了高效的 滑动窗口(Sliding Window) 算法,并结合 频率数组 来进行字符计数。其核心逻辑步骤如下:

  1. 预处理和初始化

    • 创建两个大小为 128 的整型数组 cntScntT,用于存储基于ASCII码的字符频率。cntT 用于记录目标字符串 t 中每个字符的“需求量”,而 cntS 用于记录当前滑动窗口内各字符的实际数量。
    • 遍历目标字符串 t,将其字符频率填充到 cntT 数组中。这个 cntT 数组是固定的“需求清单”。
    • 初始化两个变量 ansLeftansRight,用于记录迄今为止找到的最小窗口的左右边界。它们被初始化为一个表示“无限大”或“未找到”的状态(例如,[-1, m])。
  2. 滑动窗口的扩张与收缩

    • 算法使用 l (left) 和 r (right) 两个指针来定义滑动窗口 [l, r]
    • 窗口扩张:外层的 for 循环驱动 r 指针从左到右遍历主串 S。每当 r 向右移动一位,就将新字符 s[r] 的频率在 cntS 中加一。
    • 窗口校验与收缩:在每次扩张后,进入一个 while 循环,该循环的条件是 isCoverd(cntS, cntT)
      • isCoverd 是一个辅助函数,它通过比较 cntScntT 来判断当前窗口是否已经包含了 t 中的所有必需字符。
      • 如果窗口有效(isCoverd 返回 true
        a. 更新结果:比较当前窗口的长度 r - l 与已记录的最小长度 ansRight - ansLeft。如果当前窗口更短,就更新 ansLeftansRight
        b. 收缩窗口:为了寻找更短的有效窗口,需要尝试从左侧收缩。将左边界字符 s[l] 的频率在 cntS 中减一,然后将 l 指针右移。
      • 这个 while 循环会持续进行,直到窗口不再满足覆盖条件为止。之后,外层循环会继续移动 r 指针,寻找下一个可能的有效窗口。
  3. 返回结果

    • 整个 for 循环结束后,ansLeftansRight 中存储的就是全局最小窗口的边界。如果从未找到有效窗口(ansLeft 保持为初始值 -1),则返回空字符串;否则,使用 S.substring(ansLeft, ansRight + 1) 截取并返回结果。

完整代码

class Solution {
    /**
     * 在字符串 S 中查找包含字符串 t 所有字符的最小窗口子串。
     * @param S 主字符串
     * @param t 目标字符串
     * @return 最小窗口子串,如果不存在则返回空字符串
     */
    public String minWindow(String S, String t) {
        // cntS: 存储当前滑动窗口内子串的字符频率
        // cntT: 存储目标字符串 t 的字符频率
        // 使用大小为 128 的数组以覆盖所有基本 ASCII 字符
        int[] cntS = new int[128];
        int[] cntT = new int[128];
        int m = S.length();
        int n = t.length();
        
        // 步骤 1: 统计目标字符串 t 中每个字符的出现次数
        for (char c : t.toCharArray()) {
            cntT[c]++;
        }
        
        // ansLeft, ansRight: 记录最终找到的最小窗口的左右边界
        // 初始化为一个无效/极大范围,便于后续比较和更新
        int ansLeft = -1;
        int ansRight = m;
        
        // 将 S 转换为字符数组以提高访问效率
        char[] s = S.toCharArray();
        // l 是滑动窗口的左边界
        int l = 0;
        
        // 步骤 2: 滑动窗口遍历主串 S
        // r 是滑动窗口的右边界
        for (int r = 0; r < m; r++) {
            // a. 窗口扩张:将右边界字符计入窗口频率
            cntS[s[r]]++;
            
            // b. 窗口校验与收缩:当窗口满足覆盖条件时循环
            while (isCoverd(cntS, cntT)) {
                // 如果当前窗口比已记录的最小窗口更短,则更新结果
                if (r - l < ansRight - ansLeft) {
                    ansLeft = l;
                    ansRight = r;
                }
                // 尝试收缩窗口:将左边界字符的频率减一
                cntS[s[l]]--;
                // 左边界右移
                l++;
            }
        }
        
        // 步骤 3: 返回结果
        // 如果 ansLeft 从未被更新过,说明没有找到有效窗口
        return ansLeft < 0 ? "" : S.substring(ansLeft, ansRight + 1);
    }

    /**
     * 辅助函数:检查当前窗口的字符频率(cntS)是否完全覆盖了目标频率(cntT)。
     * @param cntS 窗口频率数组
     * @param cntT 目标频率数组
     * @return 如果覆盖则返回 true,否则返回 false
     */
    boolean isCoverd(int[] cntS, int[] cntT) {
        // 检查所有小写字母
        for (int i = 'a'; i <= 'z'; i++) {
            if (cntS[i] < cntT[i]) {
                return false;
            }
        }
        // 检查所有大写字母
        for (int i = 'A'; i <= 'Z'; i++) {
            if (cntS[i] < cntT[i]) {
                return false;
            }
        }
        // 如果所有必需字符的数量都足够,则返回 true
        return true;
    }
}

时空复杂度

时间复杂度:O(|S| + |t|)

  1. 预处理 t:第一个 for 循环遍历目标字符串 t 一次,用于填充 cntT。时间复杂度为 O(|t|),其中 |t|t 的长度。
  2. 滑动窗口遍历 S
    • 外层 for 循环的 r 指针从 0 遍历到 |S|-1,移动了 |S| 次。
    • 内层 while 循环的 l 指针也是从 0 开始向右移动,并且永不后退,最多移动到 |S|-1
    • 因此,r 指针和 l 指针都各自对字符串 S 进行了一次线性扫描。这部分操作的总时间复杂度是 O(|S|)
  3. isCoverd 函数
    • 这个函数在每次 while 循环条件判断时被调用。它遍历了从 'a''z' 和从 'A''Z' 的字符,总共 52 次比较。
    • 这个操作的次数是一个常数,不随输入字符串的长度变化。因此,isCoverd 函数的时间复杂度是 O(52),即 O(1)
    • 注意:更优化的实现会用一个变量来跟踪还差多少个字符,从而将此检查也变为O(1)且无需函数调用,但当前实现的复杂度分析结果不变。

综合分析
总时间复杂度 = 预处理时间 + 滑动窗口时间 = O(|t|) + O(|S|) * O(1) = O(|S| + |t|)

空间复杂度:O(k) 或 O(1)

  1. 主要存储开销:算法使用了两个整型数组 cntScntT,以及一个字符数组 s
    • cntScntT 的大小都是 128,这是一个固定的常数,代表了字符集的大小 k。这部分空间是 O(k)
    • s = S.toCharArray() 创建了主字符串 S 的一个副本,占用了 O(|S|) 的空间。
  2. 分析视角
    • 如果我们只考虑算法核心逻辑所需的额外辅助空间,那么 cntScntT 是主要部分,其空间复杂度为 O(k)。由于 k (128) 是一个常数,所以通常称其为 O(1) 空间。
    • 如果我们将 toCharArray() 的拷贝也视为算法的开销,那么空间复杂度将是 O(|S|)。在许多情况下,这被看作是语言实现层面的开销,而非算法本身的设计。

综合分析
最被广泛接受的答案是,算法的辅助空间复杂度O(k)(其中 k 为字符集大小),可以视为 O(1)

【LeetCode 热题 100】76. 最小覆盖子串——(解法二)滑动窗口+数组优化

参考灵神

你可能感兴趣的:(LeetCode,leetcode,算法,职场和发展,java)