滑动窗口这块内容,我在力扣上反复刷了三轮才算是真正搞明白。第一次是照着题解抄,抄完就忘;第二次是自己硬写,写出来的代码又长又臭;到了第三轮,我才摸到这套解法的门道。回头看,滑动窗口真不算难,难的是你什么时候能想到用它、以及怎么把窗口收缩的边界条件理清楚。这篇复盘就把我踩过的坑、总结出来的模板、还有几个经典题的拆解一次性说透,希望能帮你少走点弯路。
1. 滑动窗口到底在解决什么问题
1.1 先从暴力解法的痛点说起
先想一个最简单的场景:给定一个数组和一个目标值,找出数组中和大于等于目标值的最短连续子数组。如果你不看任何算法技巧,第一反应肯定是暴力枚举——把所有连续子数组都找出来,分别求和,筛出符合条件的,再比谁最短。
这个思路没错,但复杂度是 O(n²)。数组长度一上来,比如十万、百万级别,基本就跑不动了。你可能会说,可以加个前缀和优化,把求和变成 O(1),但枚举子数组本身还是 O(n²),总复杂度没变。
滑动窗口解决的,正是这一类"连续子区间 + 最值或计数"的问题。它的核心想法很简单:窗口的左右边界都只往前走,不回退。右边界负责扩张,把新元素纳入窗口;左边界负责收缩,把不再符合条件的元素踢出去。整个过程像一条毛毛虫在数组上爬,头往前走,尾跟上,一次遍历就能做完。
这一下把 O(n²) 降到了 O(n)。别小看这个优化,实际刷题时,很多看似是"子数组"的题目,只要让你求的是"连续区间"的某个性质,大概率就是滑动窗口的菜。
1.2 什么样的题一眼就能认出是滑动窗口
这是我刷题最大的收获之一:学会识别题型比记住解法更重要。我把能套滑动窗口的题分为两类,你在读题时留意这几组关键词就好。
第一类,题目里明确出现了"连续子数组(subarray)"或"连续子串(substring)",并且要求你求满足某个条件的最长、最短或数量。比如"无重复字符的最长子串"、"和大于等于 target 的最短子数组"、"至少有 K 个重复字符的最长子串"。
第二类,题目给你一个固定长度的窗口,让你在这个窗口里求最大值、最小值、平均值之类的统计量。比如"大小为 K 的窗口的最大和"、"滑动窗口最大值"(这道题力扣标 Hard,但只要掌握了单调队列的思路,其实挺套路的)。
换句话说,只要问题涉及连续区间,而且区间的端点移动是有方向的(往右走),滑动窗口基本都能上。如果区间可以任意乱序、或者让你求的是离散元素的组合问题,那就该想别的招了。
判断标准我记得很牢:连续 + 区间 + 最值/计数 → 优先想滑动窗口。
2. 一套模板吃透滑动窗口
2.1 可变窗口的标准骨架
滑动窗口的代码写多了之后,你会发现它就是一个固定的骨架,往里面填空就行。我整理了一个通用模板,Python 和 Java 都能套,先给你看 Python 版本:
def sliding_window(nums, k): n = len(nums) left = 0 window = {} # 或者用数组/变量维护窗口状态 result = 0 for right in range(n): # 1. 扩张窗口:把 nums[right] 纳入窗口 window[nums[right]] = window.get(nums[right], 0) + 1 # 2. 收缩窗口:不满足条件时,left 右移 while not condition(window): window[nums[left]] -= 1 if window[nums[left]] == 0: del window[nums[left]] left += 1 # 3. 更新答案:此时窗口满足条件,记录结果 result = max(result, right - left + 1) return result这个模板的关键在于三件事:什么时候扩张、什么时候收缩、什么时候更新答案。很多初学者死记模板,却搞不懂这三步的顺序,一换题就懵。
我的经验是,先想清楚"窗口满足什么条件",再想"什么时候需要收缩"。比如"无重复字符的最长子串",条件是窗口内所有字符都不重复;当右边界加进来的字符导致重复时,就要收缩左边界,直到重复消失。收缩完成后,窗口自然满足条件,此时窗口长度就是一个可行解,更新答案即可。
2.2 固定窗口的思路差异
固定窗口稍微有点不一样。固定窗口的意思是窗口长度从一开始就是确定的,比如"每个长度为 k 的子数组的最大和",你不需要条件判断去收缩窗口,只需要在窗口大小超过 k 时,left 跟着 right 一起往前走。
def fixed_window(nums, k): n = len(nums) left = 0 window_sum = 0 result = 0 for right in range(n): window_sum += nums[right] # 扩张 if right - left + 1 > k: # 窗口超长,收缩 window_sum -= nums[left] left += 1 if right - left + 1 == k: # 窗口恰好 k 长度,更新答案 result = max(result, window_sum) return result固定窗口的套路更简单:扩张、超长收缩、恰好长度更新。不用 while,用 if 就行,因为每次右指针移动一格,窗口最多超长一格。
不过我这里要特别提醒一点:可变窗口和固定窗口的核心区别在于收缩条件。可变窗口的收缩条件通常跟题目要求有关(比如无重复、和的大小);固定窗口的收缩条件只有一个,就是"窗口长度超了"。你做题之前先判断是哪种,再套模板,正确率会高很多。
3. 必刷经典题拆解:从最长无重复到最小覆盖
3.1 无重复字符的最长子串(LeetCode 3)
这道题可以说是滑动窗口的入门必修课。题目不难,但信息量很大。给定一个字符串 s,找出其中不含重复字符的最长子串的长度。
我的解题思路是这样:用一个字典 window 记录每个字符在窗口内出现的次数,right 指针遍历整个字符串,每遇到一个字符就加进窗口。加进去之后,如果这个字符的出现次数大于 1,说明窗口内有重复,那么 left 就右移,同时把移出窗口的字符计数减一,直到重复消除。
def lengthOfLongestSubstring(s: str) -> int: left = 0 window = {} result = 0 for right in range(len(s)): c = s[right] window[c] = window.get(c, 0) + 1 while window[c] > 1: d = s[left] window[d] -= 1 left += 1 result = max(result, right - left + 1) return result注意这里的 while 条件是"当前字符出现次数大于 1",而不是"窗口内有任意重复字符"。这两种写法等价,但前者判断起来更直接,因为你只需要盯着刚加入的字符就行。
我一开始在这个地方犯过糊涂,想着要不要每个字符都检查一遍有没有重复。后来想明白了:窗口内本来是没有重复的,加入新字符后,如果产生了重复,那一定是新字符带来的。所以只需要判断新字符的计数是否大于 1 就够了。
3.2 长度最小的子数组(LeetCode 209)
这道题是滑动窗口的另一个经典入门题,和上面那道一左一右,正好对应"最短"和"最长"两种方向。
题目:给定一个正整数数组 nums 和一个正整数 target,找出该数组中满足其和大于等于 target 的长度最小的连续子数组,并返回其长度。
思路正好反过来:right 扩张加和,窗口内的和一旦大于等于 target(满足条件),就尝试收缩左边界,看看能不能在仍然满足条件的情况下让窗口更短,收缩到不满足为止,然后记录收缩前的长度。
def minSubArrayLen(target: int, nums: List[int]) -> int: left = 0 window_sum = 0 result = float('inf') for right in range(len(nums)): window_sum += nums[right] while window_sum >= target: result = min(result, right - left + 1) window_sum -= nums[left] left += 1 return result if result != float('inf') else 0注意这里有个细节:更新答案要放在收缩循环里面,而不是收缩完成后。因为收缩完成时窗口已经不再满足条件了,你记录的是"最后一次满足条件的长度"。
很多人写这道题的时候,会把 result 的更新写在 while 外面,结果发现求出来的不是最短长度,而是最短长度加一或者其它奇怪的值。这个坑我踩过,印象特别深。记住一句话:保证窗口满足条件的时候才去更新答案,如果窗口本身已经不满足条件了,更新出来的数据一定不对。
这两道题一对比,你就能琢磨出滑动窗口的"度":一道是"窗口内出现异常就收缩到正常",另一道是"窗口内满足了就试试能不能再缩小"。方向相反,但骨架完全一致。
3.3 最小覆盖子串(LeetCode 76)
这道题是 Hard 难度,但用滑动窗口加计数器其实不难。题目要求:给你一个字符串 s 和一个字符串 t,返回 s 中涵盖 t 所有字符的最小子串。
这里的难点在于你怎么判断"涵盖了 t 的所有字符"。我的方案是维护一个 need 字典记录 t 中每个字符需要的次数,再用一个变量 need_cnt 记录还有多少种字符没满足。
def minWindow(s: str, t: str) -> str: from collections import Counter need = Counter(t) need_cnt = len(need) left = 0 result = "" min_len = float('inf') for right in range(len(s)): c = s[right] if c in need: need[c] -= 1 if need[c] == 0: need_cnt -= 1 while need_cnt == 0: if right - left + 1 < min_len: min_len = right - left + 1 result = s[left:right+1] d = s[left] if d in need: if need[d] == 0: need_cnt += 1 need[d] += 1 left += 1 return result核心逻辑:need_cnt 归零说明所有字符类型都齐了,此时窗口是一个可行解。尝试收缩左边界,如果左移的字符恰好是某个需求已经满足的字符,need_cnt 就要加一(意味着这种字符不够了),循环退出,继续扩张右边界找下一个可行解。
这道题的启发在于:窗口的"条件"不一定是一个简单的计数,它可以是一个多字段的字典。你只要保证条件判断和收缩逻辑是同步更新的,窗口就能始终维持正确状态。
我做题时发现,很多同学把 Hard 题想得太复杂,其实力扣的 Hard 题放在滑动窗口这个标签下,大部分都是"套模板 + 状态多维护一些"的模式,没有想象中那么难。
4. 进阶技巧:滑动窗口 + 单调队列
4.1 为什么普通滑动窗口搞不定最大值
如果题目只是让你求窗口内元素的和,那用一个变量就能搞定。但如果让你求的是窗口内的最大值,而且窗口是滑动着的,问题就来了:窗口滑走一个元素,你怎么知道剩下元素的最大值是多少?
最笨的办法是每次扫描一遍窗口,那样复杂度是 O(n×k),k 是窗口长度。要是 k 接近 n,复杂度又退化成了 O(n²),这就失去了滑动窗口的意义。
解决办法是在滑动窗口基础上引入一个单调队列(deque),专门用来维护窗口内的最大值。队列里的元素按照从大到小的顺序排列,队首就是当前窗口的最大值。
4.2 单调队列的实现细节
LeetCode 239 这道题是滑动窗口 + 单调队列的经典代表。题目:给你一个整数数组 nums 和一个滑动窗口大小 k,找出所有滑动窗口里的最大值。
我的做法是维护一个双端队列,队列里存的是元素的下标,而不是元素本身。为什么存下标?因为下标能告诉我们队列里的元素是否还在窗口内,方便过期淘汰。新元素入队时,先弹出所有值比它小的队尾元素,再把新元素下标压入队尾。队首元素如果已经滑出窗口(下标小于等于 i-k),就弹出。
from collections import deque def maxSlidingWindow(nums: List[int], k: int) -> List[int]: dq = deque() result = [] for i in range(len(nums)): # 新元素入队,弹出队尾所有比它小的元素 while dq and nums[dq[-1]] <= nums[i]: dq.pop() dq.append(i) # 队首元素滑出窗口,弹出 if dq[0] <= i - k: dq.popleft() # 窗口满 k 个,记录答案 if i >= k - 1: result.append(nums[dq[0]]) return result为什么要把比新元素小的队尾元素全部弹出?因为只要这些更小、更老的元素还在窗口里,新元素永远比它们有资格当最大值;一旦它们滑出窗口,新元素可能还留在窗口里。所以它们注定永远不会成为窗口最大值,留之无用。
我一开始写这道题时,队列里存的是元素值,结果在判断"队首是否过期"的时候总是出问题。后来改成存下标,逻辑一下就顺了。这里也分享给大家:涉及"是否在窗口内"的判断时,队列里存下标是更稳的做法。
5. 常见问题与排查技巧实录
5.1 死循环问题:left 没动
滑动窗口最容易出的 bug 是死循环,具体表现是 right 一直在扫描,但 left 不往前走,while 循环里判断条件永远为真,程序卡死。
这种 bug 十有八九是收缩分支里忘了处理窗口数据。比如你用字典维护窗口,left 右移时只做了left += 1,没把window[nums[left]]减掉。那窗口内数据一直是旧的,条件永远满足不了,left 卡在原地。
排查思路很简单:在 while 收缩循环里打印窗口状态,看看 left 移动前后窗口内的计数有没有同步变化。我之前排查一个问题时发现窗口内的计数比实际元素数还大,好几根指针错位,就是收缩分支少写了一行。
5.2 边界条件:窗口初始化和空输入
力扣的用例永远比你想的刁钻。空数组、空字符串、k 大于数组长度、target 是 0、数组里有负数……每一个边界条件都可能让你的代码暴毙。
拿 209 那题来说,如果 target 是 0,while window_sum >= target 永远为真,left 会一路狂奔到数组末尾,最终 output 0。但题目说 target 是正整数,所以还好。可如果你是拿这道题练手,最好在代码开头加一个if not nums: return 0的防御。
别觉得加防御判断丢人。力扣评论区都是比谁代码短,但日常工程里,你写的算法迟早要处理脏数据。先把边界处理干净,再谈优化。
5.3 别忘了"更新答案的位置"
这个问题太典型了,以至于我想单独拿出来说。很多滑动窗口题的解法都长得很像,但答案更新的位置各不相同。有些题在收缩完成后更新,有些题在收缩过程中更新,有些题在每次 right 移动后更新。
判断标准只有一个:你更新答案时,窗口状态是否一定是"合法"的?209 那题,窗口收缩后不合法了,所以必须在收缩循环内更新;3 那题,窗口收缩后一定合法了,所以可以在收缩循环外更新。
遇到新题时,先问自己这个问题,答案的更新位置就清楚了。我在刷题过程中发现,很多人的代码逻辑都对了,就是答案位置放错导致结果偏执,这一条是最常见的低级错误。
5.4 字典计数 vs 数组计数:性能差距
如果窗口里的元素是字符串字符,用字典没问题。但如果元素是整数,而且你知道取值范围(比如 0 到 100),用数组当计数器会比字典快一个数量级。
# 假设元素范围是 0~100 count = [0] * 101 # 添加 count[val] += 1 # 删除 count[val] -= 1数组访问是 O(1) 且常数极小,字典虽然也是 O(1),但哈希运算的常数大很多。力扣刷题时,一道题如果时间卡得紧,把字典换成数组往往能救你一命。华为 OD 机试那种环境尤其如此,时间卡得很死,性能优化不能不做。
5.5 一个实用的自测技巧
写完代码别急着提交,先用小样例手跑一遍。我个人的习惯是拿一个nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3这种样例,手动推演一遍窗口的变化过程,把每一步的 left、right、窗口状态、答案都写出来,再和自己的代码对比。
这个习惯帮我提前发现了很多逻辑错误,省了不少提交失败的尴尬。刷题讲究的是手感,手感就是靠这种反复手推积累起来的。
6. 我的实战心得与后续做题建议
这个章节我想跟你分享一些超脱具体题目的思考。滑动窗口不是一个孤立的知识点,它其实是"双指针"技巧在连续区间问题上的应用,理解它的本质,对你做其他题会有帮助。
双指针的核心思想是利用数据的单调性减少不必要的遍历。滑动窗口依赖的单调性是:随着右指针向右移动,窗口覆盖的区间只会变大,窗口内元素只会变多。所以当你把一个元素纳入窗口后,就不需要回头再处理它了,每个元素最多被处理两次(进一次,出一次),这才有了 O(n) 的复杂度保证。
理解了这个本质,很多变体题你就知道怎么做了。比如"含有最多两个不同字符的最长子串"、"每个元音包含偶数次的最长子字符串"(这题还牵扯到状态压缩,比较进阶)等等,核心都是维护一个窗口状态,然后按模板走。
做完了力扣的滑动窗口标签题,我还推荐你去看看"前缀和 + 哈希表"的组合。这两兄弟经常交替出现,有些题看起来像滑动窗口,其实是前缀和做的;有些题用滑动窗口反而别扭,换前缀和一下就顺了。这里我不展开,但建议你刷题时把它们放在一起对比着学。
最后分享一个小技巧。如果你刷了一会儿滑动窗口的题觉得头晕脑胀,别硬扛。打开记事本,画一条线段代表数组,用两个游标模拟 left 和 right,亲手推一遍窗口的变化过程。这个简单的动作,比你看十道题解都管用。我刷 LeetCode 239 的时候,就是用这个方法一步步推,才彻底弄懂了单调队列为什么能保持队首是最大值。动手永远比动眼有效。