「越短越合法」型滑动窗口
也被称为 「最短满足条件子串」问题。其核心特点是:所需满足的条件(合法性)往往更容易在较短的子串中得到满足。
核心思想:
- 1.目标: 在一个序列(通常是字符串或数组)中,寻找长度最小的连续子区间(窗口),使得该子区间满足特定的条件。
- 2.关键观察: 如果一个较长的子串满足条件,那么它内部(很可能)存在一个更短的子串也满足条件。 更通俗地说,窗口变短通常不会让满足条件变得更困难,反而可能更容易满足条件。
- 3.策略: 基于上述观察,采用收缩窗口左边界的策略来尝试找到更小的合法窗口,在保持合法性的前提下追求最小长度。
为什么叫“越短越合法”?
这个名称源于这类问题的一个关键特性:对于满足条件的窗口,缩短窗口长度通常有助于维持或更容易达到条件。 这通常与条件的性质有关:
- 条件对长度敏感(通常要求资源更少):
- 最小覆盖子串: 要求子串包含
t
中所有字符(出现频次≥t
中的频次)。更长的子串更容易包含额外的无关字符,而较短的子串只要能覆盖必需的字符就是合法的。缩短长度可以去除冗余字符,只要不破坏覆盖性。 - 最小乘积/K 数的最小子数组: 寻找乘积≥
K
(或元素和≥K
)的最小窗口。由于乘积或和是累加的,较长的窗口更容易达到较大的值。一旦一个窗口达到了K
,缩短它(减小乘积或和)有时(特别是在元素为正数时)可能会让值低于 K(打破条件),但也可能仍然≥K(保持合法)。收缩左边界(即尝试缩短)就是在探索是否能在保持值≥K的前提下让窗口更短。
- 最小覆盖子串: 要求子串包含
- 直观理解: 想象你要凑够某种“资源”(字符覆盖、乘积、和值)才能满足条件。一个大的窗口(资源仓库)更容易凑够资源,满足条件。但满足条件后,你怀疑可能只用仓库的一部分(更小的窗口)就凑够了所需资源。收缩左边界就是一点点清理你的仓库,看看最少的资源在哪里。
算法框架(模板):
def minWindow(s: str, t: str) -> str:# 1. 初始化:left = 0min_len = float('inf') # 记录最小长度result = "" # 记录结果# 创建计数器 (counter) 和条件检查器 (need)# (具体形式取决于问题:字符频次、元素乘积和、总和等等)# 例如,对于覆盖子串:from collections import Counterneed = 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] += 1if 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 + 1result = 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] -= 1left += 1 # 左指针右移,收缩窗口# 6. 返回结果return result
关键步骤讲解:
- 1.初始化: 设置两个指针
left
和right
,通常都从起始位置(0)开始。初始化记录最小长度和最终结果的变量。根据问题初始化必要的计数器(如need
,window
,valid
)或累加器(product
,curr_sum
)。 - 2.移动右指针 (
right
),扩大窗口:- 每次循环将
right
向右移动一位。 - 将新元素
s[right]
纳入窗口。 - 更新窗口状态信息: 更新频率计数器、和、积等,判断新元素是否影响了条件满足的关键计数器(如
valid
)。
- 每次循环将
- 3.判断条件满足 & 收缩左边界(核心体现“越短越合法”):
- 当
valid
达到要求(覆盖子串中所有字符都满足频次)或 curr_sum
≥K
或 product
≥K
(当元素为正时),表示当前窗口[left, right]
满足条件。 - 当条件满足时,进入内层
while
循环(这是关键)。这表明我们找到了一个合法窗口,现在尝试通过收缩左边界 (left
右移) 来找到可能存在的更小的合法窗口,这正是利用了“越短越可能合法”的特性。- 更新结果: 在循环开始处(收缩前)检查当前窗口长度是否是最小的,更新最小长度和结果。
- 移动左指针 (
left
),收缩窗口: 将left
指向的元素移出窗口。 - 更新窗口状态信息: 更新频率计数器、和、积等,并更新条件状态检查器(如
valid
)。如果移出的关键元素导致条件不再满足(例如,某个字符频次低于要求了,或者和/积跌破阈值了),则退出内层while
循环(因为继续收缩会打破条件)。否则,继续收缩尝试寻找更短窗口。
- 当
- 4.返回结果: 外层循环结束后,返回找到的最短合法子串(或长度/位置)。
“越短越合法”在本算法中的体现:
- 步骤 3 的内层
while
循环是核心体现。一旦窗口满足条件,我们立即进入循环,尝试收缩左边界(使窗口变短)。这是因为:- 在当前窗口
[left, right]
满足条件的前提下,更小的窗口[left+1, right]
可能也满足条件。 - 不断收缩左边界,直到窗口刚好不再满足条件为止。那么收缩前一刻的窗口
[left-1, right]
(内层循环更新结果后收缩然后打破条件的那个状态)就是以当前right
结束的最短合法子串。 - 这个过程不断寻找以每个可能的
right
为结束位置的最短合法子串,最终从这些候选中选出全局最短的。
- 在当前窗口
典型例题:
- 1.76. 最小覆盖子串 (Minimum Window Substring):
- •给定字符串
s
和t
,在s
中找出涵盖t
所有字符的最小子串。 经典中的经典,完美符合“越短越合法”——覆盖t
后,去除多余的字符,只要不破坏覆盖性,更短的窗口依然合法。
- •给定字符串
- 2.209. 长度最小的子数组 (Minimum Size Subarray Sum):
- •给定正整数数组
nums
和目标值target
,找出和 ≥target
的长度最小的连续子数组。 满足“和 ≥target
”条件后,收缩左边界尝试缩短窗口(和会减少),但可能(如果左边小数被移出,剩余和仍≥target
)依然合法。需要尝试到刚好不合法为止。当nums
全为正数时,是典型的“越短越可能不再合法但需尝试”,符合广义思路。
- •给定正整数数组
- 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时,收缩窗口不一定导致和/积减小,问题会复杂很多(经典模板主要适用全正数情况)。
- 复杂度: 由于每个元素最多被
left
和right
各访问一次,时间复杂度通常是O(n)
或O(n + m)
(覆盖子串初始化need
可能需要O(m)
)。空间复杂度通常是O(字符集大小)
或O(1)
。
总结:
「越短越合法」型滑动窗口问题的核心在于,当找到一个满足条件的(可能较大的)窗口后,立即尝试通过收缩左边界来寻找是否存在更短但依然满足条件的窗口。其算法模板(含内层 while
循环收缩)清晰地体现了这一策略。掌握好模板,理解 need/window/valid
的维护,并灵活应用到类似问题上,是解决这类问题的关键。