☰
滑动窗口算法详解:从双指针原理到模板与实战避坑
2026/10/16 13:20:45 网站建设 项目流程

很多人学滑动窗口时都有一种感觉:看题解一看就懂,自己一写就废。明明就是两个指针移来移去,但什么时候移左边界、什么时候扩大右边界、窗口里要维护什么数据,总是搞不清楚。这篇文章我用最直白的方式,把滑动窗口这个技巧彻底讲透——它是干什么的、怎么写才不出错、怎么分析一道题到底能不能用滑动窗口,以及我自己实际刷题过程中踩过的那些坑。内容不堆概念,全程跟着代码和场景走,适合刚接触算法的读者,也适合想把这个技巧彻底梳理清楚的开发者。

滑动窗口本质上是一种“在数组或字符串上维护一段连续区间”的遍历策略。它解决的核心问题,是那些需要找“满足某种条件的子数组或子串”的题目,比如最长无重复字符的子串、最小覆盖子串、长度最小的子数组等等。这类问题如果用暴力枚举,往往是 O(n²) 甚至 O(n³) 的复杂度,数据量一上来就超时。滑动窗口能把大部分这类问题优化到 O(n),因为它让每个元素最多被左边界和右边界各访问一次,整体是线性扫描。

适合阅读这篇文章的人有两类:一类是刚开始刷算法题、对双指针和窗口概念还比较模糊的初学者;另一类是已经刷过一些题,但对窗口收缩时机、边界条件老是拿不准的进阶者。我会从零开始拆解原理,然后给出可以直接套用的模板代码,再用几道经典问题完整演示推导过程,最后整理一份排查问题和避坑的清单。读完你会发现,滑动窗口没有想象中那么玄,它就是一种“有纪律的枚举”。

1. 滑动窗口的本质与使用前提

1.1 从暴力枚举到窗口滑动的优化逻辑

先看一个最简单的场景:给定一个整数数组,找出和为某个值的最短连续子数组。暴力做法是枚举所有左端点和右端点,然后对区间求和,判断是否满足条件。这个做法的复杂度是 O(n³)(枚举左右端点 O(n²) + 求和 O(n)),就算用前缀和优化求和,也还有 O(n²) 的枚举开销。当数组长度是 10⁵ 量级的时候,O(n²) 的运算量是 10¹⁰,在任何评测环境下都不可能跑完。

滑动窗口的思路是完全不同的。它不枚举所有区间,而是让窗口像一条贪吃蛇一样在数组上滑动。窗口有左右两个边界,我们用 left 和 right 两个指针表示。右指针负责扩展窗口,把新元素纳入当前区间;左指针负责收缩窗口,把不再满足条件的元素从区间中移除。整个过程只需要遍历数组一次,右指针从 0 走到 n-1,左指针同样最多从 0 走到 n-1,所以总的时间复杂度是 O(n)。

但这里有一个关键前提:窗口能够在收缩时保证正确性。也就是说,当右指针向前移动导致窗口不再满足条件时,我们可以通过移动左指针来恢复条件,并且移动左指针的过程中不会漏掉可能的更优解。这个性质,专业一点叫“单调性”,通俗一点说——如果当前窗口因为某个条件不满足而需要收缩,那么所有包含当前窗口的更大窗口也不会满足条件,所以右指针不需要回退。只有具备这种单调性的问题才适合滑动窗口。

1.2 固定窗口与可变窗口的区分

滑动窗口有两种常见形态:固定长度窗口和可变长度窗口。

固定长度窗口最典型的问题,比如“长度为 k 的子数组最大平均值”“长度为 k 的窗口内最大值”。这类问题窗口长度不变,right 每前进一步,left 就必须同步前进一步,保持窗口大小恒为 k。代码写起来就是先初始化前 k 个元素,然后从 k 开始循环,每轮加入新元素、移除旧元素、更新答案。

可变长度窗口更常见,也更考验对条件的理解。比如“无重复字符的最长子串”“和至少为 s 的最短子数组”“包含所有目标字符的最短子串”。这类问题的窗口长度不固定,right 始终向右扩展,当窗口不满足约束时,left 向右收缩,直到重新满足约束,然后更新答案。根据题目要求是要最长还是最短,更新答案的时机也不同——找最短通常是在收缩过程中更新,找最长通常是在扩展之后、收缩之前更新。这一个细节,很多人写错就在这。

1.3 滑动窗口与纯双指针的关系

