滑动窗口和双指针,在LeetCode上几乎是一对形影不离的考点。我刚开始刷题的时候,总觉得它们是两套独立的东西:滑动窗口是一套,双指针又是另一套。刷到后面才反应过来,这两个名字背后其实是同一个核心思想——用两个指针维护一个区间,在遍历过程中不断调整区间的范围和状态,从而把暴力枚举需要O(n²)甚至O(n³)的时间复杂度,压到O(n)的线性级别。
这篇文章是我刷题系列的第一篇,打算把滑动窗口和双指针整个脉络彻底捋一遍。定长窗口、不定长窗口、单序列双指针、双序列双指针、三指针、分组循环,这几类问题放一起对比着看,比一个个题孤立地刷效率高得多。内容面向刚接触算法题、还在靠记忆模板硬刷的朋友,也面向已经刷了一些题但对边界条件总没把握的读者。我会把每类问题的套路模板、适用场景、底层原因,以及调试中容易踩的坑都写清楚,尽量做到看完能直接上手复现,而不是停留在"看懂了但不会写"的状态。
1. 滑动窗口与双指针,到底在解决什么问题
1.1 从暴力枚举到指针移动
先想清楚一个问题:滑动窗口本质上在优化什么?答案是枚举的过程。
举个例子,找数组里所有长度为k的子数组,要求所有子数组中元素和的最大值。最直观的做法是两重循环:外层定起点,内层累加k个元素,每次计算都重新从起点开始加一遍。这个方法在任何教科书里都能跑通,但问题是它的时间复杂度是O(nk)。如果n是10万,k是5万,那就是50亿次加法,放到真实场景里显然不现实。
滑动窗口的想法很朴素:窗口每向右移动一格,右边多进来一个数,左边少出去一个数,窗口内的和只需要在上一轮的基础上做一次减法和一次加法。这样整个数组从头扫到尾,每个位置只被访问了常数次,时间复杂度一下子变成了O(n)。
这个优化的本质,就是两个字——复用。上一轮已经算过的信息,不要丢掉,而是通过指针的移动增量更新。理解了这一点,后面所有窗口类问题都能串起来。
双指针也是同一个道理。经典的两数之和(有序数组版本)里,左右指针分别从数组两端往中间走,根据当前和与目标值的大小关系决定移动哪一侧。每次移动,排除的是一整批不可能产生答案的组合,而不是一个一个去试。这本质上也是一种"复用已扫描信息、用指针位置剪枝"的思路。
1.2 为什么时间复杂度能降到O(n)
很多人记住了"滑动窗口是O(n)"这个结论,但不理解为什么。这里用一个最简单的摊还分析说明白。
在滑动窗口里,有两个指针left和right。right指针从头到尾只会往右移动,最多移动n次;left指针也是从头到尾往右移动,最多也移动n次。两个指针加起来,最多移动2n次,每次移动做的操作是常数级的,所以总复杂度是O(n)。
关键点在于,left指针和right指针都不会回退。也就是说,窗口内的每个元素,最多被right加进来一次,最多被left移除一次。这就是摊还分析的思想——把看似反复的增减操作,摊到每个元素头上,每个元素只摊到常数次操作。
这一点理解了,很多面试题就会变得非常简单。比如面试官问"这个窗口能不能做到O(n)",你只要说出"每个元素最多进一次出一次",就已经比其他只会背模板的人高一个层次了。
1.3 什么时候用滑动窗口,什么时候用双指针
这个问题是我刷题初期最疑惑的地方。后来我总结了一个比较实用的判断标准:
- 问题涉及连续子数组或连续子串,要求满足某个条件(最大值、最小值、目标值、字符种类限制等),优先考虑滑动窗口。
- 问题涉及两个位置的配对、合并两个有序序列、判断回文等,优先考虑双指针。
- 问题里出现"连续""子数组""子串""覆盖""最长""最短"这些关键词,滑动窗口的概率非常大。出现"有序""配对""三数之和""去重"这些关键词,双指针的概率非常大。
还有个常见的情况是,同一道题可以同时用两种思路解出来,只是细节不同。比如最长无重复字符子串,用滑动窗口是标准解法,但本质上也依赖两个指针维护窗口边界。所以我在刷题的时候,不太纠结题目归到哪一类,而是先判断"是否可以用两个指针维护一个区间",如果可以,再进一步思考是定长还是不定长,是同向还是相向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定长滑动窗口:模板最固定,套路最好学
2.1 定长窗口的标准模板
定长滑动窗口是整套体系里最好上手的,因为它模板固定,不需要纠结什么时候收缩窗口——窗口长度固定,每次移动一步,删左边加右边,循环到结束。
我常用的模板是这样:
python复制def fixed_window(nums, k):
n = len(nums)
if n < k:
return 0 # 或者根据题意返回异常值
# 初始化:先构建第一个窗口
window_sum = sum(nums[:k])
ans = window_sum
# 滑动窗口
for i in range(k, n):
window_sum += nums[i] # 右边新元素进窗口
window_sum -= nums[i - k] # 左边元素出窗口
ans = max(ans, window_sum) # 更新答案
return ans
这套模板的核心思路是"先建窗口,再滑窗口"。
第一步要先手动算第一个窗口的状态,作为起点。第二步才是循环逐个移动。新手最容易犯的错就是把初始化漏掉,直接进循环,结果第一个状态根本没被记录。
关于这个模板,有两点值得注意。一是窗口长度k不一定是题面直接给的数值,有可能是可以推导出来的。比如固定字符集大小为26,那窗口长度就是26,你只需要不断滑动统计每个窗口的某个指标。二是代码里window_sum += nums[i]和window_sum -= nums[i - k]这两步的顺序。这里先加后减,但顺序其实无所谓,因为加减的对象互不影响。不过从可读性角度,建议先加后减,语义更顺。
2.2 窗口维护时的细节与坑
定长窗口看起来简单,但细节踩起来一样让人头大。
第一个坑是数组长度不足k的情况。比如nums长度只有3,k却是5,这时候窗口根本建立不起来,必须提前判断返回空值或默认值。不写这个判断,sum(nums[:k])虽然不会报错,但结果会少算,因为Python切片不会因为越界报错。
第二个坑是更新答案的时机。定长窗口的答案更新一定要在每次窗口完整维护好之后进行,而不是在元素进出窗口的半路上。举个例子,如果先加右边元素就更新答案,再减左边元素,那这次更新用的是一个长度k+1的窗口,统计结果就错了。这种错误在写的时候不容易发现,调试一遍逻辑题时才会暴露。
第三个坑是需要注意窗口里维护的是什么信息。不一定都是简单的元素和,也可能是频率统计、不同的字符数、最大值、最小值、中位数等等。信息越复杂,维护成本越高,这也决定了题目的难度上限。比如LeetCode 239滑动窗口最大值,就是维护窗口内的最大值,如果每次重新扫描一遍,复杂度是O(nk),只有用单调队列维护,才能做到O(n)。这道题我在后面单独讲。
2.3 典型题拆解:子数组最大平均数与定长子串元音
先看LeetCode 643,子数组最大平均数I。这道题要求找长度为k的连续子数组,使其平均值最大。平均值最大等价于和最大,所以直接用上面的模板,求长度k的子数组最大和,最后除以k就行。
我写这段代码的时候顺手做了一个优化:不需要真的求出所有窗口的和再算平均值,直接在整个过程中维护最大和,最后一步统一除以k,能省一点精度误差的烦恼。
再看LeetCode 1456,定长子串中元音的最大数目。题目给一个字符串s和一个整数k,要你找出长度为k的子串中,元音字母数量最多是多少。
这道题的窗口内维护的信息变成了元音计数。初始化时先统计第一个窗口的元音数,然后滑动窗口,每移动一格,检查新进入的字符是不是元音,是就加一;检查离开的字符是不是元音,是就减一。更新答案取最大值。
这里我踩过一个很经典的坑:只考虑了新字符进入窗口,忘了处理离开窗口的字符。结果窗口越滑越长,元音计数越积越大,最后答案严重偏大。所以在维护窗口的时候,一定要养成"有进必有出"的条件反射。
3. 不定长滑动窗口:最长与最短的分类处理
3.1 最长类问题的套路
不定长滑动窗口的难点在于,窗口长度不固定,所以需要根据条件动态调整。而调整的方向取决于你是要找最长还是最短。
最长类问题是最常见的一类,典型代表是LeetCode 3无重复字符的最长子串。这类问题的通用套路是:右指针不断向右扩展,扩大窗口,直到窗口不满足条件,然后左指针向右收缩,缩小窗口,直到条件重新满足。在这个过程中,每次右指针扩展后,窗口都是当前右端点下的"最长合法窗口",所以随时记录窗口长度即可。
模板大概长这样:
python复制def longest_window(s):
n = len(s)
window = set() # 或字典
left = 0
ans = 0
for right in range(n):
# 尝试加入s[right]
while s[right] in window: # 条件不满足,收缩
window.remove(s[left])
left += 1
window.add(s[right])
ans = max(ans, right - left + 1)
return ans
注意这里有一个问题:为什么是while而不是if?因为左指针可能一次缩不到底,需要连续移除多个元素才能恢复条件。比如"abca"这个例子,右指针到第二个'a'时,窗口里已经有'a'了,需要把左侧的'a'及其左边所有字符全部移出去,才能让窗口重新合法。这个"连续收缩"的逻辑用if写大概率会出问题,后面排查的时候容易掉进死循环。
不过说实话,用set处理无重复字符这种场景可以,但一旦题目要求维护窗口内字符频率、种类数、不同元素个数等复杂状态,set就不够用了,要考虑用字典collections.Counter。
3.2 最短类问题的套路
最短类问题比最长类要绕一些。典型代表是LeetCode 76最小覆盖子串,要求找出包含t中所有字母的最短子串。
最短类的逻辑刚好反过来:右指针不断向右扩展,直到窗口"满足条件",然后记录答案,再收缩左指针,尝试让窗口更短。如果收缩后条件仍满足,就继续收缩并更新答案;直到条件不满足,再重新扩展右指针。
模板:
python复制def shortest_window(s, t):
need = collections.Counter(t)
window = collections.Counter()
left = 0
matched = 0
min_len = float('inf')
start = 0
for right in range(len(s)):
c = s[right]
window[c] += 1
if window[c] == need[c]:
matched += 1
while matched == len(need):
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
d = s[left]
if window[d] == need[d]:
matched -= 1
window[d] -= 1
left += 1
return s[start:start + min_len] if min_len != float('inf') else ""
这类题有一个核心技巧,就是用matched这个变量来跟踪"已经满足了几个字符种类"。这个技巧的好处是,你不用每次判断窗口是否覆盖t都去遍历字典,matched就是O(1)的判断。
注意收缩时判断条件的顺序:先判断window[d] == need[d]再执行window[d] -= 1,顺序不能反。因为如果先减一,window[d]已经变了,再和need[d]比较就不对了。这个顺序我写错过不止一次,后来就形成了肌肉记忆。
3.3 一道题看穿窗口收缩时机
LeetCode 209,长度最小的子数组,是理解窗口收缩时机非常好的例子。题目要求找和大于等于target的最短连续子数组,返回其长度。
这道题既不是最长也不是最短中那种条件极难判断的类型,逻辑非常清晰:右指针扩展,当窗口和大于等于target时,不断更新答案并收缩左指针,直到和小于target。
关键点在于,收缩和更新答案的顺序。我看到很多人写成:先更新答案再收缩,或者先收缩再更新答案。正确的是:每往左收缩一步,都会得到一个新的合法窗口,所以每收缩一步都应该更新一次答案。如果只在收缩前更新一次,就会漏掉收缩过程中更短的窗口。
看个例子,数组[2,3,1,2,4,3],target=7。右指针到索引3时,窗口[2,3,1,2]和为8,合法,长度为4。收缩左指针到索引1,窗口[3,1,2]和为6,不合法。再右移。右指针到索引4时,窗口[3,1,2,4]和为10,长度4,合法。收缩左指针到索引2,[1,2,4]和为7,长度3,合法,更新答案。再收缩到索引3,[2,4]和为6不合法。最终右指针到索引5,[2,4,3]和为9,长度3,收缩左指针到索引4,[4,3]和为7,长度2,更新答案。最终答案是2。
这个过程中你如果只在收缩前更新一次答案,第二次收缩到[1,2,4]长度为3答案就已经更新了,但最后一步收缩到[4,3]长度为2就丢了。所以"每收缩一步都尝试更新答案"这个习惯,一定要养成。
4. 单序列双指针与双序列双指针
4.1 相向双指针:有序数组、三数之和
相向双指针是双指针里最直观的一种形态。左右两个指针分别从序列两端出发,根据条件判断是左指针往右走还是右指针往左走。
最经典的例子是LeetCode 167,两数之和II输入有序数组。给定有序数组和目标值,找两个数使和等于目标值。左右指针分别指向第一个和最后一个元素,求当前两个指针的和。如果和大于目标值,说明右边的数太大了,右指针左移;如果和小于目标值,说明左边的数太小了,左指针右移。直到找到答案。
这套逻辑有一个非常漂亮的剪枝效果:每一步都排除掉了一整批不可能产生答案的组合。比如当前左右指针指向的元素和大于目标值,那意味着左指针不动的情况下,右指针左边的所有数跟当前左指针配,和只会更大,所以整批都被排除了。这正是双指针比暴力枚举高效的根本原因。
LeetCode 15三数之和,是两数之和的扩展版。要求找三个数,使和为0,且结果不能重复。做法是固定一个数,然后用相向双指针在内层数组上找两数之和。关键点在于去重:固定指针跳过相同值,双指针找到一组答案后,左右指针都要跳过相同的值,避免重复结果。
我第一次写三数之和的时候,只想着去重左指针和右指针,忘了固定指针也要去重,结果输出的答案里有大量重复三元组。后来总结一个规则:凡是用到排序+相向双指针的场景,去重必须三层都做,每一层的连续相同值都要跳过。
4.2 快慢指针:链表题与原地去重
快慢指针是双指针在单链表中最重要的应用,但在数组题里同样存在,最常见的代表作是LeetCode 26删除有序数组中的重复项。
这道题要求原地修改数组,用O(1)额外空间,让每个元素只出现一次,并返回新长度。解法是快慢指针:慢指针指向"已处理区域的末尾",快指针遍历整个数组。当快指针指向的元素和慢指针指向的元素不同时,说明遇到了新的元素,就把慢指针前移一位,把新元素放到慢指针位置。这样快指针不停地走,慢指针维护一个合法的前缀数组。
这个技巧还有一个很强的通用性,就是"原地去重的通用写法",我把它总结为:
python复制def remove_duplicates(nums):
if not nums:
return 0
slow = 0
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]:
slow += 1
nums[slow] = nums[fast]
return slow + 1
这里慢指针指向的是去重后数组的最后一个元素,所以返回长度是slow + 1。这个细节我第一次写的时候忘了加一,返回的是索引而不是长度,导致结果差一位。
快慢指针的题我建议把所有变体一起刷:删除有序数组重复项、移动零、移除元素、最长连续递增序列。这四道题本质上都是同一套逻辑,只是细节略有不同。放在一起刷一遍,快慢指针就基本吃透了。
4.3 双序列双指针:合并有序数组与链表
双序列双指针指的是两个独立的序列各有一个指针,通过比较两者指向的元素,决定下一步怎么走。最典型的题目是LeetCode 88合并两个有序数组,要求把nums2合并进nums1,nums1有足够的空间。
从前往后合并有一个问题:nums1的有效元素会被覆盖。所以正确做法是从后往前填。用三个指针:p1指向nums1有效部分的末尾,p2指向nums2的末尾,p指向nums1的最终末尾。每次比较p1和p2指向的元素,把较大的放到p位置,然后对应指针前移。这样从后往前填,不会覆盖nums1尚未处理的元素。
我在这里踩过一个坑,也是最常见的:p2已经先走完了,但p1还有剩余元素。这时候其实不用额外处理,因为剩下的元素已经在nums1的最终位置上。反过来,如果p1先走完,p2还有剩余,那就需要把nums2的剩余元素逐个填入nums1前面的位置。写代码时一定要把p2剩余这种情况处理掉,不然就会漏掉后半段数组。
合并两个有序链表(LeetCode 21)是同一个思路的链表版本。使用一个虚拟头节点dummy,然后两个链表各自的指针指向当前节点,比较大小,把较小的接到新链表后面。链表的好处是不用担心覆盖问题,因为每个节点都是单独的。
5. 三指针与分组循环:进阶场景的应对思路
5.1 三指针:荷兰国旗问题
三指针其实是双指针的自然扩展,适用于需要把数组划分成三个区域的问题。典型代表是LeetCode 75颜色分类,也被称为荷兰国旗问题。题目要求把包含0、1、2三类元素的数组原地排序,只能用常数空间和一趟扫描。
思路是维护三个指针:left、mid、right。left指向0区域的末尾,right指向2区域的开头,mid是当前遍历的指针。当mid遇到0时,交换nums[mid]和nums[left],然后mid和left同时右移;当mid遇到1时,mid直接右移;当mid遇到2时,交换nums[mid]和nums[right],然后right左移,但mid不动。
这里最难理解的是"遇到2交换后mid不动"这个细节。为什么?因为从right换过来的那个元素,我们还没看过,它可能是0、1或2,所以不能直接跳过,需要留在当前mid位置再判断一次。而遇到0交换后,因为left位置上的元素一定已经处理过了,所以交换完mid可以直接前进。
这道题我建议至少写三遍。第一遍照着参考代码写,第二遍关掉参考默写,第三遍尝试不看任何提示,从零推导三个指针的移动规则。只有做到第三遍,才算真正理解了"交换后mid是否需要移动"的整个判断链。
5.2 分组循环:找连续段的通用解法
分组循环是我刷题后期才系统总结的一个套路,但它解决了一大批疑难杂症。核心思想是:把数组按某种规律分成若干连续段,每次循环处理一整段,而不是处理单个元素。
最典型的应用是LeetCode 228汇总区间。题目要求把有序数组的连续数字区间汇总成字符串列表。
普通思路是遍历数组,判断当前元素和下一个元素是否连续,然后决定是暂时结束一个区间还是继续扩展。这个思路能写,但写出来的代码总是又臭又长,边界处理一大堆。
分组循环的写法干净得多:
python复制def summary_ranges(nums):
n = len(nums)
res = []
i = 0
while i < n:
start = i
while i + 1 < n and nums[i + 1] == nums[i] + 1:
i += 1
if start == i:
res.append(str(nums[start]))
else:
res.append(f"{nums[start]}->{nums[i]}")
i += 1
return res
外层while负责"起一段",内层while负责"把这段走完"。每次内层循环结束后,i正好停留在当前段的最后一个元素,然后i加一,进入下一段。整个过程天然就是O(n),因为i只向右走,内外层循环加在一起,每个元素只被访问常数次。
有了这个套路之后,我再看很多数组题都有了新视角。比如最长连续递增序列、单词分段、字符串分组等,都可以用"外层起组、内层走组"的框架改写。框架一旦建立,代码的出错率明显下降。
5.3 三指针与分组循环的实际应用
三指针和分组循环看起来好像是两回事,但在实际刷题中经常可以互相配合。比如LeetCode 80删除有序数组中的重复项II,要求每个元素最多出现两次。这道题可以用快慢指针写,也可以从分组循环的角度理解:找到一段重复区间,如果长度小于等于2就直接保留,大于2只保留前两个。
用分组循环的角度解题,好处是不用在每个字符的处理细节上纠结,而是把一整段当成一个整体来考虑。先统计当前段的长度,再根据长度决定保留多少元素到结果位置,操作起来非常清晰。
还有一类题目,比如"找出所有字符连续出现的位置"或者"按块翻转字符串",从分组循环的角度去思考,往往能想到比一次一个字符更优雅的解法。在刷题过程中,我习惯了先把题目扫描一遍,问自己一个问题:这个问题能不能按段处理?如果能,就优先考虑分组循环的框架,往往能避开很多边界问题的坑。
6. 刷题实录:常见套路问题与避坑指南
6.1 无限循环排查实录
滑动窗口和双指针写多了,最容易遇到的就是无限循环。最常见的原因有两个:左指针没有往前移动,或者右指针没有往前移动。
我遇到过最典型的案例是,在收缩窗口的时候,因为我用了if s[left] in window而不是while,导致左指针只移动一次就停下来了,而窗口仍然不合法,右指针又继续扩展,最终窗口变成全数组范围,程序跑出错误结果而不是死循环。
排查无限循环的最佳手段是:在代码里加一个计数器,限制循环执行次数。比如:
python复制t = 0
while left <= right and t < 10000:
t += 1
# 原本逻辑
如果程序跳到t的上限,说明循环条件出了问题。在本地调试时我会加这么一段,找到问题后再删掉。另一种方法是打印每次循环时left和right的值,肉眼观察两个指针是否都在往正确的方向移动。
6.2 窗口记录时机的常见错误
窗口类题目另一个高发错误是记录答案的时机不对。
最高频的错误有三种。
第一种,定长窗口忘记记录初始窗口状态。很多题的答案可能是第一个窗口就产生的,如果只从第二格开始记录,就会漏掉初始窗口。解决办法是初始化时就算一次答案,或者在循环开头就先记录,然后再滑动。
第二种,不定长窗口在收缩过程中忘记记录。这个问题在最短类问题里尤其严重,因为最短窗口可能出现在收缩的中间状态。解决办法是每次左指针移动后都尝试更新答案,而不是只在右指针扩展后更新。
第三种,记录的是"当前窗口长度"而不是"最优窗口的端点坐标"。有些题要求返回具体子串而不是长度,如果只记录长度,最后再根据长度去切子串,很容易因为窗口已经被移动而切错位置。更好的做法是记录最优窗口的起点和长度,最后根据这两个值去切。
6.3 边界条件的一页速查
我整理了一个自己常用的边界条件速查表,每次写滑动窗口或双指针前都会在脑子里过一遍:
| 场景 | 需要判断的边界 |
|---|---|
| 数组为空或长度为0 | 直接返回默认值 |
| 数组长度小于窗口k | 无法形成合法窗口 |
| 窗口左指针越过右指针 | 循环终止条件要包含left <= right |
| 双序列其中一个提前走完 | 处理剩余序列 |
| 三指针交换后mid是否需要移动 | 取决于交换过来的是否未处理过 |
| 分组循环内层循环到达数组末尾 | 避免数组越界访问 |
这个速查表是我刷了将近百道窗口与双指针题之后总结的,几乎每道题都能命中其中几行。刷题遇到边界错误时,先对着表格逐行排查,能省不少调试时间。
最后的实操体会
写到这里,我忍不住想分享一个自己刷题过程中的体会。滑动窗口和双指针表面上是一堆模板和套路,但真正决定你能不能举一反三的,其实是两件事:一是能不能理解每个指针为什么移动,而不是只会机械套模板;二是能不能在出错的时候快速定位问题,而不是靠重新看一遍参考代码来找答案。
我对自己的要求是:每道题做完之后,不看代码,把解题思路讲给人听。讲的过程里如果有一句卡住,说明那个地方理解得还不够透。比如讲滑动窗口收缩的时机,你得能说清楚"为什么此刻收缩、收缩到什么程度、收缩过程中要不要记录答案",这三个问题都答上来,模板才是真吃透了。
这套题型的上限不在模板本身,而在模板之外的变通能力。遇到条件复杂的题,试着把条件拆成"窗口需要满足的约束"和"窗口需要维护的信息"两部分,分别考虑,思路会清晰很多。希望这篇内容能帮你在滑动窗口和双指针这条路上少走一些弯路。
