hot100 最小覆盖子串(76)

本题采用双指针滑动窗口算法配合双频次数组映射解决"最小覆盖子串"问题。其核心本质是在保证窗口完全覆盖目标字符集的前提下,通过右指针扩展驱动状态匹配,利用左指针有条件收缩寻找边界极限。当前源码实现了时间复杂度 O(m + n) 与空间复杂度 O(1) 的最优双指针调度方案,最终走向是通过常数级数组检索直接输出局部最优目标切片。

一、 问题本质与数据模型

对于给定的源字符串 s(长度为 m)和目标字符串 t(长度为 n),最小覆盖子串问题要求在 s 中找到一个极短的连续闭区间 left, right,使得该区间内包含 t 中的所有字符,且包含的每种字符的数量均不小于 t 中该字符的出现频次。

为了消除多重循环导致的冗余状态扫描,算法引入了两个固定容量为 128 的整型数组来构建数据模型(128 的容量足以覆盖所有标准 ASCII 码字符):

  • need[128]:静态目标账本。用于在算法启动阶段统计字符串 t 中每个字符的实际需求量。

  • win[128]:动态窗口账本。用于在算法运行阶段实时记录当前滑动窗口 left, right 内部各个字符的收集数量。

由于字符串 t 中可能存在重复字符(例如 "aa"),仅靠简单的字符种类比对无法判定窗口是否合法。因此,算法引入了两个全局计数器:

  • required :代表字符串 t 中一共包含多少种互不相同且需求量大于 0 的字符种类。

  • count :代表当前滑动窗口内部,已经有多少种字符在数量上达到了需求标准

二、 算法演进对比

在处理多字符频次覆盖的区间搜索问题时,基于数组映射的滑动窗口法是资源消耗最低的方案:

解法名称 时间复杂度 空间复杂度 核心原理 物理瓶颈
暴力枚举法 O(m^2 * n) O(1) 枚举 s 的所有子串组合,每次对子串与 t 进行完全频次比对 包含大量的重叠区间重复计算,面对大规模输入时直接超时
滑动窗口 + 哈希表 O(m + n) O(k) (k为字符集大小) 动态维护左右指针,使用两个 HashMap 存储字符频次并进行状态匹配 哈希表的内部哈希碰撞与装载因子调整会带来额外的常数时间开销
滑动窗口 + 数组(当前解法) O(m + n) O(1) (固定大小 128) 利用直接寻址的 ASCII 频次数组代替哈希表,实现 O(1) 阶的存取性能 内存开销恒定,无明显物理瓶颈,为时空复杂度理论极限

三、 滑动窗口与状态匹配核心机制

该算法的控制流由外层右指针扩展(寻找可行解)和内层左指针收缩(寻找最优解)交替驱动:

1. 右指针右移与状态达成

主循环中,右指针 right 逐位向右步进,引入新字符 c = s.charAt(right)

  • 动态更新窗口账本:win[c]++

  • 关键判定条件:if (win[c] == need[c])。当且仅当窗口内字符 c 的数量恰好等于 目标账本中的需求量时,计数器 count++

  • 逻辑证明 :使用 == 而不是 >= 是为了确保对于每一种字符,count 计数器在生命周期内只自增一次。如果使用 >=,则后续每次移入相同字符都会导致 count 错误自增,从而破坏状态机逻辑。

2. 左指针收缩与边界压榨

当满足 count == required 时,说明当前窗口 left, right 已经是一个包含了 t 中所有字符的可行解 。此时,程序流进入 while 循环,尝试通过右移左指针 left 来精简窗口:

  • 记录最优解 :在收缩之前,判定当前窗口的物理物理长度 right - left + 1 是否小于历史历史最小值 min。若小于,则更新 min 并记录当前合法的起始索引 start = left

  • 移出字符与状态破坏 :准备移出左边界字符 d = s.charAt(left)。执行 win[d]--

  • 临界点判定 :移出后,如果满足 win[d] < need[d],说明字符 d 的丢失导致当前窗口不再满足 t 的基本数量需求。此时该字符的达标状态失效,必须执行 count--

  • 循环退出 :由于 count 减少,导致 count == required 条件不再成立,退出收缩循环。外层右指针继续向右扫描,重复该过程。

四、 算法执行状态机步进示例