有人会把滑动窗口和双指针混为一谈,两者确实有重叠,但不完全是一回事。双指针在很多场景里是两个指针从两端往中间逼近,比如有序数组的两数之和,left 在左、right 在右,根据和的大小决定移动哪一边,这种并不涉及“窗口”的概念,窗口要求两个指针维护的是一段连续区间。

滑动窗口一定是双指针的一种,但它的特点在于:两个指针都只朝一个方向移动,right 只增不减,left 也只增不减。这种“同向双指针”是滑动窗口的标志。你可以在写代码时给自己定一个纪律:right 永远在 for 循环里往前走,left 只在 while 里往前走,两者绝不回头。这个纪律能让你的代码不容易陷入死循环,也更容易分析复杂度。

2. 一套通用的滑动窗口模板

2.1 基础模板代码逐行拆解

先给出我实际写题时最常用的模板,以 Python 为例:

def sliding_window(s: str) -> int: n = len(s) if n == 0: return 0 left = 0 counter = {} result = 0 for right in range(n): # 1. 把右指针指向的元素加入窗口 ch = s[right] counter[ch] = counter.get(ch, 0) + 1 # 2. 窗口状态不满足要求时,收缩左边界 while not is_valid(counter): left_ch = s[left] counter[left_ch] -= 1 if counter[left_ch] == 0: del counter[left_ch] # 可选,避免字典膨胀 left += 1 # 3. 收缩完成后,窗口满足条件,更新答案 result = max(result, right - left + 1) return result

这个模板里最关键的是两个部分:is_valid函数决定了窗口收缩的时机,收缩完之后更新答案决定了答案的性质。实际做题时,你不需要真的写一个单独的is_valid,通常是把判断条件直接内联在while里。

模板背后的逻辑是:right 每走一步,我们都尝试让窗口重新满足条件,然后在所有满足条件的窗口中选一个最优的更新答案。因为 right 从左到右扫过每个位置,所以以任何位置结尾的合法窗口都会被考虑到,不会漏解。

2.2 需要维护的窗口数据有哪些

窗口里要维护什么,取决于题目约束。最常见的三类:

  • 字符出现次数。用哈希表记录窗口内每个字符出现了多少次,适合“无重复”“包含全部字符”这类关于字符频率的约束。
  • 数值之和。用一个变量window_sum记录当前窗口所有元素的和,适合“子数组和≥s”“乘积小于k”这类数值约束。
  • 窗口内元素的其他统计量,比如最大值、最小值、不同元素个数。这些可能需要额外的数据结构,比如单调队列、堆,或者另一个哈希表。

维护方式的核心原则是:窗口滑动时,新增元素和移除元素都要同步更新这些数据,保持数据和窗口状态的一致性。我见过很多 bug 都出现在移除元素时忘了更新某个辅助变量,导致窗口数据失真,后续逻辑全部乱掉。

2.3 收缩窗口的正确姿势

收缩窗口是滑动窗口里最容易写错的地方。很多人会写成if而不是while,或者收缩完之后忘记继续判断当前窗口是否已经满足条件。这两类问题都会破坏算法正确性。

用while而不是if的原因很简单:右指针每加入一个元素,可能让窗口状态连续多次不满足约束。比如求“和至少为 target 的最短子数组”,如果当前窗口和是 target 加上一个很大的数,那么左指针可能要连续移动好几次才能让和重新小于 target(或者在找最短时收缩到刚好不满足再停)。用if只移动一次就停了,窗口状态依然不满足,后面的答案就错了。

收缩循环的另一个要点是顺序:先移除左指针指向元素的数据,再移动 left 指针。反过来如果先left += 1再移除数据,移除的就是错误位置的元素。这个问题看似不起眼,但在哈希表计数、累加和等场景下会直接导致结果错误。

3. 经典题型完整实操:从无重复子串到最小覆盖子串

3.1 最长无重复字符的子串

题目描述很短:给定一个字符串,找出其中不含重复字符的最长子串的长度。这是滑动窗口的入门必做题,也是理解窗口收缩机制最直观的例子。

思路是这样的:right 每次扩展现窗口,加入一个新字符。如果这个字符已经在窗口里出现过,说明当前窗口不合法了——有重复字符。这时候就需要左指针向右移动,把重复字符之前的那个字符从窗口里移除,直到窗口里这个字符的出现次数重新变成 1。每轮收缩完成之后,当前窗口就是一个合法窗口,用它的长度更新答案。

代码实现如下:

def length_of_longest_substring(s: str) -> int: n = len(s) left = 0 window = {} ans = 0 for right in range(n): ch = s[right] window[ch] = window.get(ch, 0) + 1 while window[ch] > 1: left_ch = s[left] window[left_ch] -= 1 if window[left_ch] == 0: del window[left_ch] left += 1 ans = max(ans, right - left + 1) return ans

