滑动窗口是解决数组和字符串问题的一把利器。当你遇到"子数组"、"子串"、"连续"这些关键词时,滑动窗口往往能提供
什么是滑动窗口
滑动窗口( Sliding Window)是一种双指针技巧的变体,主要用于解决数组或字符串中的连续子区间问题。它维护一个窗口,通过移动窗口的左右边界来遍历所有可能的子区间,同时利用窗口内的信息避免重复计算。
想象一下,你在一列火车上,透过一个固定大小的窗户看外面的风景。随着火车前进,窗户会"滑动"到新的位置,但窗户的大小保持不变。这就是固定窗口大小的情况。另一种情况是,窗户的大小可以变化——可以拉大或缩小窗户,直到看到最合适的风景。这就是可变窗口大小的情况。
在算法中,滑动窗口通常涉及两个指针( left 和 right):
- 左指针( left):窗口的左边界
- 右指针( right):窗口的右边界
- 窗口: left 和 right 之间的区间
通过移动这两个指针,可以:
- 扩大窗口:移动 right 指针
- 缩小窗口:移动 left 指针
- 保持窗口大小:同时移动 left 和 right
滑动窗口的核心思想
滑动窗口本质上是一种双指针技术,通过维护一个"窗口"(即左右两个指针之间的区间)来解决子数组/子串问题。它的核心优势在于:避免重复计算。
思路演进:从暴力到优化
❌ 方法一:暴力枚举(超时)
直觉:对于求"最长/最短子串"问题,最简单的想法是枚举所有可能的子串,检查每个子串是否满足条件。
为什么不行: - 时间复杂度:
示例(无重复字符的最长子串): 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17# 暴力解法示例
def lengthOfLongestSubstring_brute(s: str) -> int:
n = len(s)
max_len = 0
# 枚举所有可能的起点
for i in range(n):
# 枚举所有可能的终点
for j in range(i, n):
# 检查 s[i:j+1] 是否无重复字符
substring = s[i:j+1]
if len(substring) == len(set(substring)):
max_len = max(max_len, j - i + 1)
else:
break # 剪枝:遇到重复就停止
return max_len
复杂度: - 时间:
✅ 方法二:滑动窗口(最优)
优化思路:利用"窗口滑动"避免重复计算。关键洞察是:
- 窗口扩展:右指针向右移动,尝试扩大窗口
- 窗口收缩:当窗口内不满足条件时,左指针向右移动,缩小窗口
- 增量更新:每次只处理新加入/移出的元素,不重新检查整个窗口
为什么有效: -
每个元素最多被访问两次(一次被右指针加入,一次被左指针移出)
- 时间复杂度:
关键洞察: - 单调性:如果
[left, right] 不满足条件,那么
[left, right+1]、[left, right+2]
等也不会满足(对于某些问题) -
贪心策略:在保证条件的前提下,尽量扩大窗口(求最长),或尽量缩小窗口(求最短)
火车车厢类比
想象你在查看一列火车的车厢:
- 暴力法:每次重新数一遍所有车厢(从头开始检查)
- 滑动窗口:
- 右边新车厢进入时:只检查新车厢是否符合条件
- 左边车厢驶离时:只移除最左边的车厢
- 无需重新检查中间的车厢
可视化示例(无重复字符的最长子串):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28字符串:"abcabcbb"
目标:找出最长无重复字符子串
初始状态:
┌─┐
│ a │ b c a b c b b
└─┘
left=0, right=0, 窗口="a", 无重复 ✓
步骤 1: right 向右扩展
┌───────┐
│ a b c │ a b c b b
└───────┘
left=0, right=2, 窗口="abc", 无重复 ✓, max_len=3
步骤 2: right 继续扩展,遇到重复'a'
┌───────────┐
│ a b c a │ b c b b
└───────────┘
left=0, right=3, 窗口="abca", 有重复 ✗
步骤 3: left 收缩,移除重复的'a'
┌───────┐
a │ b c a │ b c b b
└───────┘
left=1, right=3, 窗口="bca", 无重复 ✓
最终: max_len=3("abc")
滑动窗口的高效性来源
滑动窗口之所以高效,是因为它利用了问题的两个关键特性:
重复子问题(增量更新)
当我们计算窗口 [left, right] 的信息时,窗口
[left+1, right+1]
的信息可以通过增量更新的方式得到,而不需要重新计算整个窗口。
例子:计算窗口内元素的和 1
2
3
4
5# 暴力法:每次重新计算整个窗口的和 O(n^2)
window_sum = sum(arr[left:right+1])
# 滑动窗口:增量更新 O(1)
window_sum = window_sum - arr[left] + arr[right+1]
单调性(决定窗口移动策略)
在很多问题中,窗口的某些性质(如和、最大值、字符种类数等)会随着窗口的扩大或缩小呈现单调性。例如:
- 窗口越大,和越大(非负数组)
- 窗口越大,包含的字符种类越多
- 窗口越大,最大值不会变小(非严格单调)
利用这些特性,可以用
固定窗口大小问题
固定窗口大小的问题相对简单,因为窗口的大小是预先确定的。我们只需要维护一个固定大小的窗口,在数组上滑动,并记录每个窗口的某些属性(如和、最大值、平均值等)。
模板代码
1 | def fixed_window(nums, k): |
例题:子数组最大平均值
LeetCode 643. 子数组最大平均值
给定一个整数数组 nums 和一个整数
k,找出长度为 k 的连续子数组的最大平均值。
示例:
1 | 输入: nums = [1,12,-5,-6,50,3], k = 4 |
思路:
- 维护一个大小为
k的窗口 - 计算每个窗口的平均值
- 返回最大值
Python 实现:
1 | def findMaxAverage(nums, k): |
Java 实现:
1 | public double findMaxAverage(int[] nums, int k) { |
时间复杂度:
例题:大小为 K 且平均值大于等于阈值的子数组数目
LeetCode 1343. 大小为 K 且平均值大于等于阈值的子数组数目
给你一个整数数组 arr 和两个整数 k 和
threshold,返回长度为 k 且平均值大于等于
threshold 的子数组数目。
示例:
1 | 输入: arr = [2,2,2,2,5,5,5,8], k = 3, threshold = 4 |
思路:
- 维护大小为
k的窗口 - 计算每个窗口的和
- 如果窗口和 >=
threshold * k,计数加一
Python 实现:
1 | def numOfSubarrays(arr, k, threshold): |
可变窗口大小问题
可变窗口大小的问题更加灵活,窗口的大小会根据问题的约束条件动态调整。这类问题通常需要找到满足某种条件的最长或最短子数组/子串。
窗口收缩条件详解
窗口收缩(左指针右移)是滑动窗口最关键的部分,决定了算法的正确性。什么时候收缩和收缩到什么程度是两个核心问题。
核心原则:当窗口不满足条件时,必须收缩直到重新满足条件。
收缩时机:三种典型场景
场景一:窗口违反约束(求最长)
特征:窗口内某个指标超过了限制 - 无重复字符:窗口内出现重复字符 - 最多 k 个不同字符:窗口内不同字符数 > k - 和不超过 target:窗口和 > target
收缩策略:移动 left 直到窗口重新满足约束
1 | # 示例:无重复字符的最长子串 |
为什么这样收缩?因为当前窗口
[left, right]
不满足条件,那么任何包含这个窗口的更大窗口(如
[left, right+1])也不会满足条件。所以必须缩小左边界。
场景二:窗口满足条件(求最短)
特征:窗口已经满足了目标条件 - 最小覆盖子串:窗口包含了所有目标字符 - 和 >= target:窗口和已经 >= target
收缩策略:尽可能缩小窗口,在保持满足条件的前提下
1 | # 示例:最小覆盖子串 |
为什么这样收缩?因为题目要求最短/最小,所以在满足条件后,要尽可能缩小窗口,找到最小的满足条件的窗口。
场景三:固定窗口大小
特征:窗口大小固定为 k - 大小为 k 的子数组最大和 - 长度为 k 的子串出现次数
收缩策略:每次右指针移动时,左指针也移动(保持窗口大小 = k)
1 | # 示例:大小为 k 的子数组最大和 |
收缩条件对比表
| 问题类型 | 收缩时机 | 收缩条件 | 示例 |
|---|---|---|---|
| 求最长 | 窗口违反约束时 | while 不满足条件 |
无重复字符的最长子串 |
| 求最短 | 窗口满足条件时 | while 满足条件 |
最小覆盖子串 |
| 固定大小 | 窗口达到 k 时 | if size == k |
大小为 k 的子数组最大和 |
窗口收缩的数学证明
为什么求最长时,遇到违反约束就收缩?
反证法:假设窗口 [left, right]
不满足条件,但存在一个更长的满足条件的窗口
[left, right+k]( k > 0)。 - 因为
[left, right] 不满足条件,而 [left, right+k]
是 [left, right] 的超集 -
如果条件是"单调递增的"(如元素数量、字符种类数),那么
[left, right+k] 也不会满足条件 -
矛盾!所以不存在这样的窗口
结论:当窗口不满足条件时,必须收缩左边界,不能继续扩大右边界。
为什么求最短时,满足条件后仍要收缩?
贪心思想:当前窗口 [left, right]
满足条件,但可能不是最短的。尝试缩小左边界: -
如果缩小后仍满足条件:说明左边界的元素是"多余的",可以继续缩小 -
如果缩小后不满足条件:说明当前窗口是以 right
为右边界的最短窗口
结论:在满足条件的前提下,尽可能缩小窗口,才能找到最短的。
两种常见模式
模式一:寻找最长子串/子数组(窗口违反约束时收缩)
这类问题的目标是找到满足条件的最长子串。通常的做法是:
- 扩展窗口:不断扩大窗口(移动 right)
- 收缩窗口:当窗口不满足条件时,缩小窗口(移动 left)
- 更新答案:在窗口满足条件时,更新最大长度
模板代码: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20def max_length_template(arr):
left = 0
max_len = 0
# window_state 用于维护窗口的状态(如哈希表、计数器等)
window_state = {}
for right in range(len(arr)):
# 将 arr[right] 加入窗口
update_window_state(arr[right], window_state)
# 收缩窗口:当窗口不满足条件时
while not is_valid(window_state):
# 移除 arr[left]
remove_from_window(arr[left], window_state)
left += 1
# 更新答案:此时窗口满足条件
max_len = max(max_len, right - left + 1)
return max_len
关键点: -
收缩条件:while not is_valid(window_state)(窗口不满足条件)
- 更新答案:在收缩后(窗口满足条件)
模式二:寻找最短子串/子数组(窗口满足条件时收缩)
这类问题的目标是找到满足条件的最短子串。通常的做法是:
- 扩展窗口:不断扩大窗口(移动 right),直到满足条件
- 收缩窗口:一旦满足条件,尝试缩小窗口(移动 left),在保持满足条件的前提下寻找更短的解
- 更新答案:在窗口满足条件时,更新最小长度
模板代码: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19def min_length_template(arr, target):
left = 0
min_len = float('inf') # 初始化为无穷大
window_state = {}
for right in range(len(arr)):
# 将 arr[right] 加入窗口
update_window_state(arr[right], window_state)
# 收缩窗口:当窗口满足条件时
while is_valid(window_state, target):
# 更新答案:此时窗口满足条件,尝试更新最小长度
min_len = min(min_len, right - left + 1)
# 移除 arr[left],尝试缩小窗口
remove_from_window(arr[left], window_state)
left += 1
return min_len if min_len != float('inf') else 0
关键点: -
收缩条件:while is_valid(window_state, target)(窗口满足条件)
- 更新答案:在收缩前(窗口满足条件时)
对比:最长 vs 最短
| 特性 | 求最长 | 求最短 |
|---|---|---|
| 收缩时机 | 窗口不满足条件时 | 窗口满足条件时 |
| 收缩目的 | 恢复满足条件 | 寻找更短的满足条件的窗口 |
| 更新答案 | 收缩后(窗口满足条件) | 收缩前(窗口满足条件) |
| 初始值 | max_len = 0 |
min_len = inf |
| 典型问题 | 无重复字符的最长子串 | 最小覆盖子串 |
模板代码(通用)
1 | def sliding_window_template(s): |
经典题目详解
题目一:无重复字符的最长子串
LeetCode 3. 无重复字符的最长子串
给定一个字符串
s,请你找出其中不含有重复字符的最长子串的长度。
示例:
1 | 输入: s = "abcabcbb" |
思路分析:
这是一个典型的"寻找最长子串"问题。需要:
- 维护一个窗口,窗口内的字符都不重复
- 用哈希表记录窗口中每个字符的出现次数
- 当窗口中出现重复字符时,缩小窗口(移动 left),直到没有重复字符
- 在窗口没有重复字符时,更新最长长度
详细步骤:
以 s = "abcabcbb" 为例:
初始化:
left = 0,right = 0,max_len = 0,char_count = {}right = 0:加入
'a'char_count = {'a': 1}- 窗口
[0, 0]:"a",无重复,max_len = 1
right = 1:加入
'b'char_count = {'a': 1, 'b': 1}- 窗口
[0, 1]:"ab",无重复,max_len = 2
right = 2:加入
'c'char_count = {'a': 1, 'b': 1, 'c': 1}- 窗口
[0, 2]:"abc",无重复,max_len = 3
right = 3:加入
'a'char_count = {'a': 2, 'b': 1, 'c': 1}(出现重复!)- 缩小窗口:移动 left,直到
'a'只出现一次 left = 1:移除s[0] = 'a',char_count = {'a': 1, 'b': 1, 'c': 1}- 窗口
[1, 3]:"bca",无重复,max_len = 3
right = 4:加入
'b'char_count = {'a': 1, 'b': 2, 'c': 1}(出现重复!)- 缩小窗口:
left = 2,移除s[1] = 'b' - 窗口
[2, 4]:"cab",无重复,max_len = 3
继续这个过程...
Python 实现:
1 | def lengthOfLongestSubstring(s): |
代码执行过程示例:
输入:s = "abcabcbb"
| 步骤 | right | s[right] | char_count | left | 窗口 | 操作 | max_len |
|---|---|---|---|---|---|---|---|
| 初始 | - | - | {} | 0 | "" | - | 0 |
| 1 | 0 | 'a' | {'a':1} | 0 | "a" | 加入'a' | 1 |
| 2 | 1 | 'b' | {'a':1,'b':1} | 0 | "ab" | 加入'b' | 2 |
| 3 | 2 | 'c' | {'a':1,'b':1,'c':1} | 0 | "abc" | 加入'c' | 3 |
| 4 | 3 | 'a' | {'a':2,'b':1,'c':1} | 0 | "abca" | 加入'a',重复! | - |
| 4 | 3 | 'a' | {'a':1,'b':1,'c':1} | 1 | "bca" | 移除'a',不重复 | 3 |
| 5 | 4 | 'b' | {'a':1,'b':2,'c':1} | 1 | "bcab" | 加入'b',重复! | - |
| 5 | 4 | 'b' | {'a':1,'b':1,'c':1} | 2 | "cab" | 移除'b',不重复 | 3 |
| 6 | 5 | 'c' | {'a':1,'b':1,'c':2} | 2 | "cabc" | 加入'c',重复! | - |
| 6 | 5 | 'c' | {'a':1,'b':1,'c':1} | 3 | "abc" | 移除'c',不重复 | 3 |
| 7 | 6 | 'b' | {'a':1,'b':2,'c':1} | 3 | "abcb" | 加入'b',重复! | - |
| 7 | 6 | 'b' | {'b':2,'c':1} | 4 | "bcb" | 移除'a',仍重复 | - |
| 7 | 6 | 'b' | {'b':1,'c':1} | 5 | "cb" | 移除'b',不重复 | 3 |
| 8 | 7 | 'b' | {'b':2,'c':1} | 5 | "cbb" | 加入'b',重复! | - |
| 8 | 7 | 'b' | {'b':2} | 6 | "bb" | 移除'c',仍重复 | - |
| 8 | 7 | 'b' | {'b':1} | 7 | "b" | 移除'b',不重复 | 3 |
为什么这样设计?
- 一次遍历: right 指针只向右移动,每个字符最多被 right 访问一次
- 增量更新:每次只处理新加入的字符,不重新检查整个窗口
- 及时收缩:遇到重复立即收缩左边界,保证窗口始终满足条件
- 哈希表优化:用哈希表 O(1) 查找字符是否重复,避免遍历窗口

Java 实现:
1 | public int lengthOfLongestSubstring(String s) { |
复杂度分析
时间复杂度:
推导过程: 1. 外层循环:右指针
right 遍历字符串,执行 left
移动,看似嵌套,但关键洞察: - left
只会向右移动,不会回退 - left 最多移动
虽然代码中有 for right in range(n) 嵌套
while char_count[s[right]] > 1,但这不是传统的双重循环。关键在于:
- 每个字符最多被 left 访问一次,被 right 访问一次 -
所以总操作数是
推导过程: -
哈希表大小:最多存储窗口内的所有字符 -
最坏情况: - 如果字符串全部不重复:哈希表大小 =
实际例子: - 输入 "abcdefghij...xyz"(
26 个字母):哈希表大小 = 26 - 输入 "aaaaaaa..."( 100 个
'a'):哈希表大小 = 1 - 输入随机 ASCII 字符串(长度 1000):哈希表大小 ≤
128
题目二:最小覆盖子串
LeetCode 76. 最小覆盖子串
给你一个字符串 s、一个字符串 t。返回
s 中涵盖 t 所有字符的最小子串。如果
s 中不存在涵盖 t
所有字符的子串,则返回空字符串 ""。
示例:
1 | 输入: s = "ADOBECODEBANC", t = "ABC" |
思路分析:
这是一个"寻找最短子串"问题。需要:
- 用哈希表记录
t中每个字符需要的数量 - 维护一个窗口,不断扩大窗口直到包含
t的所有字符 - 一旦窗口满足条件,尝试缩小窗口,寻找更短的解
- 记录满足条件的最短窗口
详细步骤:
以 s = "ADOBECODEBANC",t = "ABC"
为例:
初始化:
need = {'A': 1, 'B': 1, 'C': 1}(t中每个字符需要的数量)window = {}(窗口中每个字符的数量)valid = 0(窗口中满足need条件的字符种类数)left = 0,right = 0start = 0,min_len = float('inf')
扩大窗口:
right = 0:加入'A',window = {'A': 1},valid = 1('A'满足条件)right = 1:加入'D',window = {'A': 1, 'D': 1},valid = 1right = 2:加入'O',window = {'A': 1, 'D': 1, 'O': 1},valid = 1right = 3:加入'B',window = {'A': 1, 'B': 1, 'D': 1, 'O': 1},valid = 2('B'满足条件)right = 4:加入'E',window = {'A': 1, 'B': 1, 'D': 1, 'E': 1, 'O': 1},valid = 2right = 5:加入'C',window = {'A': 1, 'B': 1, 'C': 1, 'D': 1, 'E': 1, 'O': 1},valid = 3(所有字符都满足条件!)
缩小窗口(
valid == len(need)):- 当前窗口
[0, 5]:"ADOBEC",长度为 6 - 尝试缩小:
left = 0,移除'A',但'A'是必需的,不能移除 - 实际上,需要检查移除字符后是否还满足条件
- 正确的做法:当
window[s[left]] > need.get(s[left], 0)时,可以移除
- 当前窗口
继续扩大窗口:
right = 6:加入'O'...- 当窗口再次满足条件时,尝试缩小并更新答案
Python 实现:
1 | def minWindow(s, t): |
Java 实现:
1 | public String minWindow(String s, String t) { |
时间复杂度:s 和
t 的长度 空间复杂度:
题目三:长度最小的子数组
LeetCode 209. 长度最小的子数组
给定一个含有 n 个正整数的数组和一个正整数
target,找出该数组中满足其和 ≥ target
的长度最小的连续子数组
[numsl, numsl+1, ..., numsr-1, numsr],并返回其长度。如果不存在符合条件的子数组,返回
0。
示例:
1 | 输入: target = 7, nums = [2,3,1,2,4,3] |
思路分析:
这是一个"寻找最短子数组"问题。需要:
- 维护一个窗口,不断扩大窗口直到窗口和
≥ target - 一旦满足条件,尝试缩小窗口,寻找更短的解
- 记录满足条件的最短长度
详细步骤:
以 target = 7,nums = [2,3,1,2,4,3]
为例:
初始化:
left = 0,right = 0,window_sum = 0,min_len = float('inf')扩大窗口:
right = 0:加入2,window_sum = 2,窗口[0, 0]:[2],和 < 7right = 1:加入3,window_sum = 5,窗口[0, 1]:[2,3],和 < 7right = 2:加入1,window_sum = 6,窗口[0, 2]:[2,3,1],和 < 7right = 3:加入2,window_sum = 8,窗口[0, 3]:[2,3,1,2],和 ≥ 7!
缩小窗口:
- 当前窗口
[0, 3]长度为 4,min_len = 4 left = 0:移除2,window_sum = 6,窗口[1, 3]:[3,1,2],和 < 7,停止缩小- 继续扩大窗口...
- 当前窗口
继续过程:
right = 4:加入4,window_sum = 10,窗口[1, 4]:[3,1,2,4],和 ≥ 7- 缩小:
left = 1,移除3,window_sum = 7,窗口[2, 4]:[1,2,4],和 ≥ 7 - 缩小:
left = 2,移除1,window_sum = 6,和 < 7,停止 min_len = min(4, 3) = 3
最终:
right = 5,加入3,window_sum = 9,窗口[2, 5]:[2,4,3],和 ≥ 7- 缩小:
left = 2,移除2,window_sum = 7,窗口[3, 5]:[4,3],和 ≥ 7 - 缩小:
left = 3,移除4,window_sum = 3,和 < 7,停止 min_len = min(3, 2) = 2
- 缩小:
Python 实现:
1 | def minSubArrayLen(target, nums): |
Java 实现:
1 | public int minSubArrayLen(int target, int[] nums) { |
时间复杂度:
题目四:字符串的排列
LeetCode 567. 字符串的排列
给你两个字符串 s1 和 s2,写一个函数来判断
s2 是否包含 s1 的排列。如果是,返回
true;否则,返回 false。
换句话说,s1 的排列之一是 s2
的子串。
示例:
1 | 输入: s1 = "ab" s2 = "eidbaooo" |
思路分析:
这个问题可以转化为:在 s2 中是否存在一个长度为
len(s1) 的子串,其字符频率与 s1 完全相同。
可以:
- 用固定大小的窗口(大小为
len(s1))在s2上滑动 - 对于每个窗口,检查窗口内字符的频率是否与
s1相同 - 如果相同,返回
true
Python 实现:
1 | def checkInclusion(s1, s2): |
优化版本(使用 valid 计数,避免每次比较整个字典):
1 | def checkInclusion(s1, s2): |
Java 实现:
1 | public boolean checkInclusion(String s1, String s2) { |
时间复杂度:
题目五:找到字符串中所有字母异位词
LeetCode 438. 找到字符串中所有字母异位词
给定两个字符串 s 和 p,找到 s
中所有 p
的字母异位词的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
字母异位词指字母相同,但排列不同的字符串。
示例:
1 | 输入: s = "cbaebabacd", p = "abc" |
思路分析:
这个问题与上一题非常相似,区别在于:
- 上一题只需要判断是否存在,这一题需要找到所有满足条件的起始索引
- 窗口大小固定为
len(p) - 对于每个满足条件的窗口,记录其起始索引
Python 实现:
1 | def findAnagrams(s, p): |
Java 实现:
1 | public List<Integer> findAnagrams(String s, String p) { |
时间复杂度:
时间复杂度分析
滑动窗口算法的时间复杂度分析有一个通用的方法:
基本分析
对于滑动窗口算法:
- 外层循环:
right指针从0移动到n-1,共次迭代 - 内层循环:
left指针的移动
虽然有一个 while 循环,但 left
指针只会向前移动,不会后退。因此,在整个算法执行过程中:
right指针移动了次 left指针最多移动次
所以总的时间复杂度是
摊还分析( Amortized Analysis)
可以用摊还分析来更严格地证明:
设 right 移动了 left 移动了 left 不会超过 right,所以
总操作数 = right 移动次数 + left 移动次数 =
不同情况的时间复杂度
固定窗口大小:
- 每个元素被访问两次(一次加入,一次移出)
可变窗口大小(最长子串):
- 每个元素最多被访问两次
可变窗口大小(最短子串):
- 每个元素最多被访问两次
需要排序或复杂操作:可能更高
- 例如,如果窗口内需要维护有序结构,可能是
- 例如,如果窗口内需要维护有序结构,可能是
常见陷阱与调试技巧
陷阱一:窗口边界处理错误
问题:窗口的左右边界 [left, right]
是闭区间还是左闭右开区间?
解决方案:
- 统一使用左闭右闭区间
[left, right] - 窗口长度 =
right - left + 1 - 初始化时,
left = 0,right = -1(空窗口)或left = 0,right = 0(包含第一个元素)
示例:
1 | # 错误:窗口长度计算错误 |
陷阱二:哈希表更新时机错误
问题:在缩小窗口时,忘记更新哈希表,或者在更新哈希表之前就检查条件。
解决方案:
- 先更新窗口状态(加入/移除元素)
- 再检查窗口是否满足条件
- 最后更新答案
示例:
1 | # 错误:先检查条件,再更新窗口 |
陷阱三: valid 计数更新错误
问题:在使用 valid 计数优化时,更新
valid 的时机不对。
解决方案:
- 当
window[c] == need[c]时,valid++ - 当
window[c]从need[c]变为need[c] - 1时,valid-- - 注意:只有当
window[c]恰好等于need[c]时,才计入valid
示例:
1 | # 错误:没有检查是否恰好相等 |
陷阱四:空窗口或边界条件
问题:没有处理空字符串、空数组、窗口大小为 0 等边界情况。
解决方案:
- 在函数开始处检查边界条件
- 确保窗口大小至少为 1(如果需要)
示例:
1 | def sliding_window(s): |
调试技巧
- 打印窗口状态:
1 | def sliding_window(s): |
- 使用断言检查不变量:
1 | # 检查窗口大小 |
单步调试:
- 使用调试器逐步执行
- 观察
left、right、window、valid的变化 - 检查每个步骤是否符合预期
测试用例:
- 空输入
- 单个元素
- 所有元素相同
- 无解的情况
- 多个解的情况
❓ Q&A: 滑动窗口常见问题
Q1: 什么时候应该使用滑动窗口?
A: 当你遇到以下关键词时,可以考虑滑动窗口:
- 连续子数组/子串:问题要求找到连续的区间
- 最长/最短:需要找到满足条件的最长或最短区间
- 固定大小:窗口大小固定(如长度为 k 的子数组)
- 包含/不包含:窗口需要包含或不包含某些元素
典型场景:
- 字符串匹配、子串查找
- 数组中的连续子数组问题
- 需要维护窗口内某些统计信息的问题
Q2: 滑动窗口和双指针有什么区别?
A: 滑动窗口是双指针技巧的一种特殊应用:
- 双指针:两个指针可以以任意方式移动(同向、反向、快慢指针等)
- 滑动窗口:两个指针( left 和 right)维护一个连续的区间,通常 right 先移动, left 后移动
滑动窗口可以看作是一种约束更严格的双指针,专门用于解决连续区间问题。
Q3: 如何判断窗口应该扩大还是缩小?
A: 这取决于问题的目标:
寻找最长子串/子数组:
- 先扩大窗口(移动 right),直到窗口不满足条件
- 然后缩小窗口(移动 left),直到窗口再次满足条件
- 在窗口满足条件时更新答案
寻找最短子串/子数组:
- 先扩大窗口(移动 right),直到窗口满足条件
- 然后缩小窗口(移动 left),尝试找到更短的解
- 在窗口满足条件时更新答案
固定窗口大小:
- 同时移动 left 和 right,保持窗口大小不变
Q4: 为什么滑动窗口的时间复杂度是 O(n) 而不是 O(n ²)?
A: left
指针只会向前移动,不会后退。
虽然有一个 while 循环,但:
right指针移动了次 left指针最多移动次(因为 left <= right)
总操作数 =
Q5: 如何处理窗口内有重复元素的情况?
A: 这取决于问题的要求:
不允许重复(如"无重复字符的最长子串"):
- 使用哈希表记录每个元素的出现次数
- 当某个元素出现次数 > 1 时,缩小窗口直到该元素只出现一次
允许重复但有限制(如"最多包含 k 个重复元素"):
- 使用哈希表记录每个元素的出现次数
- 当某个元素出现次数 > k 时,缩小窗口
完全允许重复:
- 不需要特殊处理,按正常逻辑即可
Q6: valid 计数是什么?为什么要用它?
A: valid
计数用于优化窗口条件的检查。
不使用 valid:
1 | # 每次都要比较整个字典,时间复杂度 O(m),其中 m 是字符集大小 |
使用 valid:
1 | # 只需要检查 valid == len(need),时间复杂度 O(1) |
valid 表示窗口中恰好满足 need
条件的字符种类数。当 valid == len(need)
时,说明窗口中所有需要的字符都满足条件了。
Q7: 滑动窗口可以解决哪些 LeetCode 题目?
A: 以下是一些经典的滑动窗口题目:
固定窗口大小:
- 子数组最大平均值
- 大小为 K 且平均值大于等于阈值的子数组数目
- 滑动窗口最大值(需要特殊数据结构)
可变窗口大小(最长):
- 无重复字符的最长子串
- 至多包含两个不同字符的最长子串
- 至多包含 K 个不同字符的最长子串
可变窗口大小(最短):
- 最小覆盖子串
- 长度最小的子数组
- 乘积小于 K 的子数组
其他:
- 字符串的排列
- 找到字符串中所有字母异位词
- 串联所有单词的子串
Q8: 滑动窗口在字符串和数组问题中有什么区别?
A: 本质上没有区别,都是维护一个连续的区间。主要区别在于:
字符串问题:
- 通常需要处理字符频率、字符匹配等
- 可能需要使用哈希表记录字符出现次数
- 窗口大小可能固定(如找固定长度的子串)或可变
数组问题:
- 通常需要处理数值和、最大值、最小值等
- 可能需要维护窗口内的统计信息(和、积等)
- 窗口大小可能固定或可变
核心思路是一样的:维护窗口状态,通过移动边界来遍历所有可能的子区间。
Q9: 如何优化滑动窗口的空间复杂度?
A: 几种优化方法:
- 使用数组代替哈希表(如果字符集/数值范围较小):
1 | # 如果字符都是小写字母,可以用数组代替哈希表 |
- 原地更新(如果不需要保留原数组):
1 | # 直接在原数组上操作,不创建新数组 |
- 只记录必要信息:
1 | # 只记录窗口内需要的信息,而不是所有信息 |
- 使用 valid 计数:
1 | # 避免每次比较整个字典,减少空间和时间开销 |
Q10: 滑动窗口算法有哪些常见的变体?
A: 常见的变体包括:
多指针滑动窗口:
- 使用三个或更多指针维护多个窗口
- 例如:同时维护多个不同大小的窗口
滑动窗口 + 数据结构:
- 窗口内需要维护有序结构(如堆、平衡树)
- 例如: 239. 滑动窗口最大值(需要维护窗口内的最大值)
滑动窗口 + 前缀和:
- 结合前缀和优化窗口和的计算
- 例如: 560. 和为 K 的子数组
滑动窗口 + 动态规划:
- 窗口状态需要动态规划来维护
- 例如:一些复杂的字符串匹配问题
滑动窗口 + 二分查找:
- 窗口大小不确定,需要二分查找最优大小
- 例如:某些优化问题
变体与扩展问题
变体一:多窗口问题
有些问题需要同时维护多个窗口,或者在不同条件下使用不同的窗口策略。
例题:至多包含两个不同字符的最长子串( LeetCode 159)
1 | def lengthOfLongestSubstringTwoDistinct(s): |
关键点:使用 len(char_count)
来判断窗口内字符种类数,而不是用 valid 计数。
变体二:滑动窗口 + 前缀和
当需要快速计算窗口和时,可以结合前缀和优化。
例题:和为 K 的子数组( LeetCode 560)
虽然这道题通常用哈希表解决,但也可以用滑动窗口的思想理解:维护一个窗口,当窗口和等于 K 时计数。
1 | def subarraySum(nums, k): |
变体三:滑动窗口 + 双指针
有些问题需要结合双指针技巧,在窗口内进行更复杂的操作。
例题:最小区间( LeetCode 632)
需要在多个有序数组中找到包含每个数组至少一个元素的最小区间。可以用滑动窗口的思想,配合双指针在多个数组间移动。
扩展问题推荐
困难级别:
- 串联所有单词的子串:固定窗口大小,但需要处理单词匹配
- 最小覆盖子串:可变窗口,需要维护字符频率
- 滑动窗口最大值:固定窗口,需要维护最大值(用单调队列)
中等难度:
- 至多包含两个不同字符的最长子串
- 至多包含 K 个不同字符的最长子串
- 替换后的最长重复字符
- 乘积小于 K 的子数组
简单级别:
- 子数组最大平均值
- 大小为 K 且平均值大于等于阈值的子数组数目
实战建议
如何快速识别滑动窗口问题
看到以下关键词时,优先考虑滑动窗口:
- 连续子数组/子串:问题明确要求连续区间
- 最长/最短:需要找到满足条件的最长或最短区间
- 固定长度:窗口大小固定(如长度为 k)
- 包含/不包含:窗口需要包含或不包含某些元素
- 和/积/频率:需要维护窗口内的统计信息
解题步骤
确定窗口类型:
- 固定大小:窗口大小不变,同时移动左右边界
- 可变大小:先扩大窗口,满足条件后缩小
确定窗口状态:
- 需要记录什么信息?(字符频率、和、最大值等)
- 用什么数据结构?(哈希表、数组、变量)
确定收缩条件:
- 什么时候缩小窗口?
- 缩小到什么时候停止?
更新答案的时机:
- 在扩大窗口时更新?
- 在缩小窗口时更新?
- 在满足条件时更新?
常见陷阱与避免方法
陷阱一:窗口边界处理
1 | # 错误:窗口长度计算错误 |
陷阱二:哈希表更新顺序
1 | # 错误:先检查条件,再更新窗口 |
陷阱三: valid 计数错误
1 | # 错误:没有检查是否恰好相等 |
调试技巧
- 打印窗口状态:在关键位置打印
left、right、窗口内容、窗口状态 - 单步调试:使用调试器逐步执行,观察变量变化
- 测试边界情况:空输入、单个元素、所有元素相同、无解情况
- 对比暴力解法:先用暴力法验证思路,再优化为滑动窗口
性能优化建议
空间优化:
- 如果字符集小(如只有小写字母),用数组代替哈希表
- 如果只需要计数,不需要具体字符,用变量代替哈希表
时间优化:
- 使用
valid计数避免每次比较整个字典 - 提前终止:如果已经找到最优解,可以提前结束
- 使用
代码优化:
- 合并相似逻辑,减少重复代码
- 使用更高效的数据结构(如
collections.defaultdict)
总结
滑动窗口是一种强大的算法技巧,特别适合解决连续子区间问题。掌握滑动窗口的要点:
- 理解核心思想:维护一个窗口,通过移动边界遍历所有可能的子区间
- 识别问题模式:固定窗口大小 vs 可变窗口大小,最长 vs 最短
- 掌握模板代码:理解扩大窗口、缩小窗口、更新答案的时机
- 注意边界条件:空输入、窗口大小、边界处理
- 优化技巧:使用 valid 计数、数组代替哈希表等
通过大量练习,你会发现滑动窗口是解决 LeetCode 中很多问题的"万能钥匙"。记住,算法学习是一个循序渐进的过程,多思考、多练习,你一定能掌握这个技巧!
- 本文标题:LeetCode(四)—— 滑动窗口技巧
- 本文作者:Chen Kai
- 创建时间:2020-01-25 15:15:00
- 本文链接:https://www.chenk.top/LeetCode%EF%BC%88%E5%9B%9B%EF%BC%89%E2%80%94%E2%80%94-%E6%BB%91%E5%8A%A8%E7%AA%97%E5%8F%A3%E6%8A%80%E5%B7%A7/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!