「越短越合法」型滑动窗口

也被称为 ​​「最短满足条件子串」问题 。其核心特点是:​所需满足的条件(合法性)往往更容易在较短的子串中得到满足

核心思想:​

  1. 1.目标:​ 在一个序列(通常是字符串或数组)中,寻找长度最小的连续子区间(窗口)​,使得该子区间满足特定的条件。
  2. 2.关键观察:​ 如果一个较长的子串满足条件,那么它内部(很可能)存在一个更短的子串也满足条件。​ 更通俗地说,窗口变短通常不会让满足条件变得更困难,反而可能更容易满足条件。​
  3. 3.策略:​ 基于上述观察,采用收缩窗口左边界的策略来尝试找到更小的合法窗口,在保持合法性的前提下追求最小长度。

为什么叫"越短越合法"?​

这个名称源于这类问题的一个关键特性:对于满足条件的窗口,​缩短窗口长度通常有助于维持或更容易达到条件。​​ 这通常与条件的性质有关:

  • 条件对长度敏感(通常要求资源更少):​
    • 最小覆盖子串:​ 要求子串包含 t中所有字符(出现频次≥ t中的频次)。更长的子串更容易包含额外的无关字符,而较短的子串只要能覆盖必需的字符就是合法的。缩短长度可以去除冗余字符,只要不破坏覆盖性。
    • 最小乘积/K 数的最小子数组:​ 寻找乘积≥K(或元素和≥K)的最小窗口。由于乘积或和是累加的,较长的窗口更容易达到较大的值。一旦一个窗口达到了 K,缩短它(减小乘积或和)有时(特别是在元素为正数时)可能会让值低于 K(打破条件),但也可能仍然≥K(保持合法)​ 。收缩左边界(即尝试缩短)就是在探索是否能在保持值≥K的前提下让窗口更短
  • 直观理解:​ 想象你要凑够某种"资源"(字符覆盖、乘积、和值)才能满足条件。一个大的窗口(资源仓库)更容易凑够资源,满足条件。但满足条件后,你怀疑可能只用仓库的一部分(更小的窗口)就凑够了所需资源。收缩左边界就是一点点清理你的仓库,看看最少的资源在哪里。

算法框架(模板):​

python 复制代码
def minWindow(s: str, t: str) -> str:
    # 1. 初始化:
    left = 0
    min_len = float('inf')  # 记录最小长度
    result = ""  # 记录结果
    # 创建计数器 (counter) 和条件检查器 (need)
    # (具体形式取决于问题:字符频次、元素乘积和、总和等等)
    # 例如,对于覆盖子串:
    from collections import Counter
    need = Counter(t)  # t中每个字符需要的数量
    window = Counter()  # 窗口中当前字符的数量
    valid = 0  # 计数器:窗口中有多少种字符的数量已经满足need的要求 (window[char] >= need[char])

    # 2. 移动右指针 (right), 扩大窗口
    for right in range(len(s)):
        char_right = s[right]
        # 更新窗口数据 (window, 累加计数器等)
        # 例如,覆盖子串:
        if char_right in need:
            window[char_right] += 1
            if window[char_right] == need[char_right]:
                valid += 1  # 该字符数量已达要求

        # 3. 判断当前窗口是否满足条件 (内层循环收缩左边界)
        while valid == len(need):  # 当条件满足时 (对于覆盖子串,即所有所需字符频次都达标)
            # 4. 更新结果:尝试用当前[Left, right]窗口更新最小窗口记录
            if right - left + 1 < min_len:
                min_len = right - left + 1
                result = s[left:right+1]  # 或记录left, right位置

            # 5. 收缩窗口:移动左指针 (left), 缩小窗口 (尝试找更小的窗口)
            char_left = s[left]
            # 更新窗口数据 (window, 减少计数器等)
            # 例如,覆盖子串:
            if char_left in need:
                if window[char_left] == need[char_left]:
                    valid -= 1  # 即将移除的字符是"关键"字符,数量即将低于要求
                window[char_left] -= 1

            left += 1  # 左指针右移,收缩窗口

    # 6. 返回结果
    return result