这里的收缩条件是window[ch] > 1,意思是当前加入的字符重复了。为什么收缩循环里不需要检查其他字符是否重复?因为窗口在之前的状态是合法的,唯一可能导致不合法的就是新加入的这个字符。当窗口里这个字符出现次数降回 1 时,整个窗口就重新合法了。理解这一点,你就能明白为什么收缩条件可以写得这么简单。

实测这份代码在大部分在线评测网站都能直接通过,时间复杂度 O(n),空间复杂度 O(字符集大小)。

3.2 长度最小的子数组

题目:给定一个正整数数组和一个目标值 target,找出满足和 ≥ target 的长度最小的连续子数组,返回其长度。如果不存在,返回 0。

这道题的窗口约束变成了数值约束:窗口内所有元素的和。right 每加入一个元素,window_sum就增加;当window_sum >= target时,窗口满足条件。但要注意,题目要求的是“长度最小”,所以一旦满足条件,我们要尝试收缩左边界,看看能不能在保持满足条件的前提下获得更短的窗口。这就是“满足条件后不断收缩并更新答案”的模式。

代码:

def min_sub_array_len(target: int, nums: List[int]) -> int: n = len(nums) left = 0 window_sum = 0 ans = float('inf') for right in range(n): window_sum += nums[right] while window_sum >= target: ans = min(ans, right - left + 1) window_sum -= nums[left] left += 1 return 0 if ans == float('inf') else ans

注意这里更新答案的位置:while循环体内部,收缩之前。因为每收缩一次,窗口长度就可能更小;我们记录的是所有满足条件的窗口中最短的那个。这个和上一题的“收缩完之后更新”形成对比,原因在于题目要求的方向不同——上一题要最长,收缩完了窗口更短,记录在收缩完更合理;这一题要最短,满足条件时窗口已经合法,收缩只会让它更短,需要在收缩的每一步都尝试更新。

3.3 最小覆盖子串

这道题比前两道复杂不少:给定字符串 s 和字符串 t,在 s 中找到包含 t 全部字符(含重复字符)的最短子串。如果不存在返回空串。

这里的窗口约束是“覆盖”:窗口里每个字符的出现次数都至少达到 t 中对应字符的出现次数。所以需要两个哈希表:一个记录 t 中各字符的需求量,一个记录窗口内各字符的出现次数。还要用一个变量matched表示当前窗口已经满足了 t 中多少个字符的需求,当matched == len(t_hash)时说明窗口覆盖了 t。

代码:

def min_window(s: str, t: str) -> str: if not s or not t: return "" need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 left = 0 window = {} matched = 0 start = 0 min_len = float('inf') for right in range(len(s)): ch = s[right] window[ch] = window.get(ch, 0) + 1 if ch in need and window[ch] == need[ch]: matched += 1 while matched == len(need): if right - left + 1 < min_len: min_len = right - left + 1 start = left left_ch = s[left] window[left_ch] -= 1 if left_ch in need and window[left_ch] < need[left_ch]: matched -= 1 left += 1 return "" if min_len == float('inf') else s[start:start + min_len]

这段代码有两个细节容易出错。第一,matched只有在window[ch] == need[ch]时才增加,收缩时只有window[left_ch] < need[left_ch]才减少。这是因为matched统计的是“恰好满足需求的字符种类数”,一旦一个字符的窗口计数从等于需求变成小于需求,它就不再被覆盖了。第二,收缩时更新答案是先记录当前窗口再收缩,还是先收缩再记录?这道题要在窗口合法期间取最小长度,所以先记录再移动 left 更符合直觉;如果先移动 left,窗口可能已经变成不合法状态,记录的就是非法的长度。

3.4 其他常见变体的思路提示

滑动窗口的变体很多,但核心套路不变。比如“找到字符串中所有字母异位词”,本质就是固定窗口长度等于模式串长度,在长串上滑动并比较字符频次;“乘积小于 K 的子数组”,和“长度最小的子数组”类似,只是约束是乘积上限而不是和的下限;“滑动窗口最大值”,窗口长度固定,但要额外用单调队列维护窗口内最大值,不能只靠哈希表。

遇到变体时,先问自己三个问题:窗口长度固定还是可变?窗口满足的条件是什么?答案是在扩展时更新还是收缩时更新?这三个问题想清楚了,代码基本就能沿着模板写出来。

4. 滑动窗口的复杂度分析与边界条件