以输入数据 s = "ADOBECODEBANC", t = "ABC" 为例(此时 required = 3)。核心关键步骤的状态机演进如下表所示:

步骤 指针状态 移入/移出字符 win 数组关键状态 count 值 触发逻辑与窗口更新 最小长度 min (start)
初始 left=0 - 所有清零 0 寻找可行解阶段 MAX_VALUE (-1)
1 right=0 移入 'A' win'A'=1 (满足 need) 1 count < required,继续右移 MAX_VALUE (-1)
2 right=5 移入 'C' win'C'=1 (满足 need) 2 count < required,继续右移 MAX_VALUE (-1)
3 right=10 移入 'B' win'B'=1 (满足 need) 3 首次达成可行解,窗口为 "ADOBECODEBA" 11 (0)
4 left收缩 移出 'A' win'A'=0 (小于 need) 2 破坏合法性,退出 while,left变为1 11 (0)
5 right=12 移入 'C' win'C'=2 (大于 need) 2 末尾引入 'C',暂未达成新覆盖 11 (0)
... ... ... ... ... 中间状态略去,直至右指针到末尾 11 (0)
6 left收缩 移出多余字符 沿途扣减非必要字符频次 3 窗口持续合法,压缩至 "BANC" (left=9) 4 (9)
7 终止 - - - 遍历结束,输出 s.substring(9, 9+4) 4 (9)

五、源码实现

复制代码
class Solution {
    public String minWindow(String s, String t) {
        // 建立动态窗口账本与静态目标账本
        int[] win = new int[128];
        int[] need = new int[128];
        
        // 初始化目标账本,统计 t 中每个字符的需求频次
        for (char c : t.toCharArray()) {
            need[c]++;
        }
        
        // 计算目标字符的独立种类数量
        int required = 0;
        for (int num : need) {
            if (num > 0) {
                required++;
            }
        }
        
        int left = 0;
        int count = 0;      // 记录窗口内当前已达标的字符种类数
        int start = -1;     // 记录最小覆盖子串的起始物理索引
        int min = Integer.MAX_VALUE; // 记录最小覆盖子串的物理长度
        
        // 右指针作为主控制变量,向右线性遍历源字符串 s
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            win[c]++;
            
            // 当前字符的收集数量恰好满足目标需求,该字符种类达标
            if (win[c] == need[c]) {
                count++;
            }
            
            // 当所有字符种类均达标时,当前窗口为可行解,触发左边界收缩逻辑
            while (count == required) {
                // 如果当前窗口的跨度小于历史最优解,更新记录
                if (right - left + 1 < min) {
                    start = left;
                    min = right - left + 1;
                }
                
                // 获取准备移出窗口的左侧字符
                char d = s.charAt(left);
                win[d]--;
                
                // 如果移出该字符后,其拥有量小于了目标需求量,则该字符达标状态失效
                if (win[d] < need[d]) {
                    count--;
                }
                
                // 左指针右移,继续压榨窗口边界
                left++;
            }
        }
        
        // 若 start 未被更新,说明无可行解,返回空串;否则截取对应区间的子串返回
        return (start == -1) ? "" : s.substring(start, start + min);
    }
}

六、 复杂度极限分析

1. 时间复杂度:O(m + n)

  • 目标账本初始化 :算法首先遍历长度为 n 的字符串 t 以构建 need 数组,耗时 O(n)。

  • 滑动窗口执行期 :右指针 rightfor 循环中从 0 步进到 m-1,一共执行 m 次。左指针 left 仅在条件达成时向右移动,在整个算法生命周期中,left 的自增次数绝对不会超过 m 次。

  • 结论 :字符串 s 中的每个字符最多经历一次进入窗口和一次移出窗口的操作。总体的基本指令执行次数不超过 n + 2m 次,因此整体时间复杂度呈现为稳定的线性阶 O(m + n)。

2. 空间复杂度:O(1)

  • 分配机制 :算法在堆栈空间中独立申请的辅助存储结构为两个固定长度的整型数组 win[128]need[128],以及常数个基础类型的状态控制变量。

  • 结论:数组的物理空间开销由 ASCII 字符集的大小决定,属于常量阶。该内存消耗完全不随输入字符串 s 和 t 的规模扩大而发生增长,因此空间复杂度为严格的 O(1)。