滑动窗口这个技巧,我在实际刷题和做项目里的算法优化时用过很多次。它的名字起得很直白:在一个数组或字符串上定义一个“窗口”,通过左右两个指针的移动,把连续子数组的枚举从暴力法的O(n²)甚至O(n³)压缩到O(n)。这个思路专门解决“连续子数组”问题,比如找和为某个值的最短子数组、最长无重复字符的字符串、固定窗口内的最大值。适合刚接触算法的人,也适合已经会用双指针但总想不清楚什么时候该用滑动窗口的人。
很多人学滑动窗口容易卡在一个点上:代码能跑,但不知道窗口为什么这么移动;换一道题,还是不会。所以我这篇不打算堆模板,而是从“暴力解在哪里浪费了时间”讲起,再展开定长窗口、变长窗口两套核心框架,最后列一些我实操中踩过的边界条件的坑。你把它当一份带注释的复盘记录看就行。
1. 连续子数组问题的核心痛点,以及暴力解法到底慢在哪
先明确连续子数组的定义:它是原序列中一段连续的元素切片,比如数组[1, 2, 3, 4]的所有连续子数组包括[1]、[2, 3]、[1, 2, 3, 4]等。和子序列不同,子数组不允许跳元素。这类问题在真实项目中也很常见,比如分析一段时间窗口内的请求总量、计算传感器连续异常片段,本质上都是连续子数组的统计。
1.1 暴力枚举的时间浪费在哪里
拿“长度最小的子数组”举例子:给定一个正整数数组 nums 和一个目标值 target,找到和大于等于 target 的连续子数组的最短长度。新手第一反应是双重循环:外层定起点 i,内层从 i 往后累加 sum,一旦 sum 超过 target 就记录长度,然后 break。代码长这样:
def min_sub_array_len_brute(target, nums): n = len(nums) ans = float("inf") for i in range(n): total = 0 for j in range(i, n): total += nums[j] if total >= target: ans = min(ans, j - i + 1) break return ans if ans != float("inf") else 0这段代码遇到极端情况会非常慢。假设数组全是 1,target 等于数组长度减一,每次循环都要从 i 累加到 n-1 附近才能达标,两层循环的累计工作量接近 n²/2。问题出在一个关键细节:最内层循环在反复重新计算重叠部分的和。
比如 i=0 时,你已经算出了nums[0] + nums[1] + nums[2]的结果;i=1 时,你又从零开始重新加nums[1] + nums[2]。这两次计算之间,nums[1] + nums[2]明明已经算过一遍,却被丢掉重来了。内层循环每移动一次,就丢弃了大量可复用的计算结果,这就是暴力法复杂度高的本质。
1.2 前缀和方案能优化但不够直观
另一类优化是用前缀和数组。先预处理pre[i]表示前 i 个元素的和,那么子数组nums[i:j]的和就是pre[j] - pre[i]。配合二分查找,能把求和从 O(n) 降到 O(log n),整体复杂度变成 O(n log n)。这个方案有效,但它引入了一个额外数组,而且只解决“求和”类问题。遇到“字符串中无重复字符”“窗口内元素种类受限”这类条件,前缀和就帮不上忙了。滑动窗口的优势在于它直接操作原序列,用两个指针判定窗口合法性,不需要额外数据结构就能解决大量统计问题。
1.3 滑动窗口为什么能把复杂度拉到 O(n)
核心思想是让右指针一路向前,遇到条件不满足时,左指针再追上来。每个元素最多被右指针访问一次、被左指针访问一次,总操作次数约 2n 次,所以时间复杂度和数组长度成正比。这种“一个元素最多进出窗口一次”的特点,是它比暴力法快的关键。
我起初纠结过一个点:滑动窗口的每次外层循环内,内层 while 可能执行多次,总复杂度真的还是 O(n) 吗?后来想通了:左指针不会回退,每次 while 移动左指针时,它前进的总步数不可能超过 n。左右指针总共移动不超过 2n 次,每个位置的操作是 O(1) 的(更新窗口值、更新哈希表),所以整体线性。
2. 滑动窗口的分类与适用场景:定长、变长,别搞混
滑动窗口按窗口长度是否固定分为两类。定长窗口的典型特征是题目直接给出窗口大小 k,让你求所有长度为 k 的窗口中的某个统计值;变长窗口则是窗口大小不固定,你需要根据条件动态伸缩。两种框架的代码结构有明显区别。
2.1 适合用滑动窗口的三个信号
判断一道题能不能用滑动窗口,我一般看三点。第一,研究对象必须是连续区间,子数组、子串都符合;第二,条件可以通过扩展右边界和收缩左边界来维护,比如窗口内元素和、字符频次、最大最小值;第三,窗口收缩的方向是单调的——右指针只会向右,左指针也只会向右。如果题目要求“找到所有和为 target 的子数组”,窗口收缩后还可能要重新右移,那就不适合直接套滑动窗口。
有时候可以直接排除滑动窗口。比如找数组里任意两个元素(不要求连续)的最大差值,这跟区间连续性无关,用双指针或贪心更合适。再比如求带负数的、和恰好等于某值的子数组,因为窗口和不再随扩展单调递增,滑动窗口的收缩逻辑会失效,更安全的做法是前缀和加哈希表。
2.2 定长窗口框架:右指针前进,左指针紧跟
定长窗口的代码骨架比较固定:先让右指针从 0 走到 k-1,形成初始窗口;之后每轮右指针前进一格,左指针同步前进一格,始终保持窗口长度等于 k,并更新答案。
def fixed_window(nums, k): n = len(nums) if n < k: return [] window_sum = sum(nums[:k]) res = [window_sum] for r in range(k, n): window_sum += nums[r] - nums[r - k] res.append(window_sum) return res这个框架的关键是理解nums[r] - nums[r-k]的含义:窗口向右滑动一格,增加新元素 nums[r],去掉旧元素 nums[r-k](也就是离开窗口的元素)。用这种方式更新,每次只需常数时间,不需要重新求和。
2.3 变长窗口框架:右指针负责扩展,左指针负责收缩
变长窗口的核心循环长这样:
def variable_window(nums, condition): n = len(nums) left = 0 state = None # 根据题目维护窗口内状态,比如和、频次 ans = 0 for right in range(n): # 1. 把 nums[right] 纳入窗口,更新 state ... # 2. 当窗口不满足条件时,收缩 left while not condition(state): ... left += 1 # 3. 此时窗口满足条件,更新答案 ... return ans这个模式有三个位置最容易出错:一是“更新 state”漏掉了一些维度,比如字符串题里字符种类和频次要同时维护;二是收缩条件写反,导致窗口收缩过头;三是在收缩循环之前还是之后更新答案,两种方式对应完全不同的问题。定长窗口和变长窗口的边界情况不同,下面分开细讲。
2.4 为什么变长窗口左指针移动是单调的
变长窗口的一个隐含前提是:窗口向右扩展时,“满足条件”的判定具有单调性——窗口越大,某种指标越大或越小,于是当窗口过大/不合法时,我们可以放心地收缩左边界。比如“窗口内所有元素和不超过 target”,扩大窗口会增加和,突破上限后收缩左边界会减少和,这种一增一减的单向关系让左指针可以一直向右,不需要回退。这也是为什么排序后的数组、全正数数组更适合滑动窗口,而负数会破坏单调性。
3. 定长窗口实战:从滑动最大值到字符串排列
定长窗口在工程里比变长更简单,但陷阱也不少。我拿两个典型例子展开:一个是在数值数组中求每个窗口的最大值,另一个是在长字符串中找是否存在目标字符串的排列。
3.1 定长子数组最大值的两种解法
题目场景:有数组 nums 和一个整数 k,需要输出每个长度为 k 的连续窗口的最大值。最直观的想法是对每个窗口用max()扫描,长度 k 时复杂度 O(nk)。当 k 接近 n 时,退化成 O(n²)。
第一种改进是用“单调队列”,内部维护一个递减队列。每次窗口右移时,把新元素从队尾塞入,同时弹出队尾所有比它小的元素,因为它们不可能再成为窗口最大值;队头如果滑出窗口范围就弹出。单调队列代码稍长,但每个元素进队出队各一次,复杂度 O(n)。如果只是求窗口最大值的场景,我建议优先用这个方案。
第二种做法是分块预处理,把数组按 k 大小分成若干块,用两个方向的前缀最大值数组来 O(1) 求每个窗口的最大值。这种方法没有队列的“弹出”逻辑,边界更稳定,适合不熟悉队列的人自己实现。不过单调队列在“滑动窗口最值”这类题里更通用,值得花时间练熟。
下面给一个单调队列的实现,里面用双端队列存下标而不是存值,因为存下标才能判断窗口范围:
from collections import deque def sliding_max(nums, k): q = deque() res = [] for r, v in enumerate(nums): # 维护递减队列:弹出所有小于 v 的队尾元素 while q and nums[q[-1]] <= v: q.pop() q.append(r) # 弹出窗口外的队头 if q[0] <= r - k: q.popleft() # 窗口形成后开始记录 if r >= k - 1: res.append(nums[q[0]]) return res这里的核心细节是nums[q[-1]] <= v。我试过只写<,会有一个隐患:当存在重复最大值时,旧的最大值更早离开窗口,如果保留它,会在窗口已经不包括它的时候过早弹出队头吗?实际上不会,因为还要配合q[0] <= r - k判断。但为了保证窗口内元素的“新鲜度”,用<还是<=在不同题目里有讲究。求最大值时,用<=弹出等于当前值的旧下标,能让队列里留下的下标更新,减少后续判断,也避免最大值重复记录。求最小值时反过来处理。
3.2 字符串排列判断:定长窗口的哈希表维护
题目场景:给定两个字符串 s1 和 s2,判断 s2 中是否包含 s1 的某个排列。这里的“排列”不要求连续,但必须是 s1 中所有字符的一个排列,所以窗口长度固定等于 len(s1)。判断两个字符串是否为排列关系,需要统计字符频次一致。
我见过最稳的写法是用两个长度为 26 的计数数组,分别统计 s1 的频次和当前窗口内字符频次。窗口右移时,把新字符的计数加一,把离开窗口的字符计数减一,然后逐一比较两个数组是否相等。
def check_inclusion(s1, s2): m, n = len(s1), len(s2) if m > n: return False from collections import Counter need = Counter(s1) window = Counter(s2[:m]) if need == window: return True for r in range(m, n): window[s2[r]] += 1 left = r - m window[s2[left]] -= 1 if window[s2[left]] == 0: del window[s2[left]] if need == window: return True return False这个题有个很经典的优化点:不用每轮都完整比较两个哈希表。可以维护一个“差异计数”变量 match,记录当前窗口内有多少个字符的频次与需要值相等。每次窗口移动只更新受影响的左右两个字符,match 也随之更新,整题复杂度始终是 O(n),不会有 O(26n) 的多余扫描。这也是我在实际解析代码时最常提的一个优化思路。窗口移动一次,最多影响两个字符的计数,所以用匹配数增量更新,能省掉整个哈希表对比的开销。需要注意的是,Python 的 Counter 直接比较看起来很爽,但如果 n 很大,每轮比较的代价不容忽视,生产环境里还是建议维护 match 变量。
4. 变长窗口实战:最短子数组、最长无重复子串、受限窗口
变长窗口是滑动窗口的重头戏,也是很多人感觉“看着别人的代码能懂,自己写就乱”的地方。我按三类经典问题逐一拆解。
4.1 长度最小的子数组:收缩时机是核心
回到第一节的题目:正整数数组,找和大于等于 target 的最短连续子数组。因为全为正数,右指针扩展会让和增大,左指针收缩会让和减小,单调性满足,可以用变长窗口。
def min_sub_array_len(target, nums): n = len(nums) left = 0 total = 0 ans = float("inf") for right in range(n): total += nums[right] while total >= target: ans = min(ans, right - left + 1) total -= nums[left] left += 1 return ans if ans != float("inf") else 0注意 while 循环内部先更新答案再收缩左边界。因为收缩后窗口可能依然满足条件,所以要一直收缩到不满足为止,这样才不会漏掉更短的窗口。如果把ans = min(...)放在 while 之后,看似只记录了一个合法窗口,实际上窗口收缩过程中的多个合法状态会被跳过,可能错过最短长度。我在练习时犯过这个错误,输出的结果总是比答案长 1 或 2,后来定位到就是更新顺序的问题。
4.2 最长无重复字符子串:哈希表记录位置
题目场景:给定字符串 s,找出其中不含重复字符的最长子串长度。这类题的经典解法是哈希表记录每个字符最近一次出现的位置,遇到重复字符时,把左指针跳到 max(left, 上次位置 + 1)。
def length_of_longest_substring(s): last = {} left = 0 ans = 0 for right, ch in enumerate(s): if ch in last and last[ch] >= left: left = last[ch] + 1 last[ch] = right ans = max(ans, right - left + 1) return ans这里有一个很关键的条件:last[ch] >= left。如果哈希表里记录的是更早之前的出现位置,比如字符已经在左指针左侧很久了,那么直接把它当成重复位置会出错。我举个例子:字符串"abba"。处理完前三个字符后,left=1,last['a']=0。走到最后一个字符'a'时,哈希表里有a,但它的位置 0 在左指针 1 的左侧,意味着当前窗口里并没有a,所以不能更新 left。如果少了last[ch] >= left这个判断,left 会被错误设置为 1,导致最长子串长度计算错误。这个坑特别隐蔽,我曾经把答案算成 3,而正确答案是 2。
另一种实现是使用集合,窗口内出现的字符记录在 set 中,遇到重复字符时不断从左侧移除字符直到没有重复。这种写法逻辑更直观,但最坏情况下每个字符进出 set 各一次,复杂度依然是 O(n)。哈希表记录位置的方法跳过了重复字符之前的所有字符,平均更快一点。两种方法我建议都掌握,面试时可以根据题目条件灵活切换:如果字符集很小,比如只有小写字母,用数组替代哈希表更高效。
4.3 窗口内最多 k 种不同字符:收缩条件用“种类数”
题目场景:给定一个字符串,求最长的包含不超过 k 种不同字符的子串。这是“滑动窗口 + 条件收缩”的经典变体。维护一个窗口频率表,窗口每扩展一个字符就更新频率;如果频率表中不同字符数大于 k,就收缩左边界,直到种类数回到 k 及以内。
def longest_substring_k_distinct(s, k): from collections import defaultdict freq = defaultdict(int) left = 0 ans = 0 for right, ch in enumerate(s): freq[ch] += 1 while len(freq) > k: left_ch = s[left] freq[left_ch] -= 1 if freq[left_ch] == 0: del freq[left_ch] left += 1 ans = max(ans, right - left + 1) return ans这里最容易踩的坑是:左指针移动时只减频次,不删除零频次的键,会导致len(freq)一直偏大,窗口无法收缩到合法状态。一定要在频次为零时把键删除,或者用计数为 0 的哨兵值配合额外变量记录不同字符数。我在实际写代码时更倾向于删除键,一方面语义清晰,另一方面 Python 的del操作在字典上的代价可以接受。
4.4 最长连续 1 的个数:允许翻转 k 个 0 转化成窗口限制
这类题也是变长窗口,但统计的不是“和”也不是“种类”,而是窗口内 0 的个数。给定一个二进制数组 nums 和一个整数 k,最多可以把 k 个 0 翻转为 1,求翻转后最长连续 1 的长度。直接做翻转模拟很麻烦,等价转换一下:求整个数组中包含 0 的个数不超过 k 的最长连续子数组。只要窗口内 0 的个数小于等于 k,这个窗口就可以通过最多 k 次翻转全部变成 1,窗口长度就是答案。
def longest_ones(nums, k): left = 0 zero_count = 0 ans = 0 for right, val in enumerate(nums): if val == 0: zero_count += 1 while zero_count > k: if nums[left] == 0: zero_count -= 1 left += 1 ans = max(ans, right - left + 1) return ans这类“等价转换”其实是滑动窗口进阶的常用手段。它不直接模拟题目的每一步操作,而是把限制条件抽象成窗口内某个计数指标,从而让窗口伸缩规则变得统一。做题时多问一句“我能不能把这道题转成某个指标的窗口限制”,往往能打开思路。
5. 常见问题与排查技巧实录
我平时在技术社区看到讨论滑动窗口的人,很多代码逻辑没问题,但就是一两个细节没处理好导致死循环或者输出错误。下面整理几个高频问题。
5.1 为什么我的代码会无限循环
最常见的原因是在收缩循环里忘记移动 left。看看这段:
while total > target: total -= nums[left] # 忘记 left += 1这会让窗口卡在原位置,total 一直减小,直到负数,循环迟迟不退出。排查时先检查所有 while 循环体里是否有更新指针或者更新状态的操作。另一个典型问题是左指针超过了右指针,在收缩逻辑写错的情况下,left 可能跑到 right 右边,后续访问nums[left]就越界。
5.2 更新答案的位置到底放在哪
这个问题没有统一的死答案,取决于你在求什么。求“最小”时,经常在窗口合法性第一次满足时更新,然后立即收缩,重复检查。求“最大”时,通常把收缩循环放在前面,等窗口重新合法后再更新答案。一个通用判断标准是:在窗口合法状态下更新答案,如果收缩后窗口还可能合法,那更新要放在收缩循环内部或紧随其后进行;如果收缩后窗口合法状态已经改变,那就放在收缩结束之后。
我之前写过求“最短”的版本,把ans = min(...)放在 while 后面,结果对某些用例少算了一种合法状态,害我调试了大半天。后来养成了习惯:遇到更新答案位置不确定时,把窗口合法的每个状态都手动列一遍,推演指针变化,很快就能定下来。
5.3 哈希表或频率数组何时清空零值
维护频次时,零频次键是否删除直接影响“不同字符数”“匹配字符数”这些统计指标。使用 defaultdict 时尤其容易忽略:访问不存在的键不会报错,但会创建默认值,导致长度判断失真。经验做法是:
- 使用普通字典,在频次降为零时
del对应键; - 或者使用列表/数组统计固定字符集时,另维护一个 non_zero 计数器;
- 千万不要依赖
len(defaultdict)判断不同字符数量,除非你已经清理了零值。
5.4 左右指针移动顺序怎么记住
我自己的口诀:右指针负责“加”,左指针负责“减”,每次移动后立刻更新窗口状态,不要让指针移动和状态更新错开。右指针移动后,窗口内多了一个元素,要先把这个元素纳入统计,再判断是否需要收缩;左指针移动前,窗口内要移除一个元素,先更新统计再移动指针也行,但必须保证最后 left 指向窗口的第一个有效元素。顺序错了,窗口表示的范围就乱了。
5.5 实测排查步骤
如果滑动窗口题目提交后某个用例过不了,我是这样排查的:
- 在每次右指针移动后打印窗口的范围、窗口状态和当前答案;
- 检查状态更新的结果是否与“手动展开窗口”的计算一致;
- 检查收缩循环结束后,left 是否指向一个合法位置,窗口长度是否为 right-left+1;
- 再用一个小的极端用例(比如空数组、单元素数组、窗口长度大于数组长度、所有元素相同)跑一遍。
这些步骤听着笨,但比盯着代码干想有效得多。尤其是边界用例,滑动窗口几乎每个边界条件都可能藏坑。
6. 进阶:滑动窗口与数据结构组合的解题思路
到这里基本覆盖了滑动窗口的主干。但实际题目不会只考裸窗口,它常和哈希表、单调队列、前缀和等组合在一起。我补充几种常见组合,方便你遇到复杂题时有的放矢。
6.1 滑动窗口前缀和:解决带负数的问题
前面说过,滑动窗口依赖单调性,带负数的数组会让和不再单调。这时可以把问题转成前缀和数组:对前缀和数组做滑动窗口,或者用哈希表缓存前缀和的位置。比如求“和为 target 的子数组个数”,经典解法是边遍历边计算当前前缀和,并用哈希表记录每个前缀和出现的次数。它和滑动窗口看着像,内部逻辑完全不同,但解题思路有交叉:都是利用连续区间的累积性质来降低复杂度。
6.2 滑动窗口单调队列:处理最值更新
定长窗口求最大值用单调队列,这是两个结构的经典组合。变长窗口如果也需要动态维护窗口内的最值,同样可以用单调队列,但要注意队列元素和窗口边界的联动。比如求“每个长度至少为 k 的窗口内最大值与最小值的差”,你可以同时维护一个递增队列和一个递减队列,两个队列的队头差就是窗口的差值。这种组合题比裸滑动窗口难,但结构上是“外层窗口移动 + 内层队列维护”的叠加。
6.3 滑动窗口二分答案:适合“最大值最小化”类问题
有些问题看似不是滑动窗口,比如“把数组分成若干连续段,每段和最大值最小”,这类二分答案题里经常用到滑动窗口做 check 函数:给定一个候选上限,用窗口统计是否能在不超过上限的情况下完成分段。这种跨界组合近年出现频率挺高。我见过不少同学卡在二分答案的 check 函数里,其实待 check 的常常就是滑动窗口能解决的连续段约束问题。
7. 一些实操体会与细节补充
这部分没有固定顺序,想到哪写到哪,但都是我真实用过之后觉得值得记录的细节。
7.1 活用“先移动再判断”与“先判断再移动”
定长窗口里通常先形成窗口再判断,所以有r >= k - 1的启动条件;变长窗口里,右指针每走一步都要先纳入元素再判断合法性。这两种趋势如果弄反,会让窗口的初始状态出错。
一个小技巧是:在纸上把窗口的区间写清楚。我用[left, right]表示左右指针都指向有效元素。处理字符时,窗口包含s[left]到s[right]的所有字符,长度是right-left+1。一旦把这个约定固定,后面所有代码的下标和长度计算都不会乱。
7.2 数组越界的典型场景
访问nums[left]时必须保证 left 小于等于 right。收缩循环里如果 left 已经等于 right,依然执行 left += 1,可能让 left 跑到 right 后面。这种情况下,需要在收缩后重新判断窗口是否为空,比如加一个if left > right: continue或者用while left <= right and condition兜底。
7.3 Python 性能的细微差别
在用 Python 刷这类题时,Counter 和 defaultdict 用起来方便,但频繁的键删除和重建有额外开销。如果字符集是固定的(比如英文字母),直接用长度为 26 或 128 的 list 作为计数表,速度会明显提升。另外,len(freq)判断种类数时,删除零值键的必要性我在前面已经强调过,这里再重复一次,因为这是我见过最多人踩的坑。
7.4 不要迷信模板
网上很多滑动窗口模板把代码写得非常抽象,统一用一个 while 加一个 for。模板有它的价值,但如果你不理解窗口状态的业务含义,套模板很容易在条件判断上翻车。我更建议你在理解“为什么右指针必须动、为什么左指针可以放心收缩”之后再去看模板,把模板当成沟通语言,而不是解题拐杖。
8. 从子数组到子串:滑动窗口的思维迁移
把数组题的滑动窗口思想迁移到字符串题上,关键变化是统计对象从“数值和”变成了“字符频次”“种类数”“最长重复字符数”。数据结构从简单变量变成哈希表、计数器,但整体框架没变。你在学习过程中如果能把数组和字符串问题放在一起对比,会发现它们其实是同一套骨骼,只是血肉不同。
8.1 一组对照参考
| 问题类型 | 窗口维护的状态 | 收缩触发条件 | 典型复杂度 |
|---|---|---|---|
| 最短子数组和 ≥ target | 窗口和 | 窗口和 ≥ target 时收缩找更短 | O(n) |
| 最长无重复字符子串 | 字符最近位置/集合 | 出现重复字符时收缩 | O(n) |
| 最多 k 种不同字符 | 字符频次表 | 种类数 > k 时收缩 | O(n) |
| 定长窗口最大值 | 单调队列 | 队头滑出窗口时弹出 | O(n) |
| 最大连续 1(可翻 k 个 0) | 窗口内 0 的个数 | 0 的个数 > k 时收缩 | O(n) |
这张表我建议你自己照着默写一遍,把每个问题的“窗口状态”和“收缩触发条件”讲清楚,比反复刷十道同类型题更有用。
8.2 把窗口思想用在项目里
实际业务里,连续子数组问题可以出现在很多地方:交易系统统计最近 N 笔交易的最大回撤区间、监控系统统计过去一小时内的失败率峰值、推荐系统统计用户连续活跃时段的指标变化。这些场景的数据规模都不小,滑动窗口的 O(n) 处理方式比暴力循环或每次全量重算更实用。我做过一个监控告警模块,需求是统计最近 5 分钟内超过阈值的连续告警片段,当时就是用定长滑动窗口加最大值队列实现,内存开销和计算耗时都比重新扫描全量数据低一个数量级。
8.3 一道综合性问题练手感
如果你想检验自己是否真的理解了,可以试试这道综合性问题:给定一个数组 nums 和整数 k,返回 nums 中所有长度为 k 的子数组中,满足“子数组内最大值和最小值的差不超过 limit”的最长子数组长度。注意,这里窗口长度本身不固定,但子数组的最值和长度都要满足条件。
这类题需要同时维护窗口内最大值和最小值,自然想到两个单调队列。收缩逻辑是:只要“最大值减最小值”超过 limit,就移动左指针,并且从两个队列中弹出离开窗口的下标。整体依然 O(n)。这道题如果能在 20 分钟内写出来,说明你对滑动窗口、单调队列、窗口边界都已经掌握得比较扎实。
9. 最后再分享一个我私藏的调试技巧
我在本地写滑动窗口代码时,常常会加一个简单的断言函数,手动模拟窗口内容,和代码维护的统计值做对照。比如在每次右指针移动后,重新计算一下nums[left:right+1]的和,看是否等于代码里的 total。虽然这个做法的耗时无法用于生产环境,但调试阶段能快速抓到“状态更新和指针不同步”的 bug。
还有一个更实用的习惯:写变长窗口时,把“窗口合法”的判断逻辑单独抽成一个is_valid(state)函数。这样做有三个好处:一是代码可读性更好,二是遇到需要变化条件时只改一个函数,三是方便打印日志定位问题。这个习惯帮我省了很多调试时间。
坦白说,滑动窗口不是那种看一遍教程就能彻底掌握的技能。我初学的时候也经历过“每道题都套模板,但套完不对,看了题解恍然大悟”的循环。后来我发现,真正让我进步的不是多刷题,而是每次做错后都回到“窗口状态”和“收缩触发条件”这两个基本问题上,把问题彻底想清楚再写代码。如果你也卡在某个滑动窗口题目上,不妨停一下,先把窗口的语义和收缩条件写在纸上,再动手。这个习惯一旦养成,后面遇到再复杂的连续子数组问题,你都能很快找到解题的切入点。