关键步骤讲解:​

  1. 1.初始化:​ 设置两个指针 leftright,通常都从起始位置(0)开始。初始化记录最小长度和最终结果的变量。根据问题初始化必要的计数器(如 need, window, valid)或累加器(product, curr_sum)。
  2. 2.移动右指针 (right),扩大窗口:​
    • 每次循环将 right向右移动一位。
    • 将新元素 s[right]纳入窗口。
    • 更新窗口状态信息:​ 更新频率计数器、和、积等,判断新元素是否影响了条件满足的关键计数器(如 valid)。
  3. 3.判断条件满足 & 收缩左边界(核心体现"越短越合法"):​
    • valid达到要求 (覆盖子串中所有字符都满足频次)或 curr_sumKproductK (当元素为正时),表示当前窗口 [left, right]满足条件
    • 当条件满足时 ,进入内层 while循环(这是关键)。这表明我们找到了一个合法窗口,现在尝试通过收缩左边界 (left右移)​ 来找到可能存在的更小的合法窗口 ,这正是利用了"越短越可能合法"的特性。
      • 更新结果:​ 在循环开始处(收缩前)检查当前窗口长度是否是最小的,更新最小长度和结果。
      • 移动左指针 (left),收缩窗口:​left指向的元素移出窗口。
      • 更新窗口状态信息:​ 更新频率计数器、和、积等,并更新条件状态检查器(如 valid)。如果移出的关键元素导致条件不再满足(例如,某个字符频次低于要求了,或者和/积跌破阈值了),则退出内层 while循环(因为继续收缩会打破条件)。否则,继续收缩尝试寻找更短窗口。
  4. 4.返回结果:​ 外层循环结束后,返回找到的最短合法子串(或长度/位置)。

​​"越短越合法"在本算法中的体现:​

  • 步骤 3 的内层 while循环是核心体现。一旦窗口满足条件,我们立即进入循环,尝试收缩左边界(使窗口变短)。这是因为:
    • 在当前窗口 [left, right]满足条件的前提下,更小的窗口 [left+1, right]可能也满足条件
    • 不断收缩左边界,直到窗口刚好不再满足条件 为止。那么收缩前一刻的窗口 [left-1, right](内层循环更新结果后收缩然后打破条件的那个状态)就是以当前 right结束的最短合法子串
    • 这个过程不断寻找以每个可能的 right为结束位置的最短合法子串,最终从这些候选中选出全局最短的。

典型例题:​

  1. 1.76. 最小覆盖子串 (Minimum Window Substring):​
    • 给定字符串 st,在 s中找出涵盖 t所有字符的最小子串。​ 经典中的经典,完美符合"越短越合法"------覆盖 t后,去除多余的字符,只要不破坏覆盖性,更短的窗口依然合法。
  2. 2.209. 长度最小的子数组 (Minimum Size Subarray Sum):​
    • 给定正整数数组 nums和目标值 target,找出和 ≥ target的长度最小的连续子数组。​ 满足"和 ≥ target"条件后,收缩左边界尝试缩短窗口(和会减少),但可能 (如果左边小数被移出,剩余和仍≥target)依然合法。需要尝试到刚好不合法为止。当 nums全为正数时,是典型的"越短越可能不再合法但需尝试",符合广义思路。
  3. 3.713. 乘积小于 K 的子数组 (Subarray Product Less Than K):​ (对比理解)
    • •这个题目要求的是 乘积 < K的子数组个数 ,虽然也用了滑动窗口,但其特性是"越长越容易非法"(因为正数数组乘积随长度增大),算法核心是计算以 right结尾的合法窗口数量。这与"越短越合法"的目标 ≥ K找最小窗口 形成了鲜明对比。注意区分问题要求的是 小于 K还是 大于等于 K以及是最小窗口还是计数。

注意事项:​

  • 初始化:​ 结果初始值要设为一个明显无效的值(如空字符串或极大数)。
  • 指针边界:​ 确保 left不超过 right。通常内层 while循环条件是 while (valid == condition and left <= right)或其他确保左边界不越界的条件。
  • 频率计数:​ 对于字符覆盖问题,用字典(Counter)维护 need(所需频次)和 window(当前频次)非常方便。
  • 数值判断:​ 对于求和、求积问题,注意数值溢出的可能性(特别是乘积),并且要理解当元素包含负数或0时,收缩窗口不一定导致和/积减小,问题会复杂很多(经典模板主要适用全正数情况)。
  • 复杂度:​ 由于每个元素最多被 leftright各访问一次,时间复杂度通常是 O(n)O(n + m)(覆盖子串初始化 need可能需要 O(m))。空间复杂度通常是 O(字符集大小)O(1)

总结:​

「越短越合法」型滑动窗口问题的核心在于,当找到一个满足条件的(可能较大的)窗口后,立即尝试通过收缩左边界 来寻找是否存在更短但依然满足条件的窗口 。其算法模板(含内层 while循环收缩)清晰地体现了这一策略。掌握好模板,理解 need/window/valid的维护,并灵活应用到类似问题上,是解决这类问题的关键。