4.1 双指针只进不退的复杂度论证

很多人担心滑动窗口会不会在某些情况下退化成 O(n²)。其实只要保证 left 和 right 都只向一个方向移动,并且没有在循环内部额外扫描窗口,复杂度就是 O(n)。right 从 0 到 n-1 总共 n 步;left 虽然可能在 while 里连续移动,但它从不回退,所以整个算法执行过程中 left 的总移动次数最多也是 n。两个指针加起来最多移动 2n 次,每次操作如果是 O(1)(哈希表读写、加减法),那总复杂度就是 O(n)。

这里有一个需要特别注意的点:如果收缩循环里每次都要扫描窗口内所有元素来判断合法性,比如遍历哈希表求频次,那复杂度就会上升。所以滑动窗口配合哈希表时,尽量用matched这类计数器来 O(1) 判断合法性,而不是每次都重新统计。这是从 O(n) 到 O(n·字符集大小) 的差别,数据量大时差距非常明显。

4.2 空输入与无解情况的处理

滑动窗口的边界问题主要集中在空输入和无解情况。很多模板代码假设输入非空,一旦遇到空字符串或空数组就崩。所以在代码开头写一个if not s或if n == 0的判断是必须的。

无解情况也需要特别处理。比如“长度最小的子数组”如果所有元素之和都小于 target,就根本不存在满足条件的子数组,此时答案初始值float('inf')不会被更新,返回时要做判断。最小覆盖子串如果找不到覆盖串,同样要返回空串而不是一个错误的切片。很多初学的人会忽略这一点,用s[start:start+min_len]直接切片,如果min_len还是无穷大,切片结果就完全不可控。

4.3 哈希表删除键与内存细节

在使用 Python 的字典时,收缩窗口中把计数减到 0 的键可以删除也可以不删,各有优劣。删除键的优点是字典体积不会无限膨胀,尤其在字符集很大的场景下更干净;缺点是删除操作本身有一点开销,而且后续判断ch in need时要注意,window[ch]不存在和window[ch] == 0是两种语义。

如果你保留计数为 0 的键,判断覆盖情况时要注意:window.get(ch, 0)的默认值要设置为 0,否则缺失键访问会抛 KeyError。我建议初学时统一用window.get(ch, 0)风格写,能省去一大类 KeyError 的问题。在需要频繁判断“窗口是否覆盖模式串”的题目里,保留零值键反而更方便,因为window[ch] < need[ch]的写法在键缺失时也能通过window.get(ch, 0)统一处理。

5. 滑动窗口的适用场景判定方法与优化技巧

5.1 如何判断一道题能不能用滑动窗口

不是所有连续子数组/子串问题都能用滑动窗口。一个快速判断方法是看收缩时是否具备单调性。举个例子,如果题目给的约束是“窗口内所有数的乘积 ≥ K”,那么当右指针扩展使乘积变大时,左指针收缩会使乘积变小,约束具备单调性,可以用滑动窗口。但如果约束是“窗口内最大值和最小值的差 ≤ K”,表面上也能用滑动窗口,因为窗口扩大可能让差值变大,收缩可以让差值变小,也具有单调性,所以也可以用。

反过来,如果题目要求的是“窗口内出现次数最多的字符的频次”“窗口内所有元素两两互质”这类约束,收缩时窗口状态的变化并不总是朝向“满足条件”的方向,就需要额外讨论了。更直白地说:当窗口向右扩展导致约束被破坏时,如果向左收缩一定能让约束恢复,那么这道题就适合滑动窗口;如果收缩不一定恢复,可能就需要配合其他数据结构甚至不能用滑动窗口。

5.2 一种通用的收缩加速技巧

有些题目窗口收缩后虽然满足条件了,但仍可能不是最优的,这时候需要配合额外条件加速。比如“替换后的最长重复字符”那道题,窗口内允许替换最多 k 个字符使全部变成同一个字符。这个约束下,如果窗口内除了出现次数最多的字符之外的其他字符数量超过 k,就需要收缩。这里你用不到哈希表配合一个max_count变量记录窗口内最多出现次数,收缩时不需要因为max_count变小而调整逻辑,因为只有max_count增大的时候窗口才可能扩张到更长。这是一种常见的加速优化思路,本质是放宽了收缩的精确性,但不影响最终答案。

5.3 空间复杂度的进一步优化

