如果你曾经写过双重 for 循环来遍历所有连续子数组,那么滑动窗口 很可能就是你缺失的关键优化。它通过复用已计算的结果 ,将原本 $O(nk)$
或 $O(n^2)$
的暴力扫描压缩为一次线性遍历。本文将从基本原理出发,深入剖析这一技巧,并通过四道经典 LeetCode 题目加以演练,最后还会介绍其单调队列变体。
一图看懂滑动窗口# 滑动窗口本质上是在数组或字符串上维护一个连续区间 [left, right]。当窗口移动时,无需重新计算整个区间的值,而是加入右侧新进入的元素,移除左侧离开的元素 。每个元素最多被访问两次(一次进、一次出),因此总时间复杂度为 $O(n)$
。
根据窗口大小是否固定,可分为两类:
固定窗口 :窗口宽度恒为 k,左右指针同步前进。可变窗口 :right 指针负责扩张窗口,left 指针在窗口违反约束时收缩以恢复合法性。下图展示了固定窗口求最大子数组和的过程:绿色单元格代表当前窗口,橙色表示即将进入的元素,粉色表示即将离开的元素。右侧的更新公式清晰地表明——每一步仅需一次加法和一次减法,无需重新调用 sum()。这正是滑动窗口高效的核心所在。
固定窗口:大小为 k 的最大子数组和# 问题描述 :给定数组 arr 和整数 k,找出所有长度为 k 的连续子数组中和的最大值。
朴素解法 :枚举每个长度为 k 的子数组并求和,时间复杂度为 $O(nk)$
。
滑动窗口优化 :先计算第一个窗口的和,随后每次滑动时减去左侧离开的元素,加上右侧新进入的元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def max_sum_subarray ( arr , k ):
"""求长度为 k 的连续子数组的最大和。"""
if len ( arr ) < k :
return None
window_sum = sum ( arr [: k ]) # 初始化第一个窗口的和
best = window_sum
for right in range ( k , len ( arr )):
# 新进入窗口的元素是 arr[right],离开的是 arr[right - k]
window_sum += arr [ right ] - arr [ right - k ]
best = max ( best , window_sum )
return best
Java 实现
1
2
3
4
5
6
7
8
9
10
11
public int maxSumSubarray ( int [] arr , int k ) {
if ( arr . length < k ) return Integer . MIN_VALUE ;
int windowSum = 0 ;
for ( int i = 0 ; i < k ; i ++ ) windowSum += arr [ i ] ; // 初始化窗口和
int best = windowSum ;
for ( int right = k ; right < arr . length ; right ++ ) {
windowSum += arr [ right ] - arr [ right - k ] ; // 滑动窗口更新
best = Math . max ( best , windowSum );
}
return best ;
}
复杂度分析 :时间 $O(n)$
—— 每次滑动仅需一次加法和一次减法;空间 $O(1)$
。
为何有效? 窗口的和由其内部元素集合决定。当窗口右移一位时,集合仅变化一个元素(移出一个、移入一个),因此只需应用这一增量变化。这种增量更新 思想可推广至运行计数、频率哈希表,甚至单调队列(见第 6 节
)。
可变窗口的两种思路# 当窗口大小不固定时,我们使用双指针,并根据问题目标采用不同策略:
寻找最长合法窗口 :不断扩展 right;一旦窗口非法,就收缩 left 直到恢复合法,并在此过程中记录最大长度。寻找最短合法窗口 :先扩展 right 直到窗口首次合法,然后尽可能收缩 left(同时保持合法),并在每次收缩时更新最短长度。下图演示了“无重复字符的最长子串”算法在字符串 "abcabcbb" 上的执行过程:绿色表示合法窗口(正在扩张),粉色表示检测到重复字符,紫色表示正在收缩以修复窗口。
下图则展示了窗口长度随时间的变化:绿色段为扩张阶段,紫色段为收缩阶段。
关键在于:left 指针只会向右移动,绝不会回退 。尽管外层是 for 循环、内层是 while 循环,但 left 在整个算法中的总移动步数不超过 n。这正是滑动窗口能实现摊还 $O(n)$
时间复杂度的根本原因。
LeetCode 3 —— 无重复字符的最长子串#
给定字符串 s,返回其中不含重复字符的最长子串的长度。
模式识别 :可变窗口,目标为最长合法 。约束条件是“窗口内所有字符互异”。当 s[right] 已存在于窗口中时,将 left 直接跳至该字符上次出现位置的下一位。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def length_of_longest_substring ( s ):
"""LeetCode 3:无重复字符的最长子串。"""
last_seen = {} # 记录每个字符最近一次出现的位置
left = 0 # 窗口左边界
best = 0 # 最长子串长度
for right , ch in enumerate ( s ):
if ch in last_seen and last_seen [ ch ] >= left :
# 如果当前字符在窗口内已存在,直接移动左边界
left = last_seen [ ch ] + 1
last_seen [ ch ] = right
best = max ( best , right - left + 1 )
return best
Java 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int lengthOfLongestSubstring ( String s ) {
Map < Character , Integer > lastSeen = new HashMap <> ();
int left = 0 , best = 0 ;
for ( int right = 0 ; right < s . length (); right ++ ) {
char c = s . charAt ( right );
if ( last_seen . containsKey ( c ) && last_seen . get ( c ) >= left ) {
// 跳过重复字符的位置
left = last_seen . get ( c ) + 1 ;
}
last_seen . put ( c , right );
best = Math . max ( best , right - left + 1 );
}
return best ;
}
复杂度分析 :时间 $O(n)$
—— 每个字符最多被插入和删除哈希表各一次;空间 $O(\min(n, |\Sigma|))$
,其中 $|\Sigma|$
为字符集大小。
两种实现风格 :
跳跃式收缩 (如上):left 直接跳过重复字符,代码更简洁。步进式收缩 :维护频次哈希表,通过 while count[s[right]] > 1: drop(s[left]); left += 1 逐步收缩。两者均为 $O(n)$
。跳跃式更短,步进式则更容易扩展到“至多包含 k 种字符”等变体。
LeetCode 76 —— 最小覆盖子串# 给定字符串 s 和 t,返回 s 中能包含 t 所有字符(含重复)的最短子串;若不存在,返回 ""。
模式识别 :可变窗口,目标为最短合法 。约束是“窗口中每种字符的出现次数不少于 t 中的要求”。先扩展 right 直到窗口合法,再尽可能收缩 left。
为避免每次比对两个哈希表,可维护一个 valid 计数器,表示有多少种字符已满足数量要求。当 valid == len(need) 时,窗口即合法。
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
29
30
31
32
33
34
35
36
37
38
39
40
from collections import Counter
def min_window ( s , t ):
"""LeetCode 76:覆盖 t 所有字符的最短子串。"""
if not s or not t or len ( s ) < len ( t ):
return ""
need = Counter ( t ) # 目标字符及其数量
have = {} # 当前窗口中目标字符的数量
valid = 0 # 已满足条件的字符种类数
left = 0 # 窗口左边界
best_len = float ( "inf" ) # 最短窗口长度
best_start = 0 # 最短窗口起始位置
for right , ch in enumerate ( s ):
# 如果当前字符是目标字符,更新其计数
if ch in need :
have [ ch ] = have . get ( ch , 0 ) + 1
# 如果该字符的数量刚好达标,增加 valid
if have [ ch ] == need [ ch ]:
valid += 1
# 当窗口合法时,尝试收缩左边界
while valid == len ( need ):
# 更新最短窗口信息
if right - left + 1 < best_len :
best_len = right - left + 1
best_start = left
# 移除左边界字符
drop = s [ left ]
left += 1
if drop in need :
# 如果移除后即将失衡,提前减少 valid
if have [ drop ] == need [ drop ]:
valid -= 1
have [ drop ] -= 1
# 返回结果,若未找到合法窗口则返回空字符串
return "" if best_len == float ( "inf" ) else s [ best_start : best_start + best_len ]
Java 版本
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
29
30
public String minWindow ( String s , String t ) {
if ( s . length () < t . length ()) return "" ;
Map < Character , Integer > need = new HashMap <> ();
for ( char c : t . toCharArray ()) need . merge ( c , 1 , Integer :: sum );
Map < Character , Integer > have = new HashMap <> ();
int valid = 0 , left = 0 ;
int bestLen = Integer . MAX_VALUE , bestStart = 0 ;
for ( int right = 0 ; right < s . length (); right ++ ) {
char c = s . charAt ( right );
if ( need . containsKey ( c )) {
have . merge ( c , 1 , Integer :: sum );
if ( have . get ( c ). intValue () == need . get ( c ). intValue ()) valid ++ ;
}
// 窗口合法时尝试收缩
while ( valid == need . size ()) {
if ( right - left + 1 < bestLen ) {
bestLen = right - left + 1 ;
bestStart = left ;
}
char d = s . charAt ( left ++ );
if ( need . containsKey ( d )) {
if ( have . get ( d ). intValue () == need . get ( d ). intValue ()) valid -- ;
have . merge ( d , - 1 , Integer :: sum );
}
}
}
return bestLen == Integer . MAX_VALUE ? "" : s . substring ( bestStart , bestStart + bestLen );
}
复杂度分析 :时间 $O(|s| + |t|)$
—— 双指针均单调前进;空间 $O(|\Sigma|)$
。
易错细节 :if have[drop] == need[drop]: valid -= 1 这一行必须在减少 have[drop] 之前 执行。因为我们要在计数跌破阈值前 就减少 valid,否则计数器会与实际状态脱节。
LeetCode 567 —— 字符串的排列# 判断 s2 中是否存在某个子串是 s1 的排列。
模式识别 :固定窗口 ,宽度为 len(s1)。两字符串互为排列当且仅当字符频次完全相同。因此,我们维护 s2 当前窗口的频次向量,并与 s1 的频次向量比较。
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
29
30
31
32
33
34
35
36
37
38
def check_inclusion ( s1 , s2 ):
"""LeetCode 567:s2 是否包含 s1 的某个排列?"""
n , m = len ( s1 ), len ( s2 )
if n > m :
return False
need = [ 0 ] * 26
have = [ 0 ] * 26
for c in s1 :
need [ ord ( c ) - ord ( 'a' )] += 1
for c in s2 [: n ]:
have [ ord ( c ) - ord ( 'a' )] += 1
# 统计当前有多少个字符的频率匹配上了
matches = sum ( 1 for i in range ( 26 ) if need [ i ] == have [ i ])
for right in range ( n , m ):
if matches == 26 :
return True
r = ord ( s2 [ right ]) - ord ( 'a' )
l = ord ( s2 [ right - n ]) - ord ( 'a' )
# 新元素从右边进入窗口
have [ r ] += 1
if have [ r ] == need [ r ]:
matches += 1
elif have [ r ] == need [ r ] + 1 :
matches -= 1
# 旧元素从左边移出窗口
have [ l ] -= 1
if have [ l ] == need [ l ]:
matches += 1
elif have [ l ] == need [ l ] - 1 :
matches -= 1
return matches == 26
Java 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean checkInclusion ( String s1 , String s2 ) {
int n = s1 . length (), m = s2 . length ();
if ( n > m ) return false ;
int [] need = new int [ 26 ] , have = new int [ 26 ] ;
for ( int i = 0 ; i < n ; i ++ ) {
need [ s1 . charAt ( i ) - 'a' ]++ ;
have [ s2 . charAt ( i ) - 'a' ]++ ;
}
int matches = 0 ;
for ( int i = 0 ; i < 26 ; i ++ ) if ( need [ i ] == have [ i ] ) matches ++ ;
for ( int right = n ; right < m ; right ++ ) {
if ( matches == 26 ) return true ;
int r = s2 . charAt ( right ) - 'a' ;
int l = s2 . charAt ( right - n ) - 'a' ;
have [ r ]++ ;
if ( have [ r ] == need [ r ] ) matches ++ ;
else if ( have [ r ] == need [ r ] + 1 ) matches -- ;
have [ l ]-- ;
if ( have [ l ] == need [ l ] ) matches ++ ;
else if ( have [ l ] == need [ l ] - 1 ) matches -- ;
}
return matches == 26 ;
}
复杂度分析 :时间 $O(m)$
—— 每次滑动仅需常数时间更新 matches,无需扫描全部 26 个桶;空间 $O(1)$
。
同一套框架也适用于 LC 438(查找所有字母异位词) ,只需将“提前返回”改为“收集起始索引”即可。
进阶变体:滑动窗口最大值(单调队列)# 当窗口需要查询最大值或最小值 时,简单的“加/减”操作不再足够——移除当前最大值后,我们需要立刻知道下一个最大值是谁。此时标准工具是单调双端队列 :队列中存储的值(或索引)严格递减,队首始终是当前窗口的最大值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from collections import deque
def max_sliding_window ( nums , k ):
"""LeetCode 239:每个长度为 k 的窗口的最大值。"""
dq = deque () # 存储下标;对应的值在 nums 中严格递减
out = []
for i , x in enumerate ( nums ):
# 1. 踢掉队尾所有比 x 小的元素——它们不可能再成为最大值
while dq and nums [ dq [ - 1 ]] < x :
dq . pop ()
dq . append ( i )
# 2. 如果队首已经滑出窗口,直接移除
if dq [ 0 ] <= i - k :
dq . popleft ()
# 3. 窗口形成后,队首就是当前窗口的最大值
if i >= k - 1 :
out . append ( nums [ dq [ 0 ]])
return out
每个索引最多入队和出队一次,总时间复杂度仍为 $O(n)$
,与基础滑动窗口一致。
滑动窗口的适用场景# 下图决策树可帮助你快速判断问题是否适合滑动窗口及其具体形式:
当你遇到以下全部 条件时,应考虑滑动窗口:
答案对应一个连续区间 (子数组 / 子串); 区间的属性可增量维护 (如累加和、频次哈希表、单调队列等); 目标是找固定大小 (k)或满足某种边界条件的最长/最短合法窗口 。 若任一条件不满足(如需非连续选择、全局视角或任意撤销操作),则应尝试前缀和、排序后的双指针、动态规划或堆等其他方法。
三套值得背下来的模板# 固定窗口
1
2
3
4
5
6
7
8
def fixed_window ( arr , k ):
state = init_state ( arr [: k ]) # 例如和、频次表
best = report ( state )
for right in range ( k , len ( arr )):
state = add ( state , arr [ right ])
state = remove ( state , arr [ right - k ])
best = better ( best , report ( state ))
return best
可变窗口——最长合法
1
2
3
4
5
6
7
8
9
10
11
def longest_valid ( arr ):
left = 0
state = empty_state ()
best = 0
for right , x in enumerate ( arr ):
state = add ( state , x )
while not is_valid ( state ):
state = remove ( state , arr [ left ])
left += 1
best = max ( best , right - left + 1 )
return best
可变窗口——最短合法
1
2
3
4
5
6
7
8
9
10
11
def shortest_valid ( arr ):
left = 0
state = empty_state ()
best = float ( "inf" )
for right , x in enumerate ( arr ):
state = add ( state , x )
while is_valid ( state ):
best = min ( best , right - left + 1 )
state = remove ( state , arr [ left ])
left += 1
return best if best != float ( "inf" ) else 0
常见陷阱# 窗口长度计算错误 :正确公式是 right - left + 1,而非 right - left。valid 更新顺序错误 :应在 have[c] 首次等于 need[c] 时增加 valid;在 have[c] 即将低于 need[c] 前减少 valid。用 list.pop(0) 维护固定窗口 :这是 $O(k)$
操作。应改用 collections.deque 或直接通过下标计算,确保 $O(1)$
。重新计算整个窗口 :滑动窗口的核心是增量更新。若内层循环仍遍历整个窗口,复杂度将退化为暴力解法。“和 ≥ target” 遇到负数 :收缩窗口时和可能不减反增,导致“最短合法”模板失效。此时应改用前缀和 + 单调队列或其他思路。练习题# 难度 题号与名称 适用模板 简单 LC 643 子数组最大平均数 I 固定窗口 简单 LC 1456 定长子串中元音的最大数目 固定窗口 中等 LC 3 无重复字符的最长子串 求最长 中等 LC 159 / 340 至多包含 K 个不同字符的最长子串 求最长 中等 LC 209 长度最小的子数组 求最短 中等 LC 424 替换后的最长重复字符 求最长 中等 LC 567 字符串的排列 固定窗口 中等 LC 438 找到字符串中所有字母异位词 固定窗口 中等 LC 713 乘积小于 K 的子数组 求最长 中等 LC 1004 最大连续 1 的个数 III 求最长 困难 LC 76 最小覆盖子串 求最短 困难 LC 239 滑动窗口最大值 单调队列 困难 LC 30 串联所有单词的子串 固定窗口 + 哈希
滑动窗口在真实场景中的应用# 滑动窗口远不止用于面试刷题,它在工程实践中也有广泛应用。以下是几个我亲身经历的案例:
限流算法 :令牌桶和滑动窗口计数器是两大主流方案。“每分钟最多 1000 次请求”的实现通常就是一个时间戳双端队列,从左侧逐出超时记录。redis-cell 和多数 API 网关均采用此模式。实时数据聚合 :监控系统中“过去一小时每分钟错误数”的图表,本质就是事件流上的滑动窗口。Kafka Streams 的 TimeWindow 和 Flink 的滑动窗口,都是对此模式的框架级封装。网络拥塞控制 :TCP 的拥塞窗口(cwnd)是对未确认字节的滑动窗口,而接收方通告窗口则限制了发送方左边界可推进的位置。面试题只是简化版,生产环境中的实现通常以时间戳替代数组下标,以 TTL 过期替代指针递增,但核心思想完全一致——动态维护窗口并高效更新状态。
更地道的 Python 写法# C 风格的 while right < n 循环虽可行,但使用 for right, x in enumerate(arr) 更简洁,还能避免常见的差一错误。对比:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# C 风格
right = 0
while right < len ( arr ):
add ( arr [ right ])
while bad (): remove ( arr [ left ]); left += 1
best = max ( best , right - left + 1 )
right += 1
# Pythonic
left = 0
for right , x in enumerate ( arr ):
add ( x )
while bad ():
remove ( arr [ left ]); left += 1
best = max ( best , right - left + 1 )
后者省去了 right += 1(面试中最易遗漏的语句),也避免了循环体内重复索引 arr[right]。算法逻辑不变,但出错概率显著降低 。
若需维护频次窗口,优先使用 collections.Counter 而非手写字典——其 += 和 -= 语义经过充分测试,可靠且简洁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from collections import Counter
def length_of_longest_substring_k_distinct ( s : str , k : int ) -> int :
cnt = Counter ()
left = best = 0
for right , c in enumerate ( s ):
cnt [ c ] += 1
while len ( cnt ) > k :
cnt [ s [ left ]] -= 1
if cnt [ s [ left ]] == 0 :
del cnt [ s [ left ]]
left += 1
best = max ( best , right - left + 1 )
return best
注意减计数后的 del 操作:这是确保 len(cnt) 准确的关键。若不删除零计数键,“不同字符数”会悄悄包含无效项。
边界用例自查表# 提交前,请在脑中快速过一遍以下情况。滑动窗口题目的“答案错误”大多源于这些边界:
情况 为什么会挂 空字符串 / 空数组 初始化 best 时选 0 还是 float('inf') 至关重要 “至多 K 个不同”中 k = 0 合法窗口应为空,但若代码先扩展 right,可能误报长度为 1 单个元素 > 与 >= 对 best 的判断在此处极易暴露问题元素全相同 “最长”模板通常正常;“最短”模板可能立即塌缩至长度 1 且无法扩展 target 大于 sum(arr)“和 ≥ target 的最短子数组”应返回 0,而非 float('inf') 包含负数 滑动窗口通常假设“添加元素使值单调增长”,负数会破坏此前提,需切换至前缀和 + 单调队列 窗口比数组还大 固定窗口模板必须显式检查 len(arr) < k,否则会越界
花两分钟脑内干跑这些用例,能帮你避开 80% 的坑,省下对着“测试用例 87 错误”发呆的十五分钟。
滑动窗口本质上是一种摊还分析技巧 :通过维护少量状态并在窗口移动时增量更新,避免重复计算。两个单调递增的指针将暴力解法的 $O(n^2)$
优化至 $O(n)$
。
面对连续区间问题时,牢记以下四个问题:
窗口大小是固定 还是可变 ? 若可变,目标是最长 还是最短 合法窗口? 需要维护什么状态 ——和、频次表,还是单调队列? 窗口的约束条件 是什么?哪个指针的移动会破坏它? 厘清这四点后,大多数滑动窗口问题都能归约为模板填充。