【码道初阶】挑战Leetcode76Hard最小覆盖子串问题:滑动窗口的优雅实现与深度剖析


最小覆盖子串问题:滑动窗口的优雅实现与深度剖析


一、问题核心与挑战

给定两个字符串 st,要求从 s 中找到包含 t 所有字符(包括重复字符)的最短连续子串。若不存在,返回空字符串。例如:

  • s = "ADOBECODEBANC", t = "ABC" → 输出 "BANC"(最小窗口)
  • s = "a", t = "aa" → 输出 ""(无法满足重复需求)

挑战:如何高效地在一次遍历中找到最短覆盖子串?


二、代码实现与注释

以下代码通过滑动窗口算法实现,逐行解析其逻辑:

class Solution {
public:
    string minWindow(string s, string t) {
    vector<bool>Valid(128,false);//记录哪些字符是有效的(t中出现的)
    vector<int>Freq(128,0);//记录t中某字符出现了多少次
    for(char c : t)
    {
       Valid[c]=true;
       Freq[c]++;
    }
    int l=0,min_l=0;
    int count=0,min_length=-1;//定义左指针 最小滑动窗口长度 当前滑动窗口长度 最小左指针 
    for(int r=0;r<s.size();r++)
    {
        char link = s[r];
        if(!Valid[link]) continue;
        if(--Freq[link]>=0) {count++;}//count负责计数 t中的字符总数 而每次遍历到该字符都会使Freq中该字符次数-1 用这个来判断当前窗口是否已经包含完了所有的字符
        while(count == t.size())//每一次滑动窗口满足条件都会触发循环
        {
            int window_len = r - l + 1;
            if(min_length == -1 || window_len<min_length) //滑动窗口最小长度未初始化或者大于现在的长度均会触发下一次最优尝试
            {
            min_length = window_len;
            min_l = l;
            }
            char link2 = s[l];//下面的操作旨在更新l指针 检查向右移动l的时候 滑动窗口尽量变小的同时还能不能兼顾字符完整性
            if(Valid[link2])
            {
                if(++Freq[link2]>0){//移动l指针后 如果不满足滑动窗口包含相应的字符 就将count-1重新滑动新的窗口 直到选出更短的窗口就会执行上面的代码       
                count--;}
            }
            l++;
        }
        
    }
    return (min_length == -1) ?"":s.substr(min_l,min_length);
    }
};

三、代码核心逻辑拆解

1. 初始化阶段

  • Valid数组:标记 t 中存在的字符(ASCII 共 128 个字符)。例如,t = "AABC" 时,Valid['A'] = Valid['B'] = Valid['C'] = true
  • Freq数组:记录 t 中各字符的需求量。例如,t = "AABC" 时,Freq['A'] = 2, Freq['B'] = 1, Freq['C'] = 1

2. 滑动窗口扩展阶段(右指针移动)

  • 跳过无效字符:若当前字符不在 t 中,直接跳过。
  • 更新频率与计数器
    • --Freq[link]:将当前字符的需求量减1。
    • Freq[link] >= 0,说明该字符的纳入有效,count++
    • 注意:前序递增/递减速度更快 且执行语句时就已完成递减(先递减后判定)所以尽量用前序写法
    • --Freq[link]>=0 与 Freq[link]-->0等效 但是前者执行时间更快!

3. 滑动窗口收缩阶段(左指针移动)

  • 触发条件:当 count == t.size() 时,窗口已覆盖所有字符。
  • 更新最小窗口:记录当前窗口的起始位置和长度。
  • 恢复需求与调整窗口
    • 移出左边界字符时,恢复其需求量(++Freq[link2])。
    • 若恢复后需求量 > 0,说明窗口不再满足该字符的需求,count--

四、关键设计思想

1. 频率数组的“正负值”设计

  • Freq[c] > 0:窗口缺少该字符。

  • Freq[c] = 0:窗口刚好满足需求。

  • Freq[c] < 0:窗口冗余(包含超过需求的字符)。

  • 动态调整

    • 右指针纳入字符时减少 freq[c]

    • 左指针移出字符时增加 freq[c]

2. 计数器的精准控制

  • count 的意义:记录窗口中已满足需求的字符总数(非冗余字符)。
    • 例如,t = "AABC"(总需求数4),当窗口中有2个A、1个B、1个C时,count = 4
  • 动态更新:仅当字符的纳入或移出导致需求变化时,才更新 count

3. 滑动窗口的边界收缩

  • 贪婪收缩:一旦窗口覆盖所有字符,立即尝试缩小窗口以寻找更优解。
  • 状态回退:移出字符时恢复需求,确保后续窗口的扩展能正确评估覆盖状态。

五、示例推演与流程分析

s = "ADOBECODEBANC", t = "ABC" 为例:

步骤 右指针r 窗口范围 操作
1 5 [A,D,O,B,E,C] 覆盖所有字符,记录窗口长度6,左指针收缩至索引1,移出A后需求不满足。
2 12 [B,A,N,C] 再次覆盖所有字符,记录窗口长度4,左指针收缩至索引9,移出B后需求仍满足。

  1. 初始化

    • valid['A','B','C'] = truefreq['A','B','C'] = 1
    • count = 0, min_length = -1.
  2. 右指针扩展至索引5(字符’C’)

    • 窗口 A D O B E C 包含所有 t 的字符。
    • count = 3,进入收缩阶段。
    • 更新 min_length = 6, min_l = 0.
  3. 左指针收缩至索引1

    • 移出字符 A,恢复 freq['A'] = 1,导致 count = 2,退出循环。
  4. 继续扩展右指针至索引12(字符’C’)

    • 窗口 B A N C 满足条件,更新 min_length = 4, min_l = 9.

六、边界情况处理

1. t 中有重复字符

  • 示例t = "AABC", s = "AABBC"
    • 需窗口包含至少2个A,代码通过 Freq 数组的动态增减确保正确计数。

2. s 中无有效窗口

  • 处理min_length 保持 -1,最终返回空字符串。

3. s 完全等于 t

  • 处理:首次找到窗口时直接记录为最优解。

七、复杂度分析

  • 时间复杂度O(|s| + |t|)
    • 初始化 ValidFreq 数组需 O(|t|)
    • 滑动窗口遍历 s 时,每个字符最多被左右指针各访问一次。
  • 空间复杂度O(1)
    • 固定大小的数组(128),与输入规模无关。

八、优化与扩展

1. 性能优化

  • 数组代替哈希表:利用ASCII字符集有限的特性,数组访问效率高于哈希表。
  • 提前终止:若窗口长度等于 t 的长度,可直接返回。

2. 扩展问题

  • 最长覆盖子串:修改条件为寻找最大窗口。
  • 多模式匹配:结合Trie树处理多个 t 的情况。

九、总结

通过滑动窗口技术,结合频率数组和计数器的精准控制,该算法能够在 线性时间复杂度 内高效解决最小覆盖子串问题。关键点包括:

  • 频率数组的正负值设计:简化了冗余字符的处理。

  • 计数器的动态更新:避免全量检查字符频率。

  • 边界条件的鲁棒性:通过初始化 min_length 为 -1,明确标识无效状态。

此问题不仅考察对滑动窗口的理解,还要求对字符频率管理和边界条件的细致处理,是算法设计中的经典范例。

你可能感兴趣的:(码道初阶,算法,leetcode,c++,c语言)