有些场景可以不需要哈希表。比如输入是纯小写字母,字符集只有 26 个字符,你可以用长度为 26 的数组代替字典,访问速度更快,内存占用也更固定。又比如处理的是整数数组且数值范围已知,用数组计数比字典更高效。在实际写题时,我通常会根据数据范围决定用数组还是字典:字符集小的用数组,范围未知或很大的用字典。这个选择虽然不影响复杂度量级,但常数因子可以差好几倍,尤其在 Python 这种解释型语言里,数组要比字典快不少。

6. 滑动窗口实战中的常见错误与排查方法

6.1 死循环:right 不前进或 left 后退

滑动窗口写死循环最常见的原因有三个:一是在 for 循环内部不小心修改了 right 的值,导致右指针在某次迭代中没有向前推进;二是在 while 收缩循环里忘了给 left 加 1,导致窗口无法收缩,陷入无限循环;三是收缩条件和数据更新顺序颠倒,导致数据永远修正不了。

排查方法很简单:在循环内部加上临时打印,输出每一轮 left、right 和窗口数据的变化。如果发现连续多轮 left 不变、right 不变,基本就是没加 1 或者数据更新逻辑写错了。实际刷题时,我建议在代码里插几个# print(left, right, window)的注释行,调试完删掉即可,这个做法对定位问题非常有用。

6.2 答案错误:更新时机选错了

答案错误是滑动窗口最常见的失败模式。尤其是同一个题,有人 AC 了有人 WA,一对比发现就是更新答案的时机不同。核心规则可以总结成一句话:题目要最短,在窗口满足条件后、收缩过程中或者收缩到临界时更新;题目要最长,在窗口满足条件且尽量扩张后更新。如果你发现自己的代码逻辑看着没问题但答案总是偏大或偏小,优先检查更新时机。

6.3 数据不一致:窗口数据和指针位置对不上

这类 bug 通常出现在收缩循环里。比如移除左指针指向的元素时,你写的是counter[s[right]] -= 1,而不是counter[s[left]] -= 1,数据就会错位。另一个常见问题是在left += 1之前没有移除旧元素,或者在移除元素时用了一个已经改变过的 left 值。排查的方法是缩小数据规模,用一个长为 5 的数组手动模拟每一轮,逐步判断数据是否和指针一致。手动模拟虽然慢,但对于理解错误很有帮助。

7. 高频变体与扩展应用速查表

题目类型窗口形态维护的数据更新答案时机
最长无重复子串可变窗口字符频次哈希表收缩结束后
长度最小的子数组可变窗口窗口和收缩过程中
最小覆盖子串可变窗口双哈希表 + matched收缩过程中
找到所有字母异位词固定窗口字符频次数组每轮固定窗口判断
滑动窗口最大值固定窗口单调队列每轮固定窗口
乘积小于K的子数组可变窗口窗口乘积每轮收缩完成后累加

这个表可以当作做题时的参考。遇到新题先对号入座:窗口是固定还是可变?维护数据是计数、和、积还是最大值?答案在哪一步更新?对上了,基本就能把模板套进去。

变体题目里还有一个常见的考察方向是在矩阵上做二维滑动窗口,但核心思路和一维一致,只是在 row 和 col 两个维度各维护一组指针。理解了基础版本之后,二维的窗口无非是把 left 和 right 换成了左上角和右下角,每次按行按列更新数据,整体复杂度从 O(n) 变成 O(n·m),思路不改变。

8. 经验总结:从会模板到真正掌握

最后分享几个我实际写题过程中很受用的体会。

第一,模板背熟只是第一步,真正重要的是理解窗口收缩的“合法性判断”到底依赖什么数据。很多题目模板一样,但while里的条件千差万别,这个条件就是整道题的灵魂。我见过太多人死记模板,然后在window[ch] > 1这种条件上栽跟头——因为他们不理解为什么这里是>1而不是>=1,不理解这个条件背后“当前字符重复”的语义。

第二,写完代码一定要检查边界:空输入、无解、全满足、刚好相等这四种情况。我自己的习惯是用一个极小的测试用例手动跑一遍,比如s = "abc", t = "c"这类,确认 left、right、窗口数据每一步都对,再去提交。手动模拟一遍花费的时间通常只有一两分钟,但能拦下大量低级错误。

第三,不要怕在博客、题解里看到滑动窗口就头大。它本质上就是一种“有纪律的枚举”。暴力枚举是漫无目的地尝试所有区间,滑动窗口是让区间首尾按照约束条件有方向地移动,漏掉任何可能是最优解的区间。你做题多了就会发现,滑动窗口题的代码结构高度相似,真正需要思考的,是那个“窗口满足什么条件时应该收缩”的问题。把这一点想明白,大部分滑动窗口题目你都能稳定拿下